Source code for pycsamt.agents.inv3d_agent

"""
pycsamt.agents.inv3d_agent
===========================

:class:`Inv3DAgent` — Graph-convolutional 3-D MT spatial inversion.

Wraps :class:`~pycsamt.ai.inversion.inv3d.GCNInverter3D`:

* Represents the survey network as a **spatial graph** whose edges connect
  stations within a configurable radius.  Spectral GCN message-passing
  propagates information between neighbouring stations so the resulting
  3-D resistivity volume is spatially coherent — artefacts from
  station-by-station 1-D inversion are suppressed.

* Trains on **synthetic 3-D profiles** assembled by tiling independent
  1-D forward models across a virtual station grid, then predicts on the
  observed :class:`~pycsamt.site.Sites` dataset.

* Outputs log₁₀ρ per depth layer **and** log₁₀h per interface for every
  station, giving a full layered earth model that can be gridded into a
  3-D resistivity volume.

* Optionally runs **MC-dropout uncertainty** (``n_mc`` stochastic passes)
  to produce depth-resolved confidence maps alongside the main prediction.

Requires PyTorch **or** TensorFlow.

Architecture
------------
:class:`~pycsamt.ai.nets.gcn.GCNNet` — spectral graph convolutional network
(Kipf & Welling 2017).  No external graph library is required.

References
----------
.. [1] Kipf, T. N. & Welling, M. (2017). Semi-supervised classification
       with graph convolutional networks. *ICLR 2017*.
"""

from __future__ import annotations

import time
from typing import Any

import numpy as np

from ._base import AgentResult, BaseAgent
from .ai_inversion import _default_thicknesses, _z_to_features

_SYSTEM_PROMPT = """\
You are an expert in 3-D MT inversion using graph-convolutional deep learning.
Given a GCN-based 3-D inversion result, write 4-5 sentences that:
1. Describe the survey geometry (station count, spatial extent, adjacency radius).
2. Interpret the dominant 3-D resistivity structures and their spatial continuity.
3. Assess prediction quality (RMS, depth range) relative to station spacing.
4. Compare the GCN spatial result to independent 1-D predictions where possible.
5. Recommend geological follow-up and areas with highest uncertainty.
Reply in plain scientific English.
"""

_DEFAULT_FREQS = np.logspace(-4, 3, 32)  # 32 frequencies 10⁻⁴ – 10³ Hz
_N_COMP = 4  # Re/Im of Zxy and Zyx


[docs] class Inv3DAgent(BaseAgent): """3-D MT profile inversion using a graph-convolutional network (GCN). Parameters ---------- api_key, model, llm_provider : str n_layers : int Number of depth layers per station (default 5). n_freqs : int Number of frequencies used for feature extraction (default 32). n_train_profiles : int Number of synthetic 3-D training profiles (default 150). epochs : int Training epochs (default 30). radius : float Maximum inter-station edge distance in metres for the adjacency graph (default 5 000 m). Stations farther apart than *radius* are disconnected in the graph. hidden : tuple of int GCN hidden-layer sizes (default (256, 128, 64)). dropout : float Dropout probability (default 0.1); also used for MC uncertainty. n_mc : int Number of Monte-Carlo dropout passes for uncertainty estimation. Set to 0 to skip uncertainty (faster, default 20). Input keys ---------- ``sites`` / ``path`` : Sites or str — observed MT dataset ``coords`` : ndarray (n_stations, 2), optional — station (x, y) in metres. Auto-extracted from EDI lat/lon when absent. ``adjacency`` : ndarray (n_stations, n_stations), optional — pre-computed normalised adjacency; overrides *radius* when supplied. ``output_dir`` : str, optional ``period_range`` : [T_min, T_max], optional Output data keys ---------------- ``pred_rho`` ndarray (n_sta, n_layers) — log₁₀ρ ``pred_thick`` ndarray (n_sta, n_layers-1) — log₁₀h (metres) ``pred_uncertainty`` ndarray (n_sta, n_layers) or None — MC-dropout std ``depths_km`` ndarray — depth axis at station midpoints (km) ``station_names`` list[str] ``station_coords`` ndarray (n_sta, 2) — metres ``adjacency`` ndarray (n_sta, n_sta) ``rms_global`` float ``inverter`` GCNInverter3D ``figures`` dict ``figure_paths`` dict Examples -------- >>> agent = Inv3DAgent(n_layers=5, epochs=20, n_mc=10) >>> result = agent.execute({ ... "path": "/data/WILLY_EDIs", ... "output_dir": "/out/inv3d", ... }) >>> result["rms_global"] 0.28 """ SYSTEM_PROMPT = _SYSTEM_PROMPT def __init__( self, *, api_key: str | None = None, model: str | None = None, llm_provider: str = "claude", n_layers: int = 5, n_freqs: int = 32, n_train_profiles: int = 150, epochs: int = 30, radius: float = 5_000.0, hidden: tuple[int, ...] = (256, 128, 64), dropout: float = 0.1, n_mc: int = 20, ) -> None: super().__init__( "Inv3DAgent", api_key=api_key, model=model, llm_provider=llm_provider, section_preset="inversion", ) self.n_layers = n_layers self.n_freqs = n_freqs self.n_train_profiles = n_train_profiles self.epochs = epochs self.radius = radius self.hidden = tuple(hidden) self.dropout = dropout self.n_mc = n_mc # ── public ────────────────────────────────────────────────────────────────
[docs] def execute(self, input_data: dict[str, Any]) -> AgentResult: self._last_cost = 0.0 t0 = time.time() warnings: list[str] = [] # ── backend check ───────────────────────────────────────────────────── try: from ..ai.inversion.inv3d import GCNInverter3D from ..ai.nets.gcn import build_adjacency from ..backends import get_backend_instance from ..forward.batch import generate_dataset if get_backend_instance() is None: raise ImportError("No DL backend.") except ImportError as exc: return AgentResult.failed( f"Inv3DAgent requires PyTorch or TensorFlow: {exc}", hint="pip install torch or pip install tensorflow", elapsed=time.time() - t0, ) from ..emtools._core import ( _get_z_block, _iter_items, _name, ensure_sites, ) # ── load sites ──────────────────────────────────────────────────────── sites_raw = input_data.get("sites") or input_data.get("path") if sites_raw is None: return AgentResult.failed( "No 'sites' or 'path'.", elapsed=time.time() - t0 ) try: sites = ensure_sites(sites_raw, verbose=0) except Exception as exc: return AgentResult.failed(str(exc), elapsed=time.time() - t0) output_dir = input_data.get("output_dir") import os if output_dir: os.makedirs(output_dir, exist_ok=True) freqs = _DEFAULT_FREQS[: self.n_freqs] n_features = self.n_freqs * _N_COMP # flat feature vector per station n_out = 2 * self.n_layers - 1 # log10(ρ) layers + log10(h) interfaces # ── collect observed features + station coordinates ──────────────────── station_names: list[str] = [] feat_list: list[np.ndarray] = [] coord_list: list[np.ndarray] = [] # (x_m, y_m) per station ext_coords = input_data.get("coords") # user-supplied (n_sta, 2) for i, ed in enumerate(_iter_items(sites)): nm = _name(ed, i) z_obj, z, fr = _get_z_block(ed) if z is None: warnings.append(f"{nm}: no Z data, skipped.") continue feat = _z_to_features(z_obj, z, fr, freqs) if feat is None: warnings.append(f"{nm}: feature extraction failed, skipped.") continue station_names.append(nm) feat_list.append(feat.reshape(-1)) # (n_features,) # coordinates: try EDI header, then sequential fallback if ext_coords is None: xy = _extract_station_xy(ed, i) coord_list.append(xy) n_sta = len(station_names) if n_sta < 2: return AgentResult.failed( f"Only {n_sta} usable station(s) — need ≥ 2 for GCN.", elapsed=time.time() - t0, ) X_obs = np.stack(feat_list, axis=0).astype( np.float32 ) # (n_sta, n_feat) X_obs = _pad_or_trim(X_obs, n_features) # station coordinates (metres) if ext_coords is not None: coords_m = np.asarray(ext_coords, dtype=np.float64)[:n_sta] else: coords_m = np.stack(coord_list, axis=0) # (n_sta, 2) in metres # ── build adjacency ─────────────────────────────────────────────────── user_adj = input_data.get("adjacency") if user_adj is not None: A = np.asarray(user_adj, dtype=np.float32) if A.shape != (n_sta, n_sta): warnings.append( f"Supplied adjacency shape {A.shape} ≠ ({n_sta},{n_sta}); " "rebuilding from coordinates." ) A = build_adjacency(coords_m, radius=self.radius) else: A = build_adjacency(coords_m, radius=self.radius) # warn if graph is fully disconnected off_diag_edges = int((A > 0).sum()) - n_sta if off_diag_edges == 0: warnings.append( f"No inter-station edges within radius={self.radius:.0f} m. " "The GCN will act as independent 1-D inversion. " "Try increasing radius." ) # ── generate synthetic 3-D training data ─────────────────────────────── n_1d_total = self.n_train_profiles * n_sta self._log.info( "Generating %d synthetic profiles (%d×%d) for 3-D GCN training…", n_1d_total, self.n_train_profiles, n_sta, ) try: ds = generate_dataset( solver="mt1d", n_samples=n_1d_total, freqs=freqs, n_layers=self.n_layers, noise_level=0.03, seed=42, n_jobs=1, verbose=False, ) # X_1d: (n_1d, n_freqs, 4) → flatten → (n_1d, n_features) X_1d = ds.X.reshape(n_1d_total, -1).astype(np.float32) X_1d = _pad_or_trim(X_1d, n_features) # y_1d: (n_1d, n_layers) — log10(ρ) only # y target for GCN: (n_1d, 2*n_layers-1) = log10(ρ) + log10(h) y_log_rho = ds.y[:, : self.n_layers].astype(np.float32) ths = _default_thicknesses(self.n_layers, freqs) # (n_layers-1,) log_h = np.log10(np.clip(ths, 1.0, None)).astype(np.float32) log_h_tile = np.tile( log_h[None, :], (n_1d_total, 1) ) # (n_1d, n_layers-1) y_1d = np.concatenate( [y_log_rho, log_h_tile], axis=1 ) # (n_1d, n_out) # reshape into 3-D profile tensors n_samp = n_1d_total // n_sta X_1d = X_1d[: n_samp * n_sta] y_1d = y_1d[: n_samp * n_sta] X_3d = X_1d.reshape( n_samp, n_sta, n_features ) # (n_samp, n_sta, n_feat) y_3d = y_1d.reshape( n_samp, n_sta, n_out ) # (n_samp, n_sta, n_out) # synthetic adjacency: same A for all profiles # (GCNInverter3D uses one A across the mini-batch) except Exception as exc: return AgentResult.failed( f"Synthetic dataset generation failed: {exc}", elapsed=time.time() - t0, ) # ── train GCNInverter3D ─────────────────────────────────────────────── self._log.info( "Training GCNInverter3D (GCN hidden=%s) for %d epochs…", self.hidden, self.epochs, ) try: inverter = GCNInverter3D( n_features=n_features, n_layers=self.n_layers, hidden=self.hidden, dropout=self.dropout, ) inverter.fit( X_3d, y_3d, adjacency=A, epochs=self.epochs, batch_size=max(4, min(16, n_samp // 10)), patience=max(5, self.epochs // 5), verbose=False, ) except Exception as exc: return AgentResult.failed( f"GCNInverter3D training failed: {exc}", elapsed=time.time() - t0, ) # ── predict on observed stations ────────────────────────────────────── pred_uncertainty: np.ndarray | None = None try: y_pred = inverter.predict(X_obs, adjacency=A) # (n_sta, n_out) except Exception as exc: return AgentResult.failed( f"3-D prediction failed: {exc}", elapsed=time.time() - t0, ) pred_rho = y_pred[:, : self.n_layers] # (n_sta, n_layers) pred_thick = y_pred[:, self.n_layers :] # (n_sta, n_layers-1) # optional MC-dropout uncertainty if self.n_mc > 0: try: mu, sigma = inverter.predict_with_uncertainty( X_obs, adjacency=A, n_mc=self.n_mc, ) pred_uncertainty = sigma[ :, : self.n_layers ] # (n_sta, n_layers) except Exception as exc: warnings.append(f"MC-dropout uncertainty failed: {exc}") # ── depth axis (in km) ──────────────────────────────────────────────── ths = _default_thicknesses(self.n_layers, freqs) depths = np.concatenate([[0.0], np.cumsum(ths)]) / 1000.0 # km # ── per-station forward RMS ──────────────────────────────────────────── rms_list: list[float] = [] for si, (nm, ed) in enumerate( _iter_station_items(sites, station_names) ): rms = _forward_rms_3d(ed, pred_rho[si], ths, freqs) if rms is not None: rms_list.append(rms) rms_global = float(np.nanmean(rms_list)) if rms_list else np.nan # ── figures ─────────────────────────────────────────────────────────── figures: dict[str, Any] = {} fig_paths: dict[str, str] = {} try: fig_slices = _plot_depth_slices( pred_rho, coords_m, station_names, depths, self.n_layers, ) if fig_slices is not None: figures["depth_slices"] = fig_slices p = self._save_figure( fig_slices, output_dir, "inv3d_depth_slices", warnings_list=warnings, ) if p: fig_paths["depth_slices"] = p except Exception as exc: warnings.append(f"Depth slice figure: {exc}") try: fig_sec = _plot_resistivity_section( pred_rho, station_names, depths, coords_m, ) if fig_sec is not None: figures["resistivity_section"] = fig_sec p = self._save_figure( fig_sec, output_dir, "inv3d_resistivity_section", warnings_list=warnings, ) if p: fig_paths["resistivity_section"] = p except Exception as exc: warnings.append(f"Resistivity section figure: {exc}") if pred_uncertainty is not None: try: fig_unc = _plot_uncertainty_depth_map( pred_uncertainty, coords_m, station_names, depths, ) if fig_unc is not None: figures["uncertainty_map"] = fig_unc p = self._save_figure( fig_unc, output_dir, "inv3d_uncertainty", warnings_list=warnings, ) if p: fig_paths["uncertainty_map"] = p except Exception as exc: warnings.append(f"Uncertainty map figure: {exc}") # ── LLM interpretation ──────────────────────────────────────────────── interp: str | None = None if self.api_key: rho_mean = float(np.nanmean(10**pred_rho)) rho_std = float(np.nanstd(10**pred_rho)) extent_km = ( float( np.max( np.linalg.norm( coords_m - coords_m.mean(axis=0), axis=1 ) ) ) / 1000.0 ) rms_str = ( f"{rms_global:.3f}" if not np.isnan(rms_global) else "N/A" ) prompt = ( f"3-D GCN inversion summary:\n" f" Stations: {n_sta}, extent: ~{extent_km:.1f} km\n" f" Adjacency radius: {self.radius / 1000:.1f} km, " f" edges per station: {off_diag_edges}/{n_sta}\n" f" Layers: {self.n_layers}, max depth: {depths[-1]:.2f} km\n" f" Mean resistivity: {rho_mean:.0f} Ω·m ± {rho_std:.0f}\n" f" Global RMS: {rms_str} log₁₀(Ω·m)\n" f" MC uncertainty: {'computed' if pred_uncertainty is not None else 'skipped'}\n" f" Warnings: {warnings[:3] if warnings else 'none'}\n\n" "Interpret the 3-D resistivity volume geologically." ) interp = self.query_llm(prompt, max_tokens=280) elapsed = time.time() - t0 rms_disp = ( f"RMS {rms_global:.3f}" if not np.isnan(rms_global) else "RMS N/A" ) unc_disp = ", MC σ computed" if pred_uncertainty is not None else "" return AgentResult( status="success", summary=( f"3-D GCN inversion: {n_sta} stations × {self.n_layers} layers. " f"{rms_disp}{unc_disp}. {len(figures)} figures." ), data={ "pred_rho": pred_rho, "pred_thick": pred_thick, "pred_uncertainty": pred_uncertainty, "depths_km": depths, "station_names": station_names, "station_coords": coords_m, "adjacency": A, "rms_global": rms_global, "inverter": inverter, "n_edges": off_diag_edges, "figures": figures, "figure_paths": fig_paths, }, warnings=warnings, llm_interpretation=interp, elapsed_seconds=elapsed, cost_estimate_usd=self._last_cost, )
# ── private helpers ─────────────────────────────────────────────────────────── def _extract_station_xy(ed: Any, idx: int) -> np.ndarray: """Return (x_m, y_m) for one station from EDI header lat/lon.""" try: lat = float(getattr(ed, "lat", None) or getattr(ed, "latitude", 0.0)) lon = float(getattr(ed, "lon", None) or getattr(ed, "longitude", 0.0)) except Exception: lat, lon = 0.0, float(idx) * 0.001 # fallback: spaced 1 m apart # approximate UTM-like local projection (metres) x_m = lon * 111_320.0 * np.cos(np.radians(lat)) y_m = lat * 110_574.0 return np.array([x_m, y_m], dtype=np.float64) def _pad_or_trim(X: np.ndarray, target_cols: int) -> np.ndarray: """Ensure X has exactly *target_cols* columns.""" n = X.shape[0] c = X.shape[1] if c == target_cols: return X if c > target_cols: return X[:, :target_cols] pad = np.zeros((n, target_cols - c), dtype=X.dtype) return np.concatenate([X, pad], axis=1) def _iter_station_items(sites: Any, names: list[str]): """Yield (name, EDI_object) only for stations in *names*.""" from ..emtools._core import _iter_items, _name wanted = set(names) for i, ed in enumerate(_iter_items(sites)): nm = _name(ed, i) if nm in wanted: yield nm, ed def _forward_rms_3d( ed: Any, log_rho: np.ndarray, # (n_layers,) ths: np.ndarray, # (n_layers-1,) freqs: np.ndarray, ) -> float | None: """Compute forward RMS for one predicted station.""" try: from ..emtools._core import _get_z_block from ..forward import LayeredModel, MT1DForward _, z, fr = _get_z_block(ed) if z is None or fr is None: return None rhos = 10.0**log_rho lm = LayeredModel(resistivity=rhos, thickness=ths) fwd = MT1DForward(freqs=freqs) resp = fwd.run(lm) rho_fwd = np.asarray(resp.rho_a) rho_xy = rho_fwd[:, 0, 1] if rho_fwd.ndim == 3 else rho_fwd rho_raw = getattr(ed, "rho", None) rho_obs = ( rho_raw[:, 0, 1] if rho_raw is not None else (0.2 / np.where(fr == 0, np.nan, fr)) * np.abs(z[:, 0, 1]) ** 2 ) per = 1.0 / np.where(fr == 0, np.nan, fr) per_fwd = 1.0 / np.where(freqs == 0, np.nan, freqs) mask = np.isfinite(per) & (rho_obs > 0) if mask.sum() < 2: return None interp = np.interp( np.log10(per[mask]), np.log10(per_fwd[np.isfinite(per_fwd)]), np.log10(np.clip(rho_xy[np.isfinite(per_fwd)], 1e-6, None)), ) obs_log = np.log10(np.clip(rho_obs[mask], 1e-6, None)) return float(np.sqrt(np.mean((obs_log - interp) ** 2))) except Exception: return None def _plot_depth_slices( pred_rho: np.ndarray, # (n_sta, n_layers) coords_m: np.ndarray, # (n_sta, 2) station_names: list[str], depths: np.ndarray, # (n_layers+1,) in km n_layers: int, ) -> Any: """Horizontal depth-slice maps at 3 representative depths.""" import matplotlib.pyplot as plt from matplotlib.tri import Triangulation from ..api.section import PYCSAMT_SECTION n_sta = len(station_names) PYCSAMT_SECTION.style_for("inversion") n_slices = min(3, n_layers) layer_idxs = np.linspace(0, n_layers - 1, n_slices, dtype=int) fig, axes = plt.subplots(1, n_slices, figsize=(5.0 * n_slices, 5.0)) if n_slices == 1: axes = [axes] x_m = coords_m[:, 0] y_m = coords_m[:, 1] # normalise to km for display cx = x_m / 1000.0 cy = y_m / 1000.0 vv = pred_rho[np.isfinite(pred_rho)] vmin = float(np.percentile(vv, 5)) if vv.size else 0.0 vmax = float(np.percentile(vv, 95)) if vv.size else 4.0 for ax, li in zip(axes, layer_idxs): layer_vals = pred_rho[:, li] depth_km = float(depths[li]) if n_sta >= 4: # Triangulated interpolation try: tri = Triangulation(cx, cy) tcf = ax.tricontourf( tri, layer_vals, levels=20, cmap="jet_r", vmin=vmin, vmax=vmax, ) plt.colorbar(tcf, ax=ax, label="log₁₀ρ (Ω·m)", shrink=0.8) except Exception: sc = ax.scatter( cx, cy, c=layer_vals, cmap="jet_r", vmin=vmin, vmax=vmax, s=80, edgecolors="k", lw=0.4, ) plt.colorbar(sc, ax=ax, label="log₁₀ρ (Ω·m)", shrink=0.8) else: sc = ax.scatter( cx, cy, c=layer_vals, cmap="jet_r", vmin=vmin, vmax=vmax, s=120, edgecolors="k", lw=0.5, ) plt.colorbar(sc, ax=ax, label="log₁₀ρ (Ω·m)", shrink=0.8) # station labels for nm, xi, yi in zip(station_names, cx, cy): ax.text( xi, yi, nm, fontsize=5.5, ha="center", va="bottom", color="white", 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 slice {depth_km:.2f} km", fontsize=8, fontweight="bold" ) ax.set_aspect("equal") fig.suptitle( "3-D GCN inversion — horizontal depth slices", fontsize=10, fontweight="bold", ) fig.tight_layout() return fig def _plot_resistivity_section( pred_rho: np.ndarray, # (n_sta, n_layers) station_names: list[str], depths: np.ndarray, # km coords_m: np.ndarray, # (n_sta, 2) ) -> Any: """Pseudo-section along the survey profile (distance vs depth).""" import matplotlib.pyplot as plt from ..api.section import PYCSAMT_SECTION from ..api.station import PYCSAMT_STATION_RENDERING n_sta, n_layers = pred_rho.shape if n_sta == 0: return None # project stations onto the dominant profile direction cx = coords_m[:, 0] / 1000.0 cy = coords_m[:, 1] / 1000.0 if n_sta > 1: vec = np.array([cx[-1] - cx[0], cy[-1] - cy[0]]) vec_len = np.linalg.norm(vec) + 1e-9 vec /= vec_len dist = np.array( [ np.dot([cx[i] - cx[0], cy[i] - cy[0]], vec) for i in range(n_sta) ] ) else: dist = np.zeros(1) section = PYCSAMT_SECTION.style_for("inversion") fig_w, fig_h = section.figsize_for(n_stations=n_sta, n_y=n_layers) fig, ax = plt.subplots(figsize=(fig_w, fig_h)) mat = pred_rho.T # (n_layers, n_sta) vv = mat[np.isfinite(mat)] vmin = float(np.percentile(vv, 5)) if vv.size else 0.0 vmax = float(np.percentile(vv, 95)) if vv.size else 4.0 im = ax.imshow( mat, aspect="auto", origin="upper", extent=(dist[0] - 0.25, dist[-1] + 0.25, depths[-1], depths[0]), cmap="jet_r", vmin=vmin, vmax=vmax, interpolation="bilinear", ) PYCSAMT_STATION_RENDERING.apply( ax, dist, station_names, preset="inversion", xlim=(dist[0] - 0.25, dist[-1] + 0.25), ) ax.set_ylabel("Depth (km)", fontsize=9) ax.set_xlabel("Profile distance (km)", fontsize=9) ax.tick_params(labelsize=8) section.add_colorbar(im, ax, label="$\\log_{10}\\rho$ (Ω·m)") ax.set_title( "3-D GCN inversion — resistivity section", fontsize=10, fontweight="bold", ) fig.tight_layout() return fig def _plot_uncertainty_depth_map( pred_unc: np.ndarray, # (n_sta, n_layers) coords_m: np.ndarray, station_names: list[str], depths: np.ndarray, # km ) -> Any: """Uncertainty map at the shallowest and deepest depth slices.""" import matplotlib.pyplot as plt from matplotlib.tri import Triangulation from ..api.section import PYCSAMT_SECTION n_sta, n_layers = pred_unc.shape if n_sta == 0: return None PYCSAMT_SECTION.style_for("inversion") n_panels = min(2, n_layers) layer_idxs = [0, n_layers - 1] if n_panels == 2 else [0] titles = ["Shallow uncertainty", "Deep uncertainty"] fig, axes = plt.subplots(1, n_panels, figsize=(5.0 * n_panels, 4.5)) if n_panels == 1: axes = [axes] cx = coords_m[:, 0] / 1000.0 cy = coords_m[:, 1] / 1000.0 sv = pred_unc[np.isfinite(pred_unc)] s_vmax = float(np.percentile(sv, 95)) if sv.size else 1.0 for ax, li, title in zip(axes, layer_idxs, titles): unc_vals = pred_unc[:, li] depth_km = float(depths[li]) if n_sta >= 4: try: tri = Triangulation(cx, cy) tcf = ax.tricontourf( tri, unc_vals, levels=15, cmap="Oranges", vmin=0.0, vmax=s_vmax, ) plt.colorbar(tcf, ax=ax, label="σ (log₁₀ρ)", shrink=0.8) except Exception: sc = ax.scatter( cx, cy, c=unc_vals, cmap="Oranges", vmin=0.0, vmax=s_vmax, s=80, edgecolors="k", lw=0.4, ) plt.colorbar(sc, ax=ax, label="σ (log₁₀ρ)", shrink=0.8) else: sc = ax.scatter( cx, cy, c=unc_vals, cmap="Oranges", vmin=0.0, vmax=s_vmax, s=120, edgecolors="k", lw=0.5, ) plt.colorbar(sc, ax=ax, label="σ (log₁₀ρ)", shrink=0.8) 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"{title} ({depth_km:.2f} km)", fontsize=8, fontweight="bold" ) ax.set_aspect("equal") fig.suptitle( "3-D GCN — MC-dropout uncertainty (σ log₁₀ρ)", fontsize=10, fontweight="bold", ) fig.tight_layout() return fig __all__ = ["Inv3DAgent"]