Source code for pycsamt.agents.resistivity_map

"""
pycsamt.agents.resistivity_map
================================

:class:`ResistivityMapAgent` — Horizontal depth-slice resistivity maps.

Assembles pseudo-3D resistivity volumes from per-station 1-D (or 2-D profile)
inversion results.  At each requested depth, per-station log₁₀ρ values are
scattered onto a 2-D grid and interpolated with linear or IDW methods to
produce a plan-view map.

Typical use: feed the ``predictions`` output of
:class:`~pycsamt.agents.AIInversionAgent`, :class:`~pycsamt.agents.Inv3DAgent`,
or any compatible inversion result together with station coordinates.
"""

from __future__ import annotations

import time
from typing import Any

import numpy as np

from ._base import AgentResult, BaseAgent

_SYSTEM_PROMPT = """\
You are an expert in pseudo-3D resistivity interpretation from MT surveys.
Given a set of horizontal resistivity maps, write 4-5 sentences that:
1. Describe the dominant resistivity pattern at each depth level.
2. Identify lateral contrasts that suggest geological boundaries or structures.
3. Note any stations that appear anomalous at specific depths.
4. Discuss the reliability of the interpolation given station spacing.
5. Recommend drilling targets or geological follow-up based on the maps.
Reply in plain scientific English.
"""


