Source code for pycsamt.interp.hydro

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Hydrogeophysical interpretation from EM resistivity models.

This module turns an EM-derived :class:`~pycsamt.interp.ResistivityModel`
into hydrogeological deliverables: aquifer targets, clay/saline warnings,
fractured/weathered zones, basement indicators, confidence maps, and
station summaries. It deliberately builds on the existing interpretation
objects instead of duplicating them.
"""

from __future__ import annotations

import csv
from collections.abc import Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Union

import numpy as np

from ..api.property import MetadataMixin, PyCSAMTObject
from ._base import ResistivityModel
from .borehole import Borehole
from .calibrate import ModelCalibrator
from .lithology import RockDatabase, StratigraphicLog

PathLike = Union[str, Path]

__all__ = [
    "AquiferZone",
    "HydroGeophysicalModel",
    "HydroInterpreter",
    "HydroUnit",
]


[docs] @dataclass class HydroUnit(PyCSAMTObject, MetadataMixin): """One classified hydrogeophysical cell.""" x: float z: float rho_ohm_m: float rho_log10: float unit: str lithology: str confidence: float = 1.0 metadata: dict[str, Any] = field(default_factory=dict)
[docs] @dataclass class AquiferZone(PyCSAMTObject, MetadataMixin): """Contiguous aquifer-favourable interval at one station/profile column.""" station: str x: float top: float bottom: float mean_rho_ohm_m: float confidence: float zone_type: str = "aquifer" metadata: dict[str, Any] = field(default_factory=dict)
[docs] @property def thickness(self) -> float: """Interval thickness in metres.""" return float(self.bottom - self.top)
[docs] @dataclass class HydroGeophysicalModel(PyCSAMTObject, MetadataMixin): """Hydrogeological interpretation product for one resistivity model.""" resistivity_model: ResistivityModel unit_map: np.ndarray confidence: np.ndarray zones: list[AquiferZone] = field(default_factory=list) logs: list[StratigraphicLog] = field(default_factory=list) metadata: dict[str, Any] = field(default_factory=dict)
[docs] def aquifer_zones( self, *, min_confidence: float = 0.0 ) -> list[AquiferZone]: """Return aquifer-favourable zones above a confidence threshold.""" return [ zone for zone in self.zones if zone.zone_type == "aquifer" and zone.confidence >= min_confidence ]
[docs] def station_summary(self) -> list[dict[str, Any]]: """Return compact per-station hydrogeological summaries.""" rows: list[dict[str, Any]] = [] model = self.resistivity_model for ix, x in enumerate(model.x_centers): name = _station_name(model, ix) col_units = self.unit_map[:, ix] col_conf = self.confidence[:, ix] aquifer_cells = col_units == "aquifer" clay_cells = np.isin(col_units, ["clay", "saline"]) rows.append( { "station": name, "x_m": float(x), "aquifer_cells": int(np.sum(aquifer_cells)), "clay_or_saline_cells": int(np.sum(clay_cells)), "mean_confidence": float(np.nanmean(col_conf)), "n_zones": sum( 1 for z in self.zones if z.station == name ), } ) return rows
[docs] def to_csv(self, path: PathLike) -> Path: """Write cell-level hydro units and aquifer zones to CSV.""" out = Path(path) out.parent.mkdir(parents=True, exist_ok=True) model = self.resistivity_model with out.open("w", newline="") as fh: writer = csv.writer(fh) writer.writerow( [ "station", "x_m", "z_m", "rho_log10", "rho_ohm_m", "hydro_unit", "confidence", ] ) for ix, x in enumerate(model.x_centers): station = _station_name(model, ix) for iz, z in enumerate(model.z_centers): rho_log = float(model.rho_2d[iz, ix]) writer.writerow( [ station, float(x), float(z), rho_log, float(10.0**rho_log), str(self.unit_map[iz, ix]), float(self.confidence[iz, ix]), ] ) return out
[docs] def zones_to_csv(self, path: PathLike) -> Path: """Write interpreted aquifer/fracture target intervals to CSV.""" out = Path(path) out.parent.mkdir(parents=True, exist_ok=True) with out.open("w", newline="") as fh: writer = csv.writer(fh) writer.writerow( [ "station", "x_m", "top_m", "bottom_m", "thickness_m", "mean_rho_ohm_m", "confidence", "zone_type", ] ) for zone in self.zones: writer.writerow( [ zone.station, zone.x, zone.top, zone.bottom, zone.thickness, zone.mean_rho_ohm_m, zone.confidence, zone.zone_type, ] ) return out
[docs] class HydroInterpreter(PyCSAMTObject): """Rule-based hydrogeophysical interpreter for EM resistivity sections. The default thresholds are conservative and intended as a first-pass screening model. Local calibration can be introduced by passing boreholes, a custom :class:`RockDatabase`, or explicit threshold overrides. """ def __init__( self, *, context: str = "", db: RockDatabase | None = None, water_table_depth: float | None = None, aquifer_range: tuple[float, float] = (5.0, 300.0), fracture_range: tuple[float, float] = (50.0, 800.0), clay_max: float = 20.0, saline_max: float = 3.0, basement_min: float = 1000.0, min_zone_thickness: float = 1.0, calibration_ptol: float = 0.10, max_borehole_distance: float = 500.0, ) -> None: self.context = context self.db = db if db is not None else RockDatabase.default() self.water_table_depth = water_table_depth self.aquifer_range = tuple(float(v) for v in aquifer_range) self.fracture_range = tuple(float(v) for v in fracture_range) self.clay_max = float(clay_max) self.saline_max = float(saline_max) self.basement_min = float(basement_min) self.min_zone_thickness = float(min_zone_thickness) self.calibration_ptol = float(calibration_ptol) self.max_borehole_distance = float(max_borehole_distance) self._result: HydroGeophysicalModel | None = None
[docs] def fit( self, model: Any, *, boreholes: Sequence[Borehole] | None = None, ) -> HydroGeophysicalModel: """Interpret a resistivity model or inversion result.""" res_model = _coerce_resistivity_model(model) source_method = res_model.method if boreholes: calibrator = ModelCalibrator( ptol=self.calibration_ptol, max_borehole_distance=self.max_borehole_distance, db=self.db, verbose=False, ).fit(res_model, boreholes) res_model = calibrator.calibrated_model() unit_map = np.empty(res_model.rho_2d.shape, dtype=object) confidence = np.full(res_model.rho_2d.shape, np.nan, dtype=float) for ix in range(res_model.n_x): for iz in range(res_model.n_z): rho_log = float(res_model.rho_2d[iz, ix]) z = float(res_model.z_centers[iz]) unit, conf = self._classify_cell(10.0**rho_log, z) unit_map[iz, ix] = unit confidence[iz, ix] = conf logs = _logs_from_model(res_model, self.db) zones = self._extract_zones(res_model, unit_map, confidence) out = HydroGeophysicalModel( resistivity_model=res_model, unit_map=unit_map, confidence=confidence, zones=zones, logs=logs, metadata={ "context": self.context, "source_method": source_method, "calibrated": bool(boreholes), "thresholds": { "aquifer_range": self.aquifer_range, "fracture_range": self.fracture_range, "clay_max": self.clay_max, "saline_max": self.saline_max, "basement_min": self.basement_min, "water_table_depth": self.water_table_depth, }, }, ) self._result = out return out
[docs] def aquifer_zones( self, *, min_confidence: float = 0.0 ) -> list[AquiferZone]: """Return aquifer zones from the last fitted model.""" if self._result is None: raise RuntimeError("HydroInterpreter.fit must be called first.") return self._result.aquifer_zones(min_confidence=min_confidence)
def _classify_cell(self, rho: float, z: float) -> tuple[str, float]: if not np.isfinite(rho) or rho <= 0: return "unknown", 0.0 if rho <= self.saline_max: return "saline", _low_range_confidence(rho, self.saline_max) if rho <= self.clay_max: return "clay", _low_range_confidence(rho, self.clay_max) if self.water_table_depth is not None and z < self.water_table_depth: if rho < self.basement_min: return "vadose/weathered", 0.45 aq_lo, aq_hi = self.aquifer_range if aq_lo <= rho <= aq_hi: return "aquifer", _range_confidence(rho, aq_lo, aq_hi) fr_lo, fr_hi = self.fracture_range if fr_lo <= rho <= fr_hi: return "fractured/weathered", 0.65 * _range_confidence( rho, fr_lo, fr_hi ) if rho >= self.basement_min: return "resistive basement", min( 1.0, np.log10(rho / self.basement_min + 1.0) ) return "transition", 0.35 def _extract_zones( self, model: ResistivityModel, unit_map: np.ndarray, confidence: np.ndarray, ) -> list[AquiferZone]: zones: list[AquiferZone] = [] z_edges = _depth_edges(model.z_centers) for ix, x in enumerate(model.x_centers): station = _station_name(model, ix) mask = unit_map[:, ix] == "aquifer" start: int | None = None for iz, is_aquifer in enumerate(np.r_[mask, False]): if is_aquifer and start is None: start = iz elif not is_aquifer and start is not None: stop = iz top = float(z_edges[start]) bottom = float(z_edges[stop]) if bottom - top >= self.min_zone_thickness: rho_vals = 10.0 ** model.rho_2d[start:stop, ix] zones.append( AquiferZone( station=station, x=float(x), top=top, bottom=bottom, mean_rho_ohm_m=float(np.nanmean(rho_vals)), confidence=float( np.nanmean(confidence[start:stop, ix]) ), zone_type="aquifer", ) ) start = None return zones
def _coerce_resistivity_model(model: Any) -> ResistivityModel: if isinstance(model, ResistivityModel): return model if hasattr(model, "to_resistivity_model"): return model.to_resistivity_model() if isinstance(model, dict) and "rho_2d" in model: return ResistivityModel.from_array( model["rho_2d"], model.get("x_centers"), model.get("z_centers"), station_x=model.get("station_x"), station_names=model.get("station_names"), method=str(model.get("method", "generic")), rms=float(model.get("rms", float("nan"))), ) raise TypeError( "model must be a ResistivityModel or expose to_resistivity_model()." ) def _logs_from_model( model: ResistivityModel, db: RockDatabase ) -> list[StratigraphicLog]: logs: list[StratigraphicLog] = [] station_x = model.station_x if len(model.station_x) else model.x_centers names = model.station_names or [ f"S{i:03d}" for i in range(len(station_x)) ] for ix, x in enumerate(station_x): col_idx = int(np.argmin(np.abs(model.x_centers - x))) logs.append( StratigraphicLog.from_column( station_name=names[ix] if ix < len(names) else f"S{ix:03d}", x=float(x), z_centers=model.z_centers, rho_log10=model.rho_2d[:, col_idx], db=db, ) ) return logs def _station_name(model: ResistivityModel, ix: int) -> str: if model.station_names and ix < len(model.station_names): return str(model.station_names[ix]) return f"S{ix:03d}" def _depth_edges(z_centers: np.ndarray) -> np.ndarray: z = np.asarray(z_centers, dtype=float) if z.size == 1: dz = max(float(z[0]), 1.0) return np.array([max(0.0, z[0] - 0.5 * dz), z[0] + 0.5 * dz]) mids = 0.5 * (z[:-1] + z[1:]) first = max(0.0, z[0] - (mids[0] - z[0])) last = z[-1] + (z[-1] - mids[-1]) return np.r_[first, mids, last] def _range_confidence(value: float, low: float, high: float) -> float: if high <= low: return 0.5 center = np.sqrt(low * high) half_width = max(np.log10(high) - np.log10(center), 1e-6) dist = abs(np.log10(value) - np.log10(center)) return float(np.clip(1.0 - 0.5 * dist / half_width, 0.35, 1.0)) def _low_range_confidence(value: float, high: float) -> float: return float(np.clip(1.0 - 0.25 * value / max(high, 1e-12), 0.5, 1.0))