Source code for pycsamt.interp.plot

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""plot — visualization for pycsamt.interp results.

Three plot classes cover the main interpretation deliverables:

* :class:`PlotStratigraphicLog` — single-station pseudo-stratigraphic
  log in the style of Fig. 5d / Fig. 7 of Kouadio et al. (2022).
* :class:`PlotFenceDiagram` — multi-station panel with all logs
  arranged along the profile.
* :class:`PlotCalibratedModel` — side-by-side CRM vs NM with the
  misfit G (%) map overlaid.

All classes follow the same pattern::

    fig = PlotStratigraphicLog(log).plot()
    fig.savefig("S17_log.png", dpi=200)
"""

from __future__ import annotations

from collections.abc import Sequence
from typing import Optional, Union

import numpy as np

from ..api.interp import (
    HydroProfileStyle,
    HydroSectionStyle,
    InterpStyle,
    resolve_figsize,
    resolve_profile_style,
    resolve_section_style,
)
from ._base import ResistivityModel
from .lithology import StratigraphicLog

# Type alias accepted by the style= parameter of every hydro plot class.
_StyleArg = Optional[
    Union[str, InterpStyle, HydroSectionStyle, HydroProfileStyle]
]

__all__ = [
    "PlotStratigraphicLog",
    "PlotFenceDiagram",
    "PlotCalibratedModel",
    # hydro-geophysics plots
    "PlotHydroSection",
    "PlotWaterTableProfile",
    "PlotTimeLapseSection",
    # uncertainty plots
    "PlotUncertaintySection",
    "PlotUncertaintyProfile",
    # new diagnostic / novel plots
    "PlotPetrophysicalCrossPlot",
    "PlotAquiferCharacterization",
    "PlotMultiTimeLapseGrid",
    "PlotResistivityDepthProfile",
    "PlotUncertaintyHistogram",
]


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _require_mpl():
    try:
        import matplotlib
        import matplotlib.pyplot as plt

        return matplotlib, plt
    except ImportError as exc:
        raise ImportError(
            "matplotlib is required for pycsamt.interp.plot"
        ) from exc


def _cmap_with_bad(name: str, nan_color: str):
    """Return a copy of colourmap *name* with NaN cells rendered as *nan_color*."""
    import matplotlib.pyplot as plt

    cmap = plt.get_cmap(name).copy()
    cmap.set_bad(color=nan_color)
    return cmap


def _hatch_for(lithology: str) -> str:
    """Return a matplotlib hatch string keyed on lithology name."""
    name = lithology.lower()
    if "granite" in name or "igneous" in name or "basement" in name:
        return "+"
    if "fractured" in name or "fault" in name:
        return "x"
    if "aquifer" in name or "water" in name:
        return "o"
    if "clay" in name or "shale" in name:
        return "---"
    if "sand" in name or "alluvium" in name:
        return "..."
    if "basalt" in name or "gabbro" in name:
        return "///"
    if "limestone" in name or "dolomite" in name or "marble" in name:
        return r"\\\\"
    if "schist" in name or "gneiss" in name or "quartzite" in name:
        return "|||"
    if "ore" in name or "sulfide" in name or "graphite" in name:
        return "**"
    return ""


# ---------------------------------------------------------------------------
# PlotStratigraphicLog
# ---------------------------------------------------------------------------


[docs] class PlotStratigraphicLog: """Single-station pseudo-stratigraphic log. Reproduces the two-panel layout of Fig. 5d / Fig. 7 in Kouadio et al. (2022): * **Left panel** — colour / hatch blocks for each geological layer with lithology annotations and thickness values. * **Right panel** — log₁₀(ρ) depth curve overlaid on the same depth axis. Parameters ---------- log : StratigraphicLog figsize : tuple depth_unit : str Label for the depth axis (default ``'m'``). title : str, optional annotation_kws : dict, optional Extra keyword arguments passed to ``ax.annotate``. """ def __init__( self, log: StratigraphicLog, *, figsize: tuple[float, float] = (8, 10), depth_unit: str = "m", title: str | None = None, annotation_kws: dict | None = None, ) -> None: self.log = log self.figsize = figsize self.depth_unit = depth_unit self.title = title or f"Pseudo-Stratigraphic Log — {log.station_name}" self.annotation_kws = annotation_kws or {"fontsize": 8}
[docs] def plot(self): """Render and return the matplotlib Figure.""" _, plt = _require_mpl() log = self.log fig, (ax_log, ax_rho) = plt.subplots( 1, 2, figsize=self.figsize, sharey=True, gridspec_kw={"width_ratios": [1, 1.4]}, ) # ── left: stratigraphic column ────────────────────────────── for layer in log.layers: ax_log.barh( y=(layer.top + layer.bottom) / 2, width=1.0, height=layer.thickness, color=layer.color, alpha=0.75, hatch=_hatch_for(layer.lithology), edgecolor="0.3", linewidth=0.5, ) mid = (layer.top + layer.bottom) / 2 label = f"{layer.lithology}\n({layer.thickness:.1f} {self.depth_unit})" ax_log.annotate( label, xy=(0.5, mid), xycoords=("axes fraction", "data"), ha="center", va="center", **self.annotation_kws, ) ax_log.set_xlim(0, 1) ax_log.set_ylim(log.z_centers[-1] + 10, -5) ax_log.set_xticks([]) ax_log.set_ylabel(f"Depth ({self.depth_unit})") ax_log.set_title("Lithology", fontsize=9) ax_log.invert_yaxis() # ── right: resistivity curve ───────────────────────────────── valid = ~np.isnan(log.rho_log10) ax_rho.plot( log.rho_log10[valid], log.z_centers[valid], color="0.2", linewidth=1.4, zorder=3, ) ax_rho.fill_betweenx( log.z_centers[valid], log.rho_log10[valid], alpha=0.12, color="steelblue", ) for layer in log.layers: ax_rho.axhline( layer.top, color="0.55", linewidth=0.6, linestyle="--" ) ax_rho.set_xlabel(r"$\log_{10}(\rho\ /\ \Omega\mathrm{m})$") ax_rho.set_title("Resistivity", fontsize=9) ax_rho.grid(axis="x", alpha=0.3) fig.suptitle(self.title, fontweight="bold", y=1.01) fig.tight_layout() return fig
# --------------------------------------------------------------------------- # PlotFenceDiagram # ---------------------------------------------------------------------------
[docs] class PlotFenceDiagram: """Multi-station fence diagram of pseudo-stratigraphic logs. Plots every log as a vertical panel side by side, sharing the depth axis, so the lateral geological evolution along the profile is immediately visible. Parameters ---------- logs : list of StratigraphicLog Ordered list of station logs (West → East, or South → North). figsize : tuple, optional Defaults to ``(2 * n_logs, 10)``. title : str, optional max_depth : float, optional Truncate display at this depth (metres). """ def __init__( self, logs: Sequence[StratigraphicLog], *, figsize: tuple[float, float] | None = None, title: str = "Fence Diagram", max_depth: float | None = None, ) -> None: self.logs = list(logs) self.figsize = figsize or (2 * len(logs), 10) self.title = title self.max_depth = max_depth
[docs] def plot(self): """Render and return the matplotlib Figure.""" _, plt = _require_mpl() n = len(self.logs) if n == 0: raise ValueError("No logs provided.") fig, axes = plt.subplots( 1, n, figsize=self.figsize, sharey=True, gridspec_kw={"wspace": 0.05}, ) if n == 1: axes = [axes] z_max = self.max_depth or max(log.z_centers[-1] for log in self.logs) for ax, log in zip(axes, self.logs): for layer in log.layers: if layer.top > z_max: continue bottom = min(layer.bottom, z_max) ax.barh( y=(layer.top + bottom) / 2, width=1.0, height=bottom - layer.top, color=layer.color, alpha=0.80, hatch=_hatch_for(layer.lithology), edgecolor="0.3", linewidth=0.4, ) ax.set_xlim(0, 1) ax.set_ylim(z_max + 10, -5) ax.set_xticks([]) ax.set_title(log.station_name, fontsize=7, pad=3) ax.invert_yaxis() axes[0].set_ylabel("Depth (m)") fig.suptitle(self.title, fontweight="bold") fig.tight_layout() return fig
# --------------------------------------------------------------------------- # PlotCalibratedModel # ---------------------------------------------------------------------------
[docs] class PlotCalibratedModel: """Compare CRM vs NM and display the G (%) misfit map. Three sub-plots stacked vertically: 1. CRM — original inversion result (log₁₀ρ colour image) 2. NM — calibrated New Model (same colour scale) 3. Misfit G (%) — diverging colour scale highlighting where the model was corrected the most Parameters ---------- crm : ResistivityModel Original CRM. nm : ResistivityModel Calibrated NM from :meth:`~ModelCalibrator.calibrated_model`. misfit_map : ndarray (n_z, n_x), optional G (%) array from :meth:`~ModelCalibrator.misfit_map`. If ``None``, computed from the difference between *nm* and *crm*. figsize : tuple cmap_rho : str Matplotlib colourmap for the resistivity panels. vmin_rho, vmax_rho : float Colour-scale limits for log₁₀(ρ). title : str, optional """ def __init__( self, crm: ResistivityModel, nm: ResistivityModel, misfit_map: np.ndarray | None = None, *, figsize: tuple[float, float] = (12, 10), cmap_rho: str = "jet", vmin_rho: float = 1.0, vmax_rho: float = 5.0, title: str | None = None, ) -> None: self.crm = crm self.nm = nm if misfit_map is not None: self._misfit = np.asarray(misfit_map) else: diff = nm.rho_2d - crm.rho_2d with np.errstate(divide="ignore", invalid="ignore"): self._misfit = ( 100.0 * np.abs(diff) / np.maximum(np.abs(crm.rho_2d), 1e-12) ) self.figsize = figsize self.cmap_rho = cmap_rho self.vmin_rho = vmin_rho self.vmax_rho = vmax_rho self.title = title or "CRM vs Calibrated NM"
[docs] def plot(self): """Render and return the matplotlib Figure.""" _, plt = _require_mpl() fig, axes = plt.subplots(3, 1, figsize=self.figsize, sharex=True) ax_crm, ax_nm, ax_g = axes x = self.crm.x_centers z = self.crm.z_centers extent = [x[0], x[-1], z[-1], z[0]] def _rho_im(ax, data, label): im = ax.imshow( data, aspect="auto", extent=extent, cmap=self.cmap_rho, vmin=self.vmin_rho, vmax=self.vmax_rho, origin="upper", ) ax.set_ylabel("Depth (m)") ax.set_title(label, fontsize=9) plt.colorbar( im, ax=ax, label=r"$\log_{10}(\rho)$", fraction=0.03, pad=0.01 ) _rho_im(ax_crm, self.crm.rho_2d, "CRM — Inversion result") _rho_im(ax_nm, self.nm.rho_2d, "NM — Calibrated model") # Misfit panel g_clip = np.clip(self._misfit, 0, 10) im_g = ax_g.imshow( g_clip, aspect="auto", extent=extent, cmap="RdYlBu_r", vmin=0, vmax=10, origin="upper", ) ax_g.set_ylabel("Depth (m)") ax_g.set_xlabel("Profile distance (m)") ax_g.set_title("Misfit G (%)", fontsize=9) plt.colorbar(im_g, ax=ax_g, label="G (%)", fraction=0.03, pad=0.01) # Station markers on all panels if len(self.crm.station_x): for ax in axes: for sx in self.crm.station_x: ax.axvline(sx, color="k", linewidth=0.4, alpha=0.5) fig.suptitle(self.title, fontweight="bold") fig.tight_layout() return fig
# --------------------------------------------------------------------------- # PlotHydroSection # ---------------------------------------------------------------------------
[docs] class PlotHydroSection: """2-D hydrogeological section from an :class:`~pycsamt.interp.hydromodel.EMHydroResult`. Renders a colour-image cross-section of one quantitative hydro map (hydraulic conductivity K, water saturation Sw, or porosity φ) with optional overlays: * **Water-table line** — dashed blue line at the estimated WT depth. * **Station markers** — thin vertical tick-marks at each profile station. Parameters ---------- result : EMHydroResult Quantitative hydro output from :class:`~pycsamt.interp.hydromodel.EMHydroModel`. quantity : str Which map to display: ``'K'`` — hydraulic conductivity (log₁₀ scale), ``'saturation'`` — water saturation Sw, ``'porosity'`` — effective porosity φ. cmap : str Matplotlib colourmap. Defaults: K → ``'viridis'``, Sw → ``'RdYlBu'``, φ → ``'YlOrRd'``. vmin, vmax : float, optional Colour-scale limits. Auto-derived from the data if ``None``. show_water_table : bool Overlay the water-table depth profile (default ``True``). figsize : tuple title : str, optional depth_min : float, optional Start display at this depth (m). Use to zoom past near-surface artefacts and push a shallow water-table line into view. depth_max : float, optional Truncate display at this depth (m). Examples -------- >>> fig = PlotHydroSection(result, quantity='K').plot() >>> fig = PlotHydroSection(result, quantity='saturation', ... cmap='Blues', vmin=0, vmax=1).plot() """ _LABELS: dict = { "K": r"$\log_{10}(K\ /\ \mathrm{m\,s^{-1}})$", "saturation": r"Water saturation $S_w$", "porosity": r"Porosity $\phi$", } def __init__( self, result, quantity: str = "K", *, style: _StyleArg = None, cmap: str | None = None, vmin: float | None = None, vmax: float | None = None, show_water_table: bool = True, figsize: tuple[float, float] | None = None, title: str | None = None, depth_min: float | None = None, depth_max: float | None = None, ) -> None: if quantity not in self._LABELS: raise ValueError( f"quantity must be one of {list(self._LABELS)}, got {quantity!r}." ) self.result = result self.quantity = quantity self.style = style self.cmap = cmap self.vmin = vmin self.vmax = vmax self.show_water_table = show_water_table self.figsize = figsize self.title = title self.depth_min = depth_min self.depth_max = depth_max
[docs] def plot(self): """Render and return the matplotlib Figure.""" _, plt = _require_mpl() sty = resolve_section_style(self.style) result = self.result model = result.resistivity_model if self.quantity == "K": raw = result.hydraulic_K data = np.log10(np.where(raw > 0, raw, np.nan)) elif self.quantity == "saturation": data = result.saturation else: data = result.porosity z, x = model.z_centers, model.x_centers z0 = self.depth_min if self.depth_min is not None else float(z[0]) dz = self.depth_max if self.depth_max is not None else float(z[-1]) z_mask = (z >= z0) & (z <= dz) data_plot, z_plot = data[z_mask, :], z[z_mask] vmin = ( self.vmin if self.vmin is not None else float(np.nanpercentile(data_plot, 2)) ) vmax = ( self.vmax if self.vmax is not None else float(np.nanpercentile(data_plot, 98)) ) cmap = _cmap_with_bad( self.cmap if self.cmap is not None else sty.cmap_for(self.quantity), sty.nan_color, ) fsz = resolve_figsize(self.figsize, self.style, "section") extent = [x[0], x[-1], z_plot[-1], z_plot[0]] fig, ax = plt.subplots(figsize=fsz, layout="constrained") im = ax.imshow( data_plot, aspect="auto", extent=extent, cmap=cmap, vmin=vmin, vmax=vmax, origin="upper", ) cb = fig.colorbar( im, ax=ax, label=self._LABELS[self.quantity], **sty.cb_kwargs() ) cb.ax.tick_params(labelsize=sty.cb_fontsize) if self.show_water_table: wt_plot = np.where( np.isfinite(result.water_table), result.water_table, np.nan ) ax.plot(x, wt_plot, **sty.wt_kwargs(), label="Water table") ax.legend(fontsize=8, loc="lower right") # enforce depth clip on the y-axis (imshow extent sets the image bounds # but does not clip when depth_min > z[0], so we set limits explicitly) ax.set_ylim(dz, z0) if len(model.station_x): for sx in model.station_x: ax.axvline(sx, **sty.station_kwargs()) ax.set_xlabel("Profile distance (m)") ax.set_ylabel("Depth (m)") ax.set_title( self.title or f"Hydro section — {self.quantity} [{result.method_tag}]", fontsize=10, ) return fig
# --------------------------------------------------------------------------- # PlotWaterTableProfile # ---------------------------------------------------------------------------
[docs] class PlotWaterTableProfile: """Profile plot: water-table depth and transmissivity along the section. Two stacked panels share the same x-axis (profile distance): * **Top** — water-table depth (m) as a stem/bar plot. Shallower is better; the y-axis is inverted so deep values plot downward. * **Bottom** — transmissivity T (m²/s) on a log₁₀ scale. Parameters ---------- result : EMHydroResult figsize : tuple color_wt : str Colour for the water-table bars (default ``'steelblue'``). color_T : str Colour for the transmissivity bars (default ``'seagreen'``). reference_depth : float, optional Draw a horizontal dashed line at this depth on the WT panel (e.g. a known piezometric level). title : str, optional Examples -------- >>> fig = PlotWaterTableProfile(result, reference_depth=20.0).plot() """ def __init__( self, result, *, style: _StyleArg = None, color_wt: str | None = None, color_T: str | None = None, reference_depth: float | None = None, figsize: tuple[float, float] | None = None, title: str | None = None, ) -> None: self.result = result self.style = style self.color_wt = color_wt self.color_T = color_T self.reference_depth = reference_depth self.figsize = figsize self.title = title
[docs] def plot(self): """Render and return the matplotlib Figure.""" _, plt = _require_mpl() sty = resolve_profile_style(self.style) result = self.result model = result.resistivity_model x = model.x_centers cwt = self.color_wt if self.color_wt is not None else sty.color_wt cT = self.color_T if self.color_T is not None else sty.color_T fsz = resolve_figsize(self.figsize, self.style, "profile") wt = result.water_table T_log = np.log10( np.where(result.transmissivity > 0, result.transmissivity, np.nan) ) fig, (ax_wt, ax_T) = plt.subplots( 2, 1, figsize=fsz, sharex=True, layout="constrained", gridspec_kw={"height_ratios": [1, 1]}, ) # ── water table panel ───────────────────────────────────────────── valid_wt = np.isfinite(wt) ax_wt.bar( x[valid_wt], wt[valid_wt], width=np.diff(x).mean() * 0.7, color=cwt, alpha=0.75, edgecolor="none", ) ax_wt.scatter( x[valid_wt], wt[valid_wt], **sty.scatter_kwargs(cwt, s=18) ) if self.reference_depth is not None: ax_wt.axhline( self.reference_depth, **sty.ref_kwargs(), label=f"Ref. {self.reference_depth} m", ) ax_wt.legend(fontsize=8) ax_wt.set_ylabel("Water-table depth (m)") ax_wt.invert_yaxis() ax_wt.grid(**sty.grid_kwargs()) ax_wt.set_title( self.title or f"Water table & transmissivity [{result.method_tag}]", fontsize=10, ) # ── transmissivity panel ────────────────────────────────────────── valid_T = np.isfinite(T_log) ax_T.bar( x[valid_T], T_log[valid_T], width=np.diff(x).mean() * 0.7, color=cT, alpha=0.75, edgecolor="none", bottom=T_log[valid_T].min() - 0.5 if valid_T.any() else 0, ) ax_T.scatter( x[valid_T], T_log[valid_T], **sty.scatter_kwargs(cT, s=18) ) ax_T.set_ylabel(r"$\log_{10}(T\ /\ \mathrm{m^2\,s^{-1}})$") ax_T.set_xlabel("Profile distance (m)") ax_T.grid(**sty.grid_kwargs()) if len(model.station_x): for sx in model.station_x: ax_wt.axvline(sx, **sty.station_kwargs()) ax_T.axvline(sx, **sty.station_kwargs()) return fig
# --------------------------------------------------------------------------- # PlotTimeLapseSection # ---------------------------------------------------------------------------
[docs] class PlotTimeLapseSection: """Difference section for time-lapse EM monitoring. Shows Δlog₁₀(ρ) or Δ*Sw* between a selected survey and the baseline as a diverging colour image, allowing rapid visual identification of zones that became more conductive (wetting) or more resistive (drying). Parameters ---------- timelapse : TimeLapseEM Time-lapse container holding all surveys. quantity : str ``'rho'`` — raw resistivity change Δlog₁₀ρ (default). ``'saturation'`` — saturation change ΔSw (requires *petro* and *rho_w*). survey_idx : int Index of the comparison survey in ``timelapse.surveys`` relative to the baseline (default 0 → first non-baseline survey). baseline_idx : int Index of the baseline survey (default 0). petro : ArchieModel, optional Required when ``quantity='saturation'``. rho_w : float Pore-water resistivity for saturation inversion (default 20 Ω·m). phi : float or ndarray Porosity for Archie inversion (default 0.25). cmap : str Diverging colourmap (default ``'RdBu_r'``). Positive (blue) → resistivity increase / drying. Negative (red) → resistivity decrease / wetting. vmax : float, optional Symmetric colour-scale limit. Auto-derived from the 98th percentile of ``|data|`` if ``None``. show_water_table : bool Overlay the water-table profile from the *comparison* survey. figsize : tuple title : str, optional depth_max : float, optional Examples -------- >>> from pycsamt.interp.timelapse import TimeLapseEM >>> tl = TimeLapseEM([model_dry, model_wet], labels=['dry', 'wet']) >>> fig = PlotTimeLapseSection(tl, quantity='rho').plot() >>> fig = PlotTimeLapseSection( ... tl, quantity='saturation', ... petro=ArchieModel(), rho_w=20.0 ... ).plot() """ def __init__( self, timelapse, quantity: str = "rho", *, style: _StyleArg = None, survey_idx: int = 0, baseline_idx: int = 0, petro=None, rho_w: float = 20.0, phi: float | np.ndarray = 0.25, cmap: str | None = None, vmax: float | None = None, show_water_table: bool = True, figsize: tuple[float, float] | None = None, title: str | None = None, depth_max: float | None = None, ) -> None: if quantity not in ("rho", "saturation"): raise ValueError("quantity must be 'rho' or 'saturation'.") if quantity == "saturation" and petro is None: raise ValueError( "petro (ArchieModel) is required for quantity='saturation'." ) self.timelapse = timelapse self.quantity = quantity self.style = style self.survey_idx = survey_idx self.baseline_idx = baseline_idx self.petro = petro self.rho_w = rho_w self.phi = phi self.cmap = cmap self.vmax = vmax self.show_water_table = show_water_table self.figsize = figsize self.title = title self.depth_max = depth_max
[docs] def plot(self): """Render and return the matplotlib Figure.""" _, plt = _require_mpl() sty = resolve_section_style(self.style) tl = self.timelapse if self.quantity == "rho": deltas = tl.resistivity_change(baseline_idx=self.baseline_idx) cb_label = r"$\Delta\log_{10}(\rho\ /\ \Omega\mathrm{m})$" cmap = self.cmap if self.cmap is not None else sty.cmap_timelapse else: deltas = tl.saturation_change( self.petro, phi=self.phi, rho_w=self.rho_w, baseline_idx=self.baseline_idx, ) cb_label = r"$\Delta S_w$" cmap = self.cmap if self.cmap is not None else sty.cmap_timelapse if self.survey_idx >= len(deltas): raise IndexError( f"survey_idx={self.survey_idx} out of range; " f"only {len(deltas)} comparison surveys available." ) delta = deltas[self.survey_idx] others = [i for i in range(tl.n_surveys) if i != self.baseline_idx] comp_label = tl.labels[others[self.survey_idx]] base_label = tl.labels[self.baseline_idx] ref_model = tl.surveys[self.baseline_idx] x, z = ref_model.x_centers, ref_model.z_centers dz = self.depth_max or float(z[-1]) z_mask = z <= dz data_plot = delta[z_mask, :] abs_max = float(np.nanpercentile(np.abs(data_plot), 98)) vmax = self.vmax if self.vmax is not None else max(abs_max, 1e-6) fsz = resolve_figsize(self.figsize, self.style, "section") extent = [x[0], x[-1], z[z_mask][-1], z[z_mask][0]] cmap = _cmap_with_bad(cmap, sty.nan_color) fig, ax = plt.subplots(figsize=fsz, layout="constrained") im = ax.imshow( data_plot, aspect="auto", extent=extent, cmap=cmap, vmin=-vmax, vmax=vmax, origin="upper", ) cb = fig.colorbar(im, ax=ax, label=cb_label, **sty.cb_kwargs()) cb.ax.tick_params(labelsize=sty.cb_fontsize) if self.show_water_table: comp_survey = tl.surveys[others[self.survey_idx]] from .petrophysics import ArchieModel as _Archie from .petrophysics import water_table_from_profile _archie = self.petro if self.petro is not None else _Archie() wt = np.array( [ (lambda d: d if d is not None else np.nan)( water_table_from_profile( comp_survey.rho_2d[:, ix], comp_survey.z_centers, _archie, rho_w=self.rho_w, ) ) for ix in range(comp_survey.n_x) ] ) ax.plot(x, wt, **sty.wt_kwargs(), label=f"WT ({comp_label})") ax.legend(fontsize=8, loc="lower right") if len(ref_model.station_x): for sx in ref_model.station_x: ax.axvline(sx, **sty.station_kwargs()) ax.set_xlabel("Profile distance (m)") ax.set_ylabel("Depth (m)") ax.set_title( self.title or ( f"Time-lapse section — {self.quantity}: " f"{comp_label}{base_label}" ), fontsize=10, ) return fig
# --------------------------------------------------------------------------- # PlotUncertaintySection # ---------------------------------------------------------------------------
[docs] class PlotUncertaintySection: """Two-panel section showing the P50 estimate and the uncertainty spread. Panel layout: * **Top** — P50 (median) of the selected quantity as a colour image. * **Bottom** — Uncertainty spread: either the coefficient of variation (CV = std/mean, for K) or the P90–P10 range (for Sw, porosity). Parameters ---------- unc : UncertaintyResult quantity : str ``'K'`` (default) — hydraulic conductivity (log₁₀ scale for P50, CV for spread). ``'saturation'`` — Sw (P50 and P90–P10 range). ``'porosity'`` — φ (P50 and P90–P10 range). cmap_p50 : str Colourmap for the P50 panel (defaults mirror :class:`PlotHydroSection`). cmap_spread : str Colourmap for the spread panel (default ``'hot_r'`` — dark = high uncertainty). vmin_p50, vmax_p50 : float, optional Colour limits for P50 panel. vmax_spread : float, optional Upper colour limit for the spread panel. Auto if ``None``. show_water_table : bool Overlay median water-table on both panels (default ``True``). figsize : tuple title : str, optional depth_min : float, optional Start display at this depth (m) — mirrors :class:`PlotHydroSection`. depth_max : float, optional Examples -------- >>> fig = PlotUncertaintySection(unc_result, quantity='K').plot() >>> fig = PlotUncertaintySection(unc_result, quantity='saturation', ... depth_max=200.0).plot() """ _META = { "K": { "p50_cmap": "viridis", "spread_label": "CV (std / mean K)", "p50_label": r"$\log_{10}(K_{P50}\ /\ \mathrm{m\,s^{-1}})$", }, "saturation": { "p50_cmap": "RdYlBu", "spread_label": r"$S_w$ P90 − P10", "p50_label": r"$S_{w,P50}$", }, "porosity": { "p50_cmap": "YlOrRd", "spread_label": r"$\phi$ P90 − P10", "p50_label": r"$\phi_{P50}$", }, } def __init__( self, unc, quantity: str = "K", *, style: _StyleArg = None, cmap_p50: str | None = None, cmap_spread: str | None = None, vmin_p50: float | None = None, vmax_p50: float | None = None, vmax_spread: float | None = None, show_water_table: bool = True, figsize: tuple[float, float] | None = None, title: str | None = None, depth_min: float | None = None, depth_max: float | None = None, ) -> None: if quantity not in self._META: raise ValueError(f"quantity must be one of {list(self._META)}.") self.unc = unc self.quantity = quantity self.style = style self.cmap_p50 = cmap_p50 self.cmap_spread = cmap_spread self.vmin_p50 = vmin_p50 self.vmax_p50 = vmax_p50 self.vmax_spread = vmax_spread self.show_water_table = show_water_table self.figsize = figsize self.title = title self.depth_min = depth_min self.depth_max = depth_max
[docs] def plot(self): """Render and return the matplotlib Figure.""" _, plt = _require_mpl() sty = resolve_section_style(self.style) unc = self.unc model = unc.resistivity_model meta = self._META[self.quantity] x, z = model.x_centers, model.z_centers z0 = self.depth_min if self.depth_min is not None else float(z[0]) dz = self.depth_max if self.depth_max is not None else float(z[-1]) zm = (z >= z0) & (z <= dz) cmap_p50 = _cmap_with_bad( self.cmap_p50 or sty.cmap_for(self.quantity), sty.nan_color ) cmap_spread = _cmap_with_bad( self.cmap_spread or sty.cmap_spread, sty.nan_color ) fsz = resolve_figsize(self.figsize, self.style, "uncertainty") if self.quantity == "K": p50_raw = unc.p50_K[zm, :] p50_plot = np.log10(np.where(p50_raw > 0, p50_raw, np.nan)) spread = unc.cv_K[zm, :] elif self.quantity == "saturation": p50_plot = unc.mean_Sw[zm, :] spread = unc.p90_Sw[zm, :] - unc.p10_Sw[zm, :] else: p50_plot = unc.mean_phi[zm, :] spread = unc.std_phi[zm, :] * 2.0 vmin_p = ( self.vmin_p50 if self.vmin_p50 is not None else float(np.nanpercentile(p50_plot, 2)) ) vmax_p = ( self.vmax_p50 if self.vmax_p50 is not None else float(np.nanpercentile(p50_plot, 98)) ) vmax_sp = ( self.vmax_spread if self.vmax_spread is not None else float(np.nanpercentile(spread, 98)) ) extent = [x[0], x[-1], z[zm][-1], z[zm][0]] fig, (ax_p50, ax_sp) = plt.subplots( 2, 1, figsize=fsz, sharex=True, layout="constrained", gridspec_kw={"height_ratios": [1, 1]}, ) im1 = ax_p50.imshow( p50_plot, aspect="auto", extent=extent, cmap=cmap_p50, vmin=vmin_p, vmax=vmax_p, origin="upper", ) cb1 = fig.colorbar( im1, ax=ax_p50, label=meta["p50_label"], **sty.cb_kwargs() ) cb1.ax.tick_params(labelsize=sty.cb_fontsize) ax_p50.set_ylabel("Depth (m)") ax_p50.set_title("P50 estimate", fontsize=9) im2 = ax_sp.imshow( spread, aspect="auto", extent=extent, cmap=cmap_spread, vmin=0, vmax=vmax_sp, origin="upper", ) cb2 = fig.colorbar( im2, ax=ax_sp, label=meta["spread_label"], **sty.cb_kwargs() ) cb2.ax.tick_params(labelsize=sty.cb_fontsize) ax_sp.set_ylabel("Depth (m)") ax_sp.set_xlabel("Profile distance (m)") ax_sp.set_title("Uncertainty spread", fontsize=9) if self.show_water_table: wt = np.where(np.isfinite(unc.p50_wt), unc.p50_wt, np.nan) for ax in (ax_p50, ax_sp): ax.plot(x, wt, **sty.wt_kwargs(), label="WT P50") ax_p50.legend(fontsize=8, loc="lower right") for ax in (ax_p50, ax_sp): ax.set_ylim(dz, z0) if len(model.station_x): for sx in model.station_x: ax_p50.axvline(sx, **sty.station_kwargs()) ax_sp.axvline(sx, **sty.station_kwargs()) fig.suptitle( self.title or ( f"Uncertainty section — {self.quantity} " f"[{unc.method_tag} N={unc.n_samples}]" ), fontweight="bold", ) return fig
# --------------------------------------------------------------------------- # PlotUncertaintyProfile # ---------------------------------------------------------------------------
[docs] class PlotUncertaintyProfile: """Profile plot: water-table depth and transmissivity with P10–P90 envelopes. Four data series are shown along the profile x-axis: * **Top panel** — water-table depth. Shaded band = P10–P90 range; solid line = P50 median. Optional reference depth. * **Bottom panel** — log₁₀(T). Shaded band = P10–P90; solid line = P50. Parameters ---------- unc : UncertaintyResult figsize : tuple color_wt : str Colour for the water-table envelope (default ``'steelblue'``). color_T : str Colour for the transmissivity envelope (default ``'seagreen'``). reference_depth : float, optional Horizontal reference line on the WT panel. title : str, optional Examples -------- >>> fig = PlotUncertaintyProfile(unc_result, reference_depth=20.0).plot() """ def __init__( self, unc, *, style: _StyleArg = None, color_wt: str | None = None, color_T: str | None = None, reference_depth: float | None = None, figsize: tuple[float, float] | None = None, title: str | None = None, ) -> None: self.unc = unc self.style = style self.color_wt = color_wt self.color_T = color_T self.reference_depth = reference_depth self.figsize = figsize self.title = title
[docs] def plot(self): """Render and return the matplotlib Figure.""" _, plt = _require_mpl() sty = resolve_profile_style(self.style) unc = self.unc model = unc.resistivity_model x = model.x_centers cwt = self.color_wt if self.color_wt is not None else sty.color_wt cT = self.color_T if self.color_T is not None else sty.color_T fsz = resolve_figsize(self.figsize, self.style, "profile") p10_wt, p50_wt, p90_wt = unc.p10_wt, unc.p50_wt, unc.p90_wt p10_T = np.log10(np.where(unc.p10_T > 0, unc.p10_T, np.nan)) p50_T = np.log10(np.where(unc.p50_T > 0, unc.p50_T, np.nan)) p90_T = np.log10(np.where(unc.p90_T > 0, unc.p90_T, np.nan)) fig, (ax_wt, ax_T) = plt.subplots( 2, 1, figsize=fsz, sharex=True, layout="constrained", gridspec_kw={"height_ratios": [1, 1]}, ) # ── WT panel ────────────────────────────────────────────────────── ax_wt.fill_between( x, p10_wt, p90_wt, **sty.envelope_kwargs(cwt), label="P10–P90" ) ax_wt.plot(x, p50_wt, **sty.line_kwargs(cwt), label="P50") ax_wt.scatter(x, p50_wt, **sty.scatter_kwargs(cwt)) if self.reference_depth is not None: ax_wt.axhline( self.reference_depth, **sty.ref_kwargs(), label=f"Ref. {self.reference_depth} m", ) ax_wt.set_ylabel("Water-table depth (m)") ax_wt.invert_yaxis() ax_wt.legend(fontsize=8, ncol=3) ax_wt.grid(**sty.grid_kwargs()) ax_wt.set_title( self.title or ( f"WT & T uncertainty profile " f"[{unc.method_tag} N={unc.n_samples}]" ), fontsize=10, ) # ── T panel ─────────────────────────────────────────────────────── ax_T.fill_between( x, p10_T, p90_T, **sty.envelope_kwargs(cT), label="P10–P90" ) ax_T.plot(x, p50_T, **sty.line_kwargs(cT), label="P50") ax_T.scatter(x, p50_T, **sty.scatter_kwargs(cT)) ax_T.set_ylabel(r"$\log_{10}(T_{P50}\ /\ \mathrm{m^2\,s^{-1}})$") ax_T.set_xlabel("Profile distance (m)") ax_T.legend(fontsize=8) ax_T.grid(**sty.grid_kwargs()) if len(model.station_x): for sx in model.station_x: ax_wt.axvline(sx, **sty.station_kwargs()) ax_T.axvline(sx, **sty.station_kwargs()) return fig
# --------------------------------------------------------------------------- # PlotPetrophysicalCrossPlot # ---------------------------------------------------------------------------
[docs] class PlotPetrophysicalCrossPlot: """ρ vs φ scatter colored by Sw with the fitted Archie/WS model curve. Reproduces the cross-plot of Fig. 3b/3c in Chen et al. (2026) for EM data. Each point is one model cell; the colour encodes water saturation (or depth). The petrophysical model curve is drawn at the mean observed saturation. Hashin-Shtrikman bounds are optionally shown as a shaded envelope — no existing tool combines all three in one panel. Parameters ---------- result : EMHydroResult petro : ArchieModel or WaxmanSmitsModel, optional Defaults to ``result.config.petro``. color_by : str ``'saturation'`` (default) or ``'depth'``. show_hs_bounds : bool Overlay Hashin-Shtrikman bounds (default ``True``). rho_matrix : float Rock-matrix resistivity for HS bounds (Ω·m; default 5 000). depth_range : (float, float), optional Restrict to this depth window (m). Sw_for_curve : float, optional Sw value used to draw the model curve. Defaults to mean(Sw). log_rho : bool Log₁₀ scale on the y-axis (default ``True``). style, figsize, title """ def __init__( self, result, *, style: _StyleArg = None, petro=None, color_by: str = "saturation", show_hs_bounds: bool = True, rho_matrix: float = 5000.0, depth_range: tuple[float, float] | None = None, Sw_for_curve: float | None = None, log_rho: bool = True, figsize: tuple[float, float] | None = None, title: str | None = None, ) -> None: self.result = result self.style = style self.petro = petro self.color_by = color_by self.show_hs_bounds = show_hs_bounds self.rho_matrix = rho_matrix self.depth_range = depth_range self.Sw_for_curve = Sw_for_curve self.log_rho = log_rho self.figsize = figsize self.title = title
[docs] def plot(self): """Render and return the matplotlib Figure.""" _, plt = _require_mpl() from .petrophysics import ( ArchieModel, HashinShtrikmanBounds, ) sty = resolve_section_style(self.style) res = self.result model = res.resistivity_model cfg = res.config petro = self.petro if self.petro is not None else cfg.petro rho_w = cfg.rho_w fsz = resolve_figsize(self.figsize, self.style, "crossplot") rho_lin = 10.0**model.rho_2d z_2d = np.broadcast_to( model.z_centers[:, np.newaxis], rho_lin.shape ).copy() mask = np.ones(rho_lin.shape, dtype=bool) if self.depth_range is not None: z_lo, z_hi = self.depth_range mask = (z_2d >= z_lo) & (z_2d <= z_hi) phi_f = res.porosity[mask].ravel() rho_f = rho_lin[mask].ravel() Sw_f = res.saturation[mask].ravel() z_f = z_2d[mask].ravel() ok = ( np.isfinite(rho_f) & np.isfinite(phi_f) & (rho_f > 0) & (phi_f > 0) ) phi_f, rho_f, Sw_f, z_f = phi_f[ok], rho_f[ok], Sw_f[ok], z_f[ok] c_vals = Sw_f if self.color_by == "saturation" else z_f c_label = r"$S_w$" if self.color_by == "saturation" else "Depth (m)" y_vals = np.log10(rho_f) if self.log_rho else rho_f y_label = ( r"$\log_{10}(\rho\ /\ \Omega\mathrm{m})$" if self.log_rho else r"$\rho$ (Ω·m)" ) fig, ax = plt.subplots(figsize=fsz, layout="constrained") if not len(phi_f): ax.set_xlabel(r"Porosity $\phi$") ax.set_ylabel(y_label) ax.set_title( self.title or f"Petrophysical cross-plot [{res.method_tag}]", fontsize=10, ) ax.text( 0.5, 0.5, "No data in selected depth range", ha="center", va="center", transform=ax.transAxes, fontsize=11, color="0.5", ) return fig if self.show_hs_bounds: phi_r = np.linspace( max(float(phi_f.min()), 0.01), min(float(phi_f.max()), 0.70), 120, ) hs = HashinShtrikmanBounds( rho_matrix=self.rho_matrix, rho_fluid=rho_w ) r_lo, r_hi = hs.bounds(phi_r) y_lo = np.log10(r_lo) if self.log_rho else r_lo y_hi = np.log10(r_hi) if self.log_rho else r_hi ax.fill_between( phi_r, y_lo, y_hi, label="HS bounds", **sty.hs_fill_kwargs() ) sc = ax.scatter( phi_f, y_vals, c=c_vals, **sty.crossplot_scatter_kwargs() ) cb = fig.colorbar(sc, ax=ax, label=c_label, **sty.cb_kwargs()) cb.ax.tick_params(labelsize=sty.cb_fontsize) Sw_c = ( self.Sw_for_curve if self.Sw_for_curve is not None else float(np.nanmean(Sw_f)) ) phi_c = np.linspace( max(float(phi_f.min()), 0.02), min(float(phi_f.max()), 0.70), 200 ) if isinstance(petro, ArchieModel): rho_c = petro.forward(phi=phi_c, Sw=Sw_c, rho_w=rho_w) lbl_c = f"Archie (Sw={Sw_c:.2f})" else: sigma_w = 1e3 / rho_w rho_c = petro.forward(phi=phi_c, Sw=Sw_c, sigma_w=sigma_w) lbl_c = f"WS (Sw={Sw_c:.2f})" y_c = np.log10(np.clip(rho_c, 1e-2, 1e7)) if self.log_rho else rho_c ax.plot(phi_c, y_c, label=lbl_c, **sty.model_curve_kwargs()) ax.set_xlabel(r"Porosity $\phi$") ax.set_ylabel(y_label) ax.legend(fontsize=8) ax.grid(alpha=0.25) ax.set_title( self.title or f"Petrophysical cross-plot [{res.method_tag}]", fontsize=10, ) return fig
# --------------------------------------------------------------------------- # PlotAquiferCharacterization # ---------------------------------------------------------------------------
[docs] class PlotAquiferCharacterization: """Dar-Zarrouk profile — TR, S, water table, and transmissivity. Three or four stacked panels with a shared profile-distance x-axis: 1. **TR** = Σρᵢhᵢ (Ω·m²) — aquifer productivity indicator. Threshold line at ``sty.tr_threshold``. 2. **S** = Σhᵢ/ρᵢ (siemens) — clay-seal protective capacity. Narain-Mehrotra class lines at ``sty.s_threshold_moderate`` and ``sty.s_threshold_good``. 3. **WT** — water-table depth (m). 4. **T** — log₁₀(T) (m²/s), optional. Parameters ---------- result : EMHydroResult show_transmissivity : bool Add a fourth T panel (default ``True``). log_TR : bool Use a log₁₀ y-axis for the TR panel (default ``True``). Recommended when a resistive basement dominates TR and compresses the productive-aquifer bars to near-zero on a linear scale. reference_depth : float, optional Horizontal dashed line on the WT panel. style, figsize, title """ def __init__( self, result, *, style: _StyleArg = None, show_transmissivity: bool = True, log_TR: bool = True, reference_depth: float | None = None, figsize: tuple[float, float] | None = None, title: str | None = None, ) -> None: self.result = result self.style = style self.show_transmissivity = show_transmissivity self.log_TR = log_TR self.reference_depth = reference_depth self.figsize = figsize self.title = title
[docs] def plot(self): """Render and return the matplotlib Figure.""" _, plt = _require_mpl() sty = resolve_profile_style(self.style) result = self.result model = result.resistivity_model x = model.x_centers fsz = resolve_figsize(self.figsize, self.style, "aquifer_char") n_pan = 4 if self.show_transmissivity else 3 fig, axes = plt.subplots( n_pan, 1, figsize=fsz, sharex=True, layout="constrained", gridspec_kw={"height_ratios": [1] * n_pan}, ) ax_TR, ax_S, ax_wt = axes[0], axes[1], axes[2] ax_T = axes[3] if self.show_transmissivity else None bar_w = np.diff(x).mean() * 0.7 if len(x) > 1 else 50.0 # 1 — Transverse Resistance TR = result.dar_zarrouk_TR if self.log_TR: tr_plot = np.log10(np.where(TR > 0, TR, np.nan)) tr_thr = np.log10(sty.tr_threshold) tr_ylabel = r"$\log_{10}(\mathrm{TR}\ /\ \Omega\mathrm{m}^2)$" else: tr_plot = TR tr_thr = sty.tr_threshold tr_ylabel = "TR (Ω·m²)" vld_tr = np.isfinite(tr_plot) ax_TR.bar( x[vld_tr], tr_plot[vld_tr], width=bar_w, **sty.bar_kwargs(sty.color_TR), ) ax_TR.axhline( tr_thr, color="crimson", lw=1.2, ls="--", label=f">{sty.tr_threshold:.0f} Ω·m² (productive)", ) ax_TR.set_ylabel(tr_ylabel) ax_TR.legend(fontsize=7) ax_TR.grid(**sty.grid_kwargs()) ax_TR.set_title( self.title or f"Aquifer characterization [{result.method_tag}]", fontsize=10, ) # 2 — Longitudinal Conductance + protection classes ax_S.bar( x, result.dar_zarrouk_S, width=bar_w, **sty.bar_kwargs(sty.color_S), ) for thr, lbl, ls in [ (sty.s_threshold_moderate, "Poor | Moderate", ":"), (sty.s_threshold_good, "Moderate | Good", "--"), ]: ax_S.axhline(thr, color="0.35", lw=1.0, ls=ls, label=lbl) ax_S.set_ylabel("S (siemens)") ax_S.legend(fontsize=7, ncol=2) ax_S.grid(**sty.grid_kwargs()) # 3 — Water Table wt = result.water_table valid = np.isfinite(wt) ax_wt.bar( x[valid], wt[valid], width=bar_w, **sty.bar_kwargs(sty.color_wt) ) ax_wt.scatter( x[valid], wt[valid], **sty.scatter_kwargs(sty.color_wt, s=16) ) if self.reference_depth is not None: ax_wt.axhline( self.reference_depth, **sty.ref_kwargs(), label=f"Ref. {self.reference_depth} m", ) ax_wt.legend(fontsize=7) ax_wt.set_ylabel("WT depth (m)") ax_wt.invert_yaxis() ax_wt.grid(**sty.grid_kwargs()) # 4 — Transmissivity if ax_T is not None: T_log = np.log10( np.where( result.transmissivity > 0, result.transmissivity, np.nan ) ) vld = np.isfinite(T_log) ax_T.bar( x[vld], T_log[vld], width=bar_w, **sty.bar_kwargs(sty.color_T) ) ax_T.scatter( x[vld], T_log[vld], **sty.scatter_kwargs(sty.color_T, s=16) ) ax_T.set_ylabel(r"$\log_{10}(T)$") ax_T.set_xlabel("Profile distance (m)") ax_T.grid(**sty.grid_kwargs()) else: ax_wt.set_xlabel("Profile distance (m)") if len(model.station_x): for sx in model.station_x: for ax in axes: ax.axvline(sx, **sty.station_kwargs()) return fig
# --------------------------------------------------------------------------- # PlotMultiTimeLapseGrid # ---------------------------------------------------------------------------
[docs] class PlotMultiTimeLapseGrid: """Grid of EM sections at successive time steps — Fig. 5c/5d equivalent. N mini-sections in one row, shared colourbar on the right. Parameters ---------- timelapse : TimeLapseEM quantity : str ``'rho'`` — absolute log₁₀ρ. ``'delta_rho'`` — Δlog₁₀ρ from baseline (diverging). ``'delta_saturation'`` — ΔSw from baseline (requires *petro*). surveys : list of int, optional Survey indices to show (default: all). baseline_idx : int Baseline for delta quantities (default 0). petro, rho_w, phi Petrophysics for saturation conversion. vmin, vmax : float, optional depth_max : float, optional figsize_panel : (w, h), optional Size of each mini panel. style, title """ def __init__( self, timelapse, quantity: str = "rho", *, style: _StyleArg = None, surveys: list[int] | None = None, baseline_idx: int = 0, petro=None, rho_w: float = 20.0, phi: float = 0.25, vmin: float | None = None, vmax: float | None = None, depth_max: float | None = None, figsize_panel: tuple[float, float] | None = None, title: str | None = None, ) -> None: if quantity not in ("rho", "delta_rho", "delta_saturation"): raise ValueError( "quantity must be 'rho', 'delta_rho', or 'delta_saturation'." ) if quantity == "delta_saturation" and petro is None: raise ValueError( "petro (ArchieModel) required for 'delta_saturation'." ) self.timelapse = timelapse self.quantity = quantity self.style = style self.surveys = surveys self.baseline_idx = baseline_idx self.petro = petro self.rho_w = rho_w self.phi = phi self.vmin = vmin self.vmax = vmax self.depth_max = depth_max self.figsize_panel = figsize_panel self.title = title
[docs] def plot(self): """Render and return the matplotlib Figure.""" _, plt = _require_mpl() sty = resolve_section_style(self.style) tl = self.timelapse idx_list = ( self.surveys if self.surveys is not None else list(range(tl.n_surveys)) ) n_panels = len(idx_list) if not n_panels: raise ValueError("surveys list is empty.") pw, ph = ( self.figsize_panel if self.figsize_panel is not None else resolve_figsize(None, self.style, "tl_panel") ) fsz = (pw * n_panels + 1.2, ph) is_delta = self.quantity != "rho" ref_m = tl.surveys[self.baseline_idx] z, x = ref_m.z_centers, ref_m.x_centers dz = self.depth_max or float(z[-1]) z_mask = z <= dz panels: list[np.ndarray] = [] for si in idx_list: surv = tl.surveys[si] if self.quantity == "rho": data = surv.rho_2d[z_mask, :] elif self.quantity == "delta_rho": data = (surv.rho_2d - ref_m.rho_2d)[z_mask, :] else: from .petrophysics import ( ArchieModel as _Archie, ) archie = self.petro if self.petro is not None else _Archie() data = ( archie.saturation(10.0**surv.rho_2d, self.phi, self.rho_w) - archie.saturation( 10.0**ref_m.rho_2d, self.phi, self.rho_w ) )[z_mask, :] panels.append(data) stack = np.stack(panels, axis=0) if is_delta: amax = float(np.nanpercentile(np.abs(stack), 98)) vmax = self.vmax if self.vmax is not None else max(amax, 1e-6) vmin = self.vmin if self.vmin is not None else -vmax cmap = _cmap_with_bad(sty.cmap_timelapse, sty.nan_color) else: vmin = ( self.vmin if self.vmin is not None else float(np.nanpercentile(stack, 2)) ) vmax = ( self.vmax if self.vmax is not None else float(np.nanpercentile(stack, 98)) ) cmap = _cmap_with_bad(sty.cmap_K, sty.nan_color) extent = [x[0], x[-1], z[z_mask][-1], z[z_mask][0]] fig, axes = plt.subplots( 1, n_panels, figsize=fsz, sharey=True, layout="constrained", gridspec_kw={"wspace": 0.04}, ) if n_panels == 1: axes = [axes] im_last = None for ax, data, si in zip(axes, panels, idx_list): im_last = ax.imshow( data, aspect="auto", extent=extent, cmap=cmap, vmin=vmin, vmax=vmax, origin="upper", ) ax.set_title(tl.labels[si], fontsize=8) ax.set_xlabel("Distance (m)", fontsize=7) ax.tick_params(labelsize=7) axes[0].set_ylabel("Depth (m)") cb_label = { "rho": r"$\log_{10}(\rho)$", "delta_rho": r"$\Delta\log_{10}(\rho)$", "delta_saturation": r"$\Delta S_w$", }[self.quantity] fig.colorbar( im_last, ax=axes, label=cb_label, fraction=0.015, pad=0.01 ) if len(ref_m.station_x): for ax in axes: for sx in ref_m.station_x: ax.axvline(sx, **sty.station_kwargs()) fig.suptitle( self.title or f"Time-lapse grid — {self.quantity}", fontsize=10, fontweight="bold", ) return fig
# --------------------------------------------------------------------------- # PlotResistivityDepthProfile # ---------------------------------------------------------------------------
[docs] class PlotResistivityDepthProfile: """1-D resistivity depth profile with zone shading — Fig. 3a equivalent. Plots the EM inversion ρ(z) curve at one station with a fill and optional hydraulic zone shading derived from an :class:`~pycsamt.interp.hydromodel.EMHydroResult`. Parameters ---------- source : ResistivityModel or EMHydroResult station : str or int Station name or column index. depth_max : float, optional show_zones : bool Shade aquifer / vadose / basement zones from EMHydroResult (``True``). borehole : Borehole, optional If given, a narrow borehole panel is added on the right. log_rho : bool Log-scale x-axis (default ``True``). style, figsize, title """ _ZONE_COLORS = { "aquifer": "#aaddff", "fractured/weathered": "#d0eedd", "vadose/weathered": "#ffffcc", "resistive basement": "#d3d3d3", "clay": "#f5deb3", "saline": "#f4a460", } def __init__( self, source, station: str | int = 0, *, style: _StyleArg = None, depth_max: float | None = None, show_zones: bool = True, borehole=None, log_rho: bool = True, figsize: tuple[float, float] | None = None, title: str | None = None, ) -> None: self.source = source self.station = station self.style = style self.depth_max = depth_max self.show_zones = show_zones self.borehole = borehole self.log_rho = log_rho self.figsize = figsize self.title = title
[docs] def plot(self): """Render and return the matplotlib Figure.""" _, plt = _require_mpl() sty = resolve_section_style(self.style) fsz = resolve_figsize(self.figsize, self.style, "depth_profile") from .hydromodel import EMHydroResult has_hydro = isinstance(self.source, EMHydroResult) model = self.source.resistivity_model if has_hydro else self.source n_panels = 2 if self.borehole is not None else 1 fig, axes = plt.subplots( 1, n_panels, figsize=fsz, sharey=True, layout="constrained", gridspec_kw={"width_ratios": [3, 1]} if n_panels == 2 else None, ) ax = axes[0] if n_panels == 2 else axes # ── column resolution ────────────────────────────────────── if isinstance(self.station, str): ix = model.station_names.index(self.station) else: ix = int(self.station) name = ( model.station_names[ix] if model.station_names and ix < len(model.station_names) else f"S{ix:03d}" ) z_all = model.z_centers dz = ( self.depth_max if self.depth_max is not None else float(z_all[-1]) ) z_mask = z_all <= dz z = z_all[z_mask] rho_l = model.rho_2d[z_mask, ix] # ── zone shading from EMHydroResult ──────────────────────── if has_hydro and self.show_zones: Sw_col = self.source.saturation[z_mask, ix] for iz in range(len(z) - 1): sw = Sw_col[iz] if sw >= 0.85: color = self._ZONE_COLORS["aquifer"] elif sw < 0.25: color = self._ZONE_COLORS["resistive basement"] else: color = self._ZONE_COLORS["vadose/weathered"] ax.axhspan( z[iz], z[iz + 1], color=color, alpha=0.28, zorder=0 ) # ── ρ curve ──────────────────────────────────────────────── x_vals = rho_l if self.log_rho else 10.0**rho_l ax.plot(x_vals, z, **sty.rho_curve_kwargs()) ax.fill_betweenx( z, x_vals, color=sty.rho_fill_color, alpha=sty.rho_fill_alpha ) ax.invert_yaxis() ax.set_xlabel( r"$\log_{10}(\rho\ /\ \Omega\mathrm{m})$" if self.log_rho else r"$\rho$ (Ω·m)" ) ax.set_ylabel("Depth (m)") ax.grid(alpha=0.25, axis="x") ax.set_title(self.title or f"ρ depth profile — {name}", fontsize=10) # ── optional borehole panel ──────────────────────────────── if self.borehole is not None and n_panels == 2: ax_bh = axes[1] for intv in getattr(self.borehole, "intervals", []): top = getattr(intv, "top", None) bottom = getattr(intv, "bottom", None) color = getattr(intv, "color", "0.7") label = getattr(intv, "lithology", "") if top is not None and bottom is not None: ax_bh.barh( y=(top + bottom) / 2, width=1.0, height=bottom - top, color=color, alpha=0.80, edgecolor="0.4", ) ax_bh.annotate( label, xy=(0.5, (top + bottom) / 2), xycoords=("axes fraction", "data"), ha="center", va="center", fontsize=6, ) ax_bh.set_xlim(0, 1) ax_bh.set_xticks([]) ax_bh.set_title("Borehole", fontsize=8) return fig
# --------------------------------------------------------------------------- # PlotUncertaintyHistogram # ---------------------------------------------------------------------------
[docs] class PlotUncertaintyHistogram: """Posterior histograms of key hydro quantities per station. Shows the full MC posterior for water-table depth or transmissivity at up to 6 stations, with histogram bars, optional KDE, and P10/P50/P90 vertical lines. When raw ensemble arrays are not passed, a Gaussian approximation is drawn from the stored statistics. Parameters ---------- unc : UncertaintyResult quantity : str ``'water_table'`` (default) or ``'transmissivity'``. stations : list of str or int, optional Stations to display (default: up to 6 evenly spaced). wt_ensemble : ndarray (n_samples, n_x), optional T_ensemble : ndarray (n_samples, n_x), optional show_kde : bool show_percentiles : bool log_x : bool, optional Log scale (default ``True`` for T, ``False`` for WT). ncols : int Subplot columns (default min(n_sta, 3)). style, figsize, title """ def __init__( self, unc, quantity: str = "water_table", *, style: _StyleArg = None, stations: list[str | int] | None = None, wt_ensemble: np.ndarray | None = None, T_ensemble: np.ndarray | None = None, show_kde: bool = True, show_percentiles: bool = True, log_x: bool | None = None, ncols: int | None = None, figsize: tuple[float, float] | None = None, title: str | None = None, ) -> None: if quantity not in ("water_table", "transmissivity"): raise ValueError( "quantity must be 'water_table' or 'transmissivity'." ) self.unc = unc self.quantity = quantity self.style = style self.stations = stations self.wt_ensemble = wt_ensemble self.T_ensemble = T_ensemble self.show_kde = show_kde self.show_percentiles = show_percentiles self.log_x = log_x self.ncols = ncols self.figsize = figsize self.title = title
[docs] def plot(self): """Render and return the matplotlib Figure.""" _, plt = _require_mpl() sty = resolve_profile_style(self.style) unc = self.unc model = unc.resistivity_model n_x = model.n_x names = ( model.station_names if model.station_names else [f"S{i:03d}" for i in range(n_x)] ) if self.stations is None: step = max(1, n_x // 6) idx_list = list(range(0, n_x, step))[:6] else: idx_list = [ names.index(s) if isinstance(s, str) else int(s) for s in self.stations ] n_sta = len(idx_list) n_cols = self.ncols if self.ncols is not None else min(n_sta, 3) n_rows = int(np.ceil(n_sta / n_cols)) fsz = self.figsize or (4.0 * n_cols, 3.5 * n_rows) fig, axes = plt.subplots( n_rows, n_cols, figsize=fsz, layout="constrained" ) axes_flat = np.array(axes).ravel() if n_sta > 1 else [axes] is_T = self.quantity == "transmissivity" is_log = self.log_x if self.log_x is not None else is_T ens = self.T_ensemble if is_T else self.wt_ensemble mean_a = unc.mean_T if is_T else unc.mean_wt std_a = unc.std_T if is_T else unc.std_wt p10_a = unc.p10_T if is_T else unc.p10_wt p50_a = unc.p50_T if is_T else unc.p50_wt p90_a = unc.p90_T if is_T else unc.p90_wt color = sty.color_T if is_T else sty.color_wt for plot_i, (ax, ix) in enumerate(zip(axes_flat, idx_list)): name = names[ix] if ix < len(names) else f"S{ix:03d}" if ens is not None: raw = ens[:, ix] raw = raw[np.isfinite(raw)] if is_log: raw = np.log10(np.where(raw > 0, raw, np.nan)) raw = raw[np.isfinite(raw)] else: mu = ( np.log10(max(mean_a[ix], 1e-20)) if is_log else mean_a[ix] ) sig = max(std_a[ix], 1e-12) raw = np.random.default_rng(42 + ix).normal(mu, sig, 500) if len(raw) < 2: ax.set_visible(False) continue ax.hist(raw, **sty.hist_kwargs(color)) if self.show_kde: try: from scipy.stats import gaussian_kde kde = gaussian_kde(raw) xg = np.linspace(raw.min(), raw.max(), 200) ax.plot(xg, kde(xg), **sty.kde_kwargs()) except Exception: pass if self.show_percentiles: for val, ls, lbl in [ (p10_a[ix], ":", "P10"), (p50_a[ix], "-", "P50"), (p90_a[ix], ":", "P90"), ]: v = np.log10(max(val, 1e-20)) if is_log else val if np.isfinite(v): ax.axvline( v, color=sty.ref_color, lw=1.0, ls=ls, label=lbl ) if plot_i == 0: ax.legend(fontsize=6) ax.set_title(name, fontsize=8) ax.set_xlabel( r"$\log_{10}(T)$" if is_log and is_T else "WT depth (m)", fontsize=7, ) ax.set_ylabel("Density", fontsize=7) ax.tick_params(labelsize=7) for ax in axes_flat[n_sta:]: ax.set_visible(False) fig.suptitle( self.title or ( f"Posterior histogram — {self.quantity.replace('_', ' ')} " f"[N={unc.n_samples}]" ), fontsize=10, fontweight="bold", ) return fig