[docs] class ResistivityMapAgent(BaseAgent): """Build horizontal resistivity depth-slice maps from 1-D inversion results. Parameters ---------- api_key, model, llm_provider : str depth_indices : list[int] or None Layer indices to map (0-based). ``None`` maps 3 evenly spaced layers. interp_method : {'linear', 'nearest', 'idw'} Interpolation method for gridding station values (default ``'linear'``). grid_n : int Number of grid cells per axis (default 50). Input keys ---------- ``predictions`` : dict {station: ndarray} — log₁₀ρ per layer (from AIInversionAgent, Inv3DAgent, etc.) ``station_coords`` : dict {station: (x, y)} or ndarray (n_sta, 2) — metres ``depths_km`` : ndarray — depth axis (km) ``depth_indices`` : list[int], optional ``output_dir`` : str, optional Output data keys ---------------- ``depth_maps`` list[dict] — per-depth: {'depth_km', 'grid_x', 'grid_y', 'grid_rho'} ``depth_levels_km`` list[float] ``figures`` dict ``figure_paths`` dict """ SYSTEM_PROMPT = _SYSTEM_PROMPT def __init__( self, *, api_key: str | None = None, model: str | None = None, llm_provider: str = "claude", depth_indices: list[int] | None = None, interp_method: str = "linear", grid_n: int = 50, ) -> None: super().__init__( "ResistivityMapAgent", api_key=api_key, model=model, llm_provider=llm_provider, section_preset="inversion", ) self.depth_indices = depth_indices self.interp_method = interp_method.lower() self.grid_n = grid_n
[docs] def execute(self, input_data: dict[str, Any]) -> AgentResult: self._last_cost = 0.0 t0 = time.time() warnings: list[str] = [] predictions = input_data.get("predictions") if not predictions: return AgentResult.failed( "No 'predictions' dict provided.", hint="Pass predictions from AIInversionAgent or Inv3DAgent.", elapsed=time.time() - t0, ) coords_raw = input_data.get("station_coords") if coords_raw is None: return AgentResult.failed( "No 'station_coords' provided.", hint="Pass {'StationName': (x_m, y_m), ...} or ndarray (n_sta, 2).", elapsed=time.time() - t0, ) depths_raw = input_data.get("depths_km") depth_indices = input_data.get("depth_indices") or self.depth_indices output_dir = input_data.get("output_dir") # ── parse coordinates ───────────────────────────────────────────── station_names = list(predictions.keys()) n_sta = len(station_names) if isinstance(coords_raw, dict): xy = np.array( [coords_raw[nm] for nm in station_names if nm in coords_raw], dtype=float, ) present = [nm for nm in station_names if nm in coords_raw] else: xy = np.asarray(coords_raw, dtype=float)[:n_sta] present = station_names[: len(xy)] if len(xy) < 3: return AgentResult.failed( f"Need ≥ 3 stations with coordinates, got {len(xy)}.", elapsed=time.time() - t0, ) # ── build section matrix ────────────────────────────────────────── n_layers = max(len(np.asarray(predictions[nm])) for nm in present) mat = np.full((n_sta, n_layers), np.nan) for si, nm in enumerate(present): v = np.asarray(predictions[nm], dtype=float) n = min(len(v), n_layers) mat[si, :n] = v[:n] # ── depth axis ──────────────────────────────────────────────────── if depths_raw is not None: depths_km = np.asarray(depths_raw, dtype=float) else: depths_km = np.linspace(0.0, 2.0, n_layers + 1) # ── choose depth indices to map ─────────────────────────────────── if depth_indices is None: depth_indices = list( np.linspace(0, n_layers - 1, min(3, n_layers), dtype=int) ) depth_indices = [ int(d) for d in depth_indices if 0 <= int(d) < n_layers ] if not depth_indices: return AgentResult.failed( "No valid depth indices to map.", elapsed=time.time() - t0, ) # ── build grid and interpolate ──────────────────────────────────── cx, cy = xy[: len(present), 0], xy[: len(present), 1] xg = np.linspace(cx.min(), cx.max(), self.grid_n) yg = np.linspace(cy.min(), cy.max(), self.grid_n) Xg, Yg = np.meshgrid(xg, yg) depth_maps: list[dict] = [] for li in depth_indices: vals = mat[: len(present), li] mask = np.isfinite(vals) if mask.sum() < 3: warnings.append( f"Layer {li}: insufficient valid data for interpolation." ) continue try: rho_grid = _interpolate_map( cx[mask], cy[mask], vals[mask], Xg, Yg, method=self.interp_method, ) except Exception as exc: warnings.append(f"Layer {li} interpolation failed: {exc}") continue d_km = float(depths_km[min(li, len(depths_km) - 1)]) depth_maps.append( { "layer_idx": li, "depth_km": d_km, "grid_x": xg, "grid_y": yg, "grid_rho": rho_grid, "station_x": cx, "station_y": cy, "station_rho": vals, } ) if not depth_maps: return AgentResult.failed( "All depth layers failed to interpolate.", elapsed=time.time() - t0, ) # ── figures ─────────────────────────────────────────────────────── figures: dict[str, Any] = {} fig_paths: dict[str, str] = {} try: fig = _plot_depth_maps(depth_maps, present) if fig is not None: figures["depth_maps"] = fig p = self._save_figure( fig, output_dir, "resistivity_depth_maps", warnings_list=warnings, ) if p: fig_paths["depth_maps"] = p except Exception as exc: warnings.append(f"Depth map figure: {exc}") # ── LLM interpretation ──────────────────────────────────────────── interp: str | None = None if self.api_key: depth_levels = [dm["depth_km"] for dm in depth_maps] mean_rhos = [ float(np.nanmean(dm["grid_rho"])) for dm in depth_maps ] prompt = ( f"Resistivity depth maps:\n" f" Stations: {len(present)}\n" f" Depth levels mapped (km): {depth_levels}\n" f" Mean log₁₀ρ per level: {[f'{v:.2f}' for v in mean_rhos]}\n" f" Grid: {self.grid_n}×{self.grid_n}, method: {self.interp_method}\n" f" Warnings: {warnings[:3] if warnings else 'none'}\n\n" "Interpret the horizontal resistivity maps and identify structures." ) interp = self.query_llm(prompt, max_tokens=250) elapsed = time.time() - t0 return AgentResult( status="success", summary=( "Resistivity maps: {} depth levels ({} km). {} figures.".format( len(depth_maps), ", ".join(f"{dm['depth_km']:.2f}" for dm in depth_maps), len(figures), ) ), data={ "depth_maps": depth_maps, "depth_levels_km": [dm["depth_km"] for dm in depth_maps], "station_names": present, "station_coords": xy, "figures": figures, "figure_paths": fig_paths, }, warnings=warnings, llm_interpretation=interp, elapsed_seconds=elapsed, cost_estimate_usd=self._last_cost, )
# ── private helpers ─────────────────────────────────────────────────────────── def _interpolate_map( cx: np.ndarray, cy: np.ndarray, vals: np.ndarray, Xg: np.ndarray, Yg: np.ndarray, method: str, ) -> np.ndarray: """Interpolate scattered station values onto a regular grid.""" from scipy.interpolate import ( NearestNDInterpolator, griddata, ) points = np.column_stack([cx, cy]) xi = np.column_stack([Xg.ravel(), Yg.ravel()]) if method == "idw": # Inverse-distance weighting (power = 2) dists = np.sqrt(((xi[:, None, :] - points[None, :, :]) ** 2).sum(-1)) dists = np.maximum(dists, 1.0) # avoid division by zero w = 1.0 / dists**2 rho_flat = (w * vals[None, :]).sum(-1) / w.sum(-1) else: rho_flat = griddata( points, vals, xi, method="linear" if method == "linear" else "nearest", ) # fill NaN borders with nearest nan_mask = ~np.isfinite(rho_flat) if nan_mask.any(): nn = NearestNDInterpolator(points, vals) rho_flat[nan_mask] = nn(xi[nan_mask]) return rho_flat.reshape(Xg.shape) def _plot_depth_maps(depth_maps: list[dict], station_names: list[str]) -> Any: """Panel of horizontal depth-slice maps.""" import matplotlib.pyplot as plt n = len(depth_maps) if n == 0: return None fig, axes = plt.subplots(1, n, figsize=(5.5 * n, 5.0)) if n == 1: axes = [axes] all_vals = np.concatenate( [dm["grid_rho"][np.isfinite(dm["grid_rho"])] for dm in depth_maps] ) vmin = float(np.percentile(all_vals, 5)) if all_vals.size else 0.0 vmax = float(np.percentile(all_vals, 95)) if all_vals.size else 4.0 for ax, dm in zip(axes, depth_maps): xg = dm["grid_x"] / 1000.0 # km yg = dm["grid_y"] / 1000.0 Xg, Yg = np.meshgrid(xg, yg) im = ax.pcolormesh( Xg, Yg, dm["grid_rho"], cmap="jet_r", vmin=vmin, vmax=vmax, shading="auto", ) plt.colorbar(im, ax=ax, label="log₁₀ρ (Ω·m)", shrink=0.85) # station markers st_x = dm["station_x"] / 1000.0 st_y = dm["station_y"] / 1000.0 ax.scatter( st_x, st_y, c=dm["station_rho"], cmap="jet_r", vmin=vmin, vmax=vmax, edgecolors="k", lw=0.5, s=50, zorder=5, ) for xi, yi, nm in zip(st_x, st_y, station_names): ax.text( xi, yi, nm, fontsize=5.5, ha="center", va="bottom", color="w", fontweight="bold", ) ax.set_xlabel("E–W (km)", fontsize=8) ax.set_ylabel("N–S (km)", fontsize=8) ax.tick_params(labelsize=7) ax.set_title( f"Depth {dm['depth_km']:.2f} km", fontsize=8, fontweight="bold" ) ax.set_aspect("equal") fig.suptitle( "Pseudo-3D resistivity depth maps", fontsize=10, fontweight="bold" ) fig.tight_layout() return fig __all__ = ["ResistivityMapAgent"]