"""
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, using one of two ``physics`` modes
(see below), 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.
Physics modes
-------------
``physics="mt1d"`` (default)
Tiles independent 1-D forward models across the real station
positions (:func:`~pycsamt.forward.batch.generate_dataset`). This
is the original smoke/demo path from the AI-inversion
implementation plan: nothing enforces genuine lateral/3-D coupling
between the tiled 1-D columns beyond what the GCN's spatial
smoothing adds after the fact, so the plan explicitly does not
call this genuine 3-D inversion. Kept unconditionally as the
default and as an explicit fallback, per the plan's requirement.
``physics="mt3d"``
Generates genuinely 3-D correlated geological volumes at the
survey's own real station ``(x, y)`` positions and solves them
with the research-only, small-grid
:class:`~pycsamt.forward.maxwell.mt3d.MT3DAdapter`
(:func:`~pycsamt.ai.training.dataset3d.generate_3d_maxwell_dataset`).
Unlike ``Inv2DAgent(physics="mt2d")``, this uses the survey's
*actual* station geometry rather than a synthetic uniform
spacing, since the real coordinates are already needed to build
the GCN adjacency graph. Each training profile's per-station
target is the true 3-D volume's own vertical resistivity column
at that station, resampled onto the agent's display depth grid
(nearest-cell; the geological grid's own resolution is coarser
than most production sections, per :class:`MT3DAdapter`'s
small-cell-budget research-only status). Only the TE-like
``zxy`` response is requested and used, matching the observed
feature pipeline (:func:`~pycsamt.agents.ai_inversion._z_to_features`,
which is ``[log10(rho_a_xy), phase_xy]`` only, zero-padded to this
agent's 4-wide feature slot — using the unused ``zyx``/diagonal
slots for synthetic data would create a train/observation
distribution mismatch, not add information). When a held-out
synthetic split is available, a genuine (non-fabricated) recovery
check against known-truth resistivity is added to the result via
:func:`~pycsamt.ai.validation.recovery_report`, since the field
survey has no ground truth to check against. This mode is far
more expensive per training profile than ``"mt1d"`` (a real 3-D
Maxwell solve vs. an independent 1-D solve per station) — lower
``n_train_profiles`` accordingly. Mesh accuracy degrades at higher
frequencies (~10% error by 20-50 Hz vs ~1-2% at 1-2 Hz, at this
agent's default ``cells_per_skin_depth=None``); passing an
explicit ``cells_per_skin_depth`` (e.g. 8.0) narrows this to
~5-9% at the same cell budget but does not fully close it — see
:mod:`~pycsamt.ai.training.dataset3d`'s module docstring for
measured numbers and the cost/accuracy trade-off raising it
together with ``max_mesh_cells`` buys. Prefer a ``freqs`` grid
concentrated in the low-frequency range this solver's own
benchmark validates when accuracy matters more
than matching a specific field acquisition band.
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 os
import time
from typing import Any
import numpy as np
from ..compat.sklearn import validate_params
from ._base import AgentResult, BaseAgent
from ._param_schemas import INV3D_PARAM_CONSTRAINTS
from ._topography import resolve_agent_topography
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).
freqs : array-like or None
Explicit positive frequency grid in hertz. When supplied, it replaces
the legacy ``10^-4``--``10^3`` Hz grid and determines ``n_freqs``.
depth_max : float or None
Maximum cumulative model depth in metres. When supplied, layer
thicknesses are geometrically graded and normalized to this depth.
``None`` preserves the legacy Bostick-derived display parameterization.
n_train_profiles : int
Number of synthetic 3-D training profiles (default 150). With
``physics="mt3d"``, each profile costs a real 3-D Maxwell
solve, not a cheap independent 1-D solve — lower this
substantially (e.g. 20-40) for that mode.
epochs : int
Training epochs (default 30).
patience : int or None
Early-stopping patience measured in epochs. ``None`` uses one fifth
of ``epochs`` with a minimum of five.
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).
physics : {"mt1d", "mt3d"}, default "mt1d"
Synthetic training-data physics; see the module docstring.
correlation_length_x_m, correlation_length_y_m, correlation_length_z_m
: (float, float)
``physics="mt3d"`` only: horizontal/horizontal/vertical
correlation length ranges forwarded to
:class:`~pycsamt.ai.training.dataset3d.Maxwell3DDatasetConfig`.
log_resistivity_mean, log_resistivity_std : float
``physics="mt3d"`` only: affine map from the standardized
correlated field to ``log10(resistivity_ohm_m)``.
mesh_safety_factor, max_mesh_cells : float, int
``physics="mt3d"`` only: forwarded to
``Maxwell3DDatasetConfig``. ``max_mesh_cells`` also builds the
matching ``MT3DAdapter(max_cells=...)`` used to solve.
cells_per_skin_depth : float or None, default None
``physics="mt3d"`` only: opt-in frequency-aware solver core
resolution, forwarded to ``Maxwell3DDatasetConfig`` — see its
docstring for the accuracy-vs-cell-cost trade-off this exists
to address. ``None`` (the default) preserves this agent's
original behavior (core resolution = the geological grid's
own spacing); this agent's default ``freqs`` grid spans up to
~1000 Hz (``_DEFAULT_FREQS``), so enabling this without also
raising ``max_mesh_cells`` and/or narrowing ``freqs`` risks a
"needs N solver cells" error at high frequencies.
geology_grid_nx_ny, geology_grid_nz : int, int or None
``physics="mt3d"`` only: resolution of the *geological*
training grid (how finely the true 3-D resistivity volumes
are drawn), independent of the solver mesh's own
frequency-aware resolution above. ``geology_grid_nz=None``
(default) uses ``min(max(n_layers, 4), 8)``. Raising these
increases realism at the cost of more solver cells per
realization; see ``max_mesh_cells`` for the resulting budget.
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
``freqs`` : array-like, optional — execution-time frequency-grid override.
``depth_max`` : float, optional — cumulative finite-layer depth in metres.
``topography`` : bool or dict, optional
``True`` extracts elevation and chainage from ``sites`` and adds a
terrain-draped section. A mapping may instead provide
``elevation_m`` and optionally ``chainage_km``, plus
``exaggeration`` and ``interp_method`` rendering options. This is a
geometry/visualisation contract; it does not alter the MT forward
solver or GCN prediction.
``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)
``frequency_grid_hz`` ndarray — effective feature/forward frequency grid
``configured_depth_max_m`` float or None — explicit depth contract
``topography`` dict or None — resolved terrain metadata
``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
``physics`` str — ``"mt1d"`` or ``"mt3d"``, mode used
``mt3d_recovery`` dict or None — held-out known-truth
recovery metrics (``physics="mt3d"`` only)
``training_history`` dict -- per-epoch training and validation losses
``epochs_completed`` int -- epochs actually completed before stopping
``best_validation_loss`` float -- minimum finite validation loss
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
@validate_params(INV3D_PARAM_CONSTRAINTS, prefer_skip_nested_validation=True)
def __init__(
self,
*,
api_key: str | None = None,
model: str | None = None,
llm_provider: str = "claude",
n_layers: int = 5,
n_freqs: int = 32,
freqs: Any = None,
depth_max: float | None = None,
n_train_profiles: int = 150,
epochs: int = 30,
patience: int | None = None,
radius: float = 5_000.0,
hidden: tuple[int, ...] = (256, 128, 64),
dropout: float = 0.1,
n_mc: int = 20,
physics: str = "mt1d",
correlation_length_x_m: tuple[float, float] = (500.0, 2000.0),
correlation_length_y_m: tuple[float, float] = (500.0, 2000.0),
correlation_length_z_m: tuple[float, float] = (100.0, 500.0),
log_resistivity_mean: float = 2.0,
log_resistivity_std: float = 0.5,
mesh_safety_factor: float = 3.0,
max_mesh_cells: int = 6_000,
cells_per_skin_depth: float | None = None,
geology_grid_nx_ny: int = 6,
geology_grid_nz: int | None = None,
verbose: bool | int | str = False,
) -> None:
super().__init__(
"Inv3DAgent",
api_key=api_key,
model=model,
llm_provider=llm_provider,
section_preset="inversion",
verbose=verbose,
)
self.n_layers = n_layers
self.n_freqs = n_freqs
self.freqs = None if freqs is None else np.asarray(freqs, dtype=float)
self.depth_max = None if depth_max is None else float(depth_max)
self.n_train_profiles = n_train_profiles
self.epochs = epochs
self.patience = None if patience is None else max(1, int(patience))
self.radius = radius
self.hidden = tuple(hidden)
self.dropout = dropout
self.n_mc = n_mc
self.physics = physics
self.correlation_length_x_m = correlation_length_x_m
self.correlation_length_y_m = correlation_length_y_m
self.correlation_length_z_m = correlation_length_z_m
self.log_resistivity_mean = float(log_resistivity_mean)
self.log_resistivity_std = float(log_resistivity_std)
self.mesh_safety_factor = float(mesh_safety_factor)
self.max_mesh_cells = int(max_mesh_cells)
self.cells_per_skin_depth = (
None
if cells_per_skin_depth is None
else float(cells_per_skin_depth)
)
self.geology_grid_nx_ny = int(geology_grid_nx_ny)
self.geology_grid_nz = (
None if geology_grid_nz is None else int(geology_grid_nz)
)
# ── 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 ..forward.batch import generate_dataset
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,
warnings=warnings,
)
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,
warnings=warnings,
)
try:
sites = ensure_sites(sites_raw, verbose=0)
except Exception as exc:
return AgentResult.failed(
str(exc), elapsed=time.time() - t0, warnings=warnings
)
output_dir = input_data.get("output_dir")
if output_dir:
os.makedirs(output_dir, exist_ok=True)
freqs_cfg = input_data.get("freqs", self.freqs)
if freqs_cfg is None:
freqs = _DEFAULT_FREQS[: self.n_freqs]
else:
freqs = np.asarray(freqs_cfg, dtype=float).reshape(-1)
if (
freqs.size < 2
or not np.all(np.isfinite(freqs))
or np.any(freqs <= 0)
):
return AgentResult.failed(
"'freqs' must contain at least two finite positive values.",
elapsed=time.time() - t0,
warnings=warnings,
)
freqs = np.unique(freqs)
n_freqs = int(freqs.size)
n_features = n_freqs * _N_COMP # flat feature vector per station
n_out = 2 * self.n_layers - 1 # log10(ρ) layers + log10(h) interfaces
depth_max_cfg = input_data.get("depth_max", self.depth_max)
if depth_max_cfg is not None and float(depth_max_cfg) <= 0:
return AgentResult.failed(
"'depth_max' must be positive when supplied.",
elapsed=time.time() - t0,
warnings=warnings,
)
ths = _agent_thicknesses(
self.n_layers,
freqs,
None if depth_max_cfg is None else float(depth_max_cfg),
)
# ── 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,
warnings=warnings,
)
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 ───────────────────────────────
mt3d_dataset = None
if self.physics == "mt3d":
self._log.info(
"Generating %d synthetic 3-D Maxwell realizations "
"(%d stations × %d freqs)…",
self.n_train_profiles,
n_sta,
n_freqs,
)
try:
mt3d_dataset, X_3d, y_3d = _generate_mt3d_training_data(
station_names=station_names,
coords_m=coords_m,
freqs=freqs,
n_layers=self.n_layers,
ths=ths,
depth_max_m=depth_max_cfg,
correlation_length_x_m=self.correlation_length_x_m,
correlation_length_y_m=self.correlation_length_y_m,
correlation_length_z_m=self.correlation_length_z_m,
log_resistivity_mean=self.log_resistivity_mean,
log_resistivity_std=self.log_resistivity_std,
mesh_safety_factor=self.mesh_safety_factor,
max_mesh_cells=self.max_mesh_cells,
n_realizations=self.n_train_profiles,
n_features=n_features,
geology_grid_nx_ny=self.geology_grid_nx_ny,
geology_grid_nz=self.geology_grid_nz,
cells_per_skin_depth=self.cells_per_skin_depth,
verbose=self.verbose,
)
n_samp = len(mt3d_dataset.select("train"))
except Exception as exc:
return AgentResult.failed(
f"3-D Maxwell dataset assembly failed: {exc}",
elapsed=time.time() - t0,
warnings=warnings,
)
else:
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=self.verbose,
)
# 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)
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,
warnings=warnings,
)
# ── train GCNInverter3D ───────────────────────────────────────────────
self._log.info(
"Training GCNInverter3D (GCN hidden=%s) for %d epochs…",
self.hidden,
self.epochs,
)
# Delay loading PyTorch/TensorFlow until all SciPy Maxwell solves have
# finished. Importing PyTorch first can load a second OpenMP runtime on
# Windows and abort the sparse solver before its first realization.
try:
from ..backends import get_backend_instance
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,
warnings=warnings,
)
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=(
self.patience
if self.patience is not None
else max(5, self.epochs // 5)
),
verbose=self.verbose,
)
except Exception as exc:
return AgentResult.failed(
f"GCNInverter3D training failed: {exc}",
elapsed=time.time() - t0,
warnings=warnings,
)
# ── 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,
warnings=warnings,
)
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}")
# ── known-truth recovery check (physics="mt3d" only) ────────────────
# The field survey has no ground truth; this instead checks the
# trained network against held-out synthetic realizations whose
# true resistivity is known, per the AI-inversion plan's M0
# baseline-metrics requirement.
mt3d_recovery: dict[str, Any] | None = None
if mt3d_dataset is not None:
try:
mt3d_recovery = _mt3d_recovery_check(
inverter, mt3d_dataset, A, self.n_layers, ths, n_features
)
except Exception as exc:
warnings.append(f"mt3d recovery check failed: {exc}")
# ── depth axis (in km) ────────────────────────────────────────────────
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}")
# Topography is deliberately applied after inversion. The current
# mt1d synthetic forward solver has no terrain-aware mesh, so changing
# the predicted resistivity here would imply physics that was never
# solved. Instead, resolve station elevations and return/render the
# model in an absolute-elevation coordinate system.
topography = resolve_agent_topography(
input_data.get("topography", False),
sites=sites,
station_names=station_names,
coords_m=coords_m,
warnings_list=warnings,
)
if topography is not None and topography["applied"]:
try:
from ..topo import plot_topo_section
topo_model = {
"pred_rho": pred_rho,
"depths_km": depths,
"station_names": station_names,
"station_coords": coords_m,
"rms_global": rms_global,
}
ax = plot_topo_section(
topo_model,
elevation=topography["elevation_m"],
chainage=topography["chainage_km"],
station_names=station_names,
station_x=topography["chainage_km"],
topo_source="array",
model_unit="km",
depth_max=float(depths[-1]),
exaggeration=topography["exaggeration"],
interp_method=topography["interp_method"],
title="GCN inversion draped below station topography",
)
fig_topo = ax.get_figure()
figures["topography_section"] = fig_topo
p = self._save_figure(
fig_topo,
output_dir,
"inv3d_topography_section",
warnings_list=warnings,
)
if p:
fig_paths["topography_section"] = p
topography["rendered"] = True
except Exception as exc:
topography["rendered"] = False
warnings.append(f"Topography section: {exc}")
# ── LLM interpretation ────────────────────────────────────────────────
interp: str | None = None
if self.llm_available:
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 ""
recovery_note = ""
if mt3d_recovery is not None:
recovery_note = (
f" Held-out recovery RMSE={mt3d_recovery['rmse']:.3f} "
f"(log10 Ω·m, n={mt3d_recovery['n_samples']})."
)
return AgentResult(
status="success",
summary=(
f"3-D GCN inversion (physics={self.physics}): "
f"{n_sta} stations × {self.n_layers} layers. "
f"{rms_disp}{unc_disp}. {len(figures)} figures."
f"{recovery_note}"
),
data={
"pred_rho": pred_rho,
"pred_thick": pred_thick,
"pred_uncertainty": pred_uncertainty,
"depths_km": depths,
"frequency_grid_hz": freqs,
"configured_depth_max_m": depth_max_cfg,
"topography": topography,
"station_elevation_m": (
None if topography is None else topography["elevation_m"]
),
"station_chainage_km": (
None if topography is None else topography["chainage_km"]
),
"station_names": station_names,
"station_coords": coords_m,
"adjacency": A,
"rms_global": rms_global,
"inverter": inverter,
"training_history": dict(getattr(inverter, "_history", {})),
"epochs_completed": len(
getattr(inverter, "_history", {}).get("val_loss", [])
),
"best_validation_loss": float(
getattr(inverter, "_meta", {}).get("best_val_loss", np.nan)
),
"n_edges": off_diag_edges,
"figures": figures,
"figure_paths": fig_paths,
"physics": self.physics,
"mt3d_recovery": mt3d_recovery,
},
warnings=warnings,
llm_interpretation=interp,
elapsed_seconds=elapsed,
cost_estimate_usd=self._last_cost,
)
# ── private helpers ───────────────────────────────────────────────────────────
def _agent_thicknesses(
n_layers: int,
freqs: np.ndarray,
depth_max: float | None,
) -> np.ndarray:
"""Return legacy or explicitly depth-limited interface thicknesses."""
if depth_max is None:
return _default_thicknesses(n_layers, freqs)
n_finite = max(int(n_layers) - 1, 1)
weights = np.geomspace(1.0, 3.0, n_finite)
return (float(depth_max) * weights / weights.sum()).astype(float)
def _extract_station_xy(ed: Any, idx: int) -> np.ndarray:
"""Return (x_m, y_m) for one station from EDI header lat/lon."""
try:
coords = getattr(ed, "coords", None)
if coords is not None and len(coords) >= 2:
lat, lon = float(coords[0]), float(coords[1])
else:
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, 0.0
if lat == 0.0 and lon == 0.0:
return np.array(
[float(idx) * 1.0, 0.0], dtype=np.float64
) # 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 _generate_mt3d_training_data(
*,
station_names: list[str],
coords_m: np.ndarray,
freqs: np.ndarray,
n_layers: int,
ths: np.ndarray,
depth_max_m: float | None,
correlation_length_x_m: tuple[float, float],
correlation_length_y_m: tuple[float, float],
correlation_length_z_m: tuple[float, float],
log_resistivity_mean: float,
log_resistivity_std: float,
mesh_safety_factor: float,
max_mesh_cells: int,
n_realizations: int,
n_features: int,
geology_grid_nx_ny: int,
geology_grid_nz: int | None,
cells_per_skin_depth: float | None,
verbose: bool | int | str = False,
):
"""Build a genuinely 3-D correlated training set at the survey's
own real station positions and convert it to
:class:`~pycsamt.ai.inversion.inv3d.GCNInverter3D`'s tensors.
Unlike ``Inv2DAgent(physics="mt2d")``'s synthetic uniform station
spacing, this reuses *coords_m* directly (already needed for the
GCN adjacency graph), so training profiles share the real
station geometry.
Returns
-------
dataset : Maxwell3DDataset
Full dataset (train/validation/test), kept for a later
known-truth recovery check.
X : ndarray (n_train, n_stations, n_features)
y : ndarray (n_train, n_stations, 2*n_layers-1)
"""
from ..ai.geology import GeologyGrid
from ..ai.training.dataset3d import (
Maxwell3DDatasetConfig,
generate_3d_maxwell_dataset,
)
xs, ys = coords_m[:, 0], coords_m[:, 1]
x_span = max(float(xs.max() - xs.min()), 1.0)
y_span = max(float(ys.max() - ys.min()), 1.0)
margin_x = max(0.25 * x_span, 300.0)
margin_y = max(0.25 * y_span, 300.0)
x_lo, x_hi = float(xs.min()) - margin_x, float(xs.max()) + margin_x
y_lo, y_hi = float(ys.min()) - margin_y, float(ys.max()) + margin_y
depth_total_m = (
float(depth_max_m) if depth_max_m is not None else float(np.sum(ths))
)
n_core_xy = int(geology_grid_nx_ny)
n_core_z = (
min(max(n_layers, 4), 8)
if geology_grid_nz is None
else int(geology_grid_nz)
)
grid = GeologyGrid.regular_3d(
nx=n_core_xy,
ny=n_core_xy,
nz=n_core_z,
dx_m=(x_hi - x_lo) / n_core_xy,
dy_m=(y_hi - y_lo) / n_core_xy,
dz_m=depth_total_m / n_core_z,
x_origin_m=x_lo,
y_origin_m=y_lo,
)
config = Maxwell3DDatasetConfig(
dataset_id="inv3dagent-mt3d",
grid=grid,
correlation_length_x_m=correlation_length_x_m,
correlation_length_y_m=correlation_length_y_m,
correlation_length_z_m=correlation_length_z_m,
frequencies_hz=freqs,
station_xy_m=coords_m,
n_realizations=n_realizations,
seed=0,
log_resistivity_mean=log_resistivity_mean,
log_resistivity_std=log_resistivity_std,
components=("zxy",),
mesh_safety_factor=mesh_safety_factor,
max_mesh_cells=max_mesh_cells,
cells_per_skin_depth=cells_per_skin_depth,
validation_fraction=0.1,
test_fraction=0.1,
verbose=verbose,
)
dataset = generate_3d_maxwell_dataset(config)
X, y = _maxwell3d_samples_to_gcn_arrays(
dataset.select("train"),
dataset.grid,
coords_m,
freqs,
n_layers,
ths,
n_features,
)
return dataset, X, y
def _nearest_index(axis: np.ndarray, value: float) -> int:
"""Return the index of *axis*'s entry closest to *value*."""
return int(np.argmin(np.abs(axis - value)))
def _maxwell3d_samples_to_gcn_arrays(
samples,
grid,
coords_m: np.ndarray,
freqs: np.ndarray,
n_layers: int,
ths: np.ndarray,
n_features: int,
) -> tuple[np.ndarray, np.ndarray]:
"""Convert ``Maxwell3DSample`` objects into
``GCNInverter3D``'s ``(X, y)`` convention.
``X`` uses the same ``[log10(rho_a_xy), phase_xy]`` layout as
:func:`~pycsamt.agents.ai_inversion._z_to_features` (zero-padded
to *n_features*), so synthetic training data and the observed
features share one convention. ``y``'s resistivity half is each
station's true vertical column, nearest-cell resampled from the
3-D volume's own (coarser) depth grid onto the agent's display
depth grid (*ths*); the thickness half is the fixed display grid
itself, exactly as the ``physics="mt1d"`` path already uses.
"""
if not samples:
raise ValueError("samples must contain at least one entry.")
n_sta = coords_m.shape[0]
n_freq = len(freqs)
mu0 = 4.0e-7 * np.pi
log_h = np.log10(np.clip(ths, 1.0, None)).astype(np.float32)
layer_depths_m = np.concatenate([[0.0], np.cumsum(ths)])[:n_layers]
x_idx = np.asarray([_nearest_index(grid.x_m, x) for x in coords_m[:, 0]])
y_idx = np.asarray([_nearest_index(grid.y_m, y) for y in coords_m[:, 1]])
z_idx = np.asarray(
[_nearest_index(grid.z_m, d) for d in layer_depths_m]
) # (n_layers,)
n_out = 2 * n_layers - 1
X = np.zeros((len(samples), n_sta, n_features), dtype=np.float32)
y = np.empty((len(samples), n_sta, n_out), dtype=np.float32)
for i, sample in enumerate(samples):
zxy = sample.survey.impedance[:, :, 0] # (n_sta, n_freq)
f = sample.survey.frequencies_hz[None, :]
rho_a = np.abs(zxy) ** 2 / (2.0 * np.pi * f * mu0)
phase = np.degrees(np.angle(zxy))
feat = np.concatenate(
[
np.log10(np.clip(rho_a, 1e-12, None)),
phase,
],
axis=1,
).astype(np.float32) # (n_sta, 2 * n_freq)
X[i, :, : min(2 * n_freq, n_features)] = feat[
:, : min(2 * n_freq, n_features)
]
columns = sample.resistivity_ohm_m[:, y_idx, x_idx] # (nz, n_sta)
log_rho_cols = np.log10(columns[z_idx, :]).T # (n_sta, n_layers)
y[i, :, :n_layers] = log_rho_cols
y[i, :, n_layers:] = log_h[None, :]
return X, y
def _mt3d_recovery_check(
inverter,
dataset,
adjacency: np.ndarray,
n_layers: int,
ths: np.ndarray,
n_features: int,
) -> dict[str, Any] | None:
"""Evaluate known-truth recovery on a held-out dataset partition.
Prefers the ``test`` partition, falling back to ``validation``;
returns ``None`` when neither has samples (e.g. too few
realizations were requested to split three ways).
"""
from ..ai.validation import recovery_report
held_out = dataset.select("test") or dataset.select("validation")
if not held_out:
return None
coords_m = np.asarray(held_out[0].survey.coordinates_m)[:, :2]
freqs = held_out[0].survey.frequencies_hz
rmse_vals, mae_vals, r2_vals = [], [], []
for sample in held_out:
X_i, _ = _maxwell3d_samples_to_gcn_arrays(
[sample], dataset.grid, coords_m, freqs, n_layers, ths, n_features
)
pred = inverter.predict(X_i[0], adjacency=adjacency) # (n_sta, n_out)
pred_log_rho = pred[:, :n_layers]
x_idx = np.asarray(
[_nearest_index(dataset.grid.x_m, x) for x in coords_m[:, 0]]
)
y_idx = np.asarray(
[_nearest_index(dataset.grid.y_m, y) for y in coords_m[:, 1]]
)
layer_depths_m = np.concatenate([[0.0], np.cumsum(ths)])[:n_layers]
z_idx = np.asarray(
[_nearest_index(dataset.grid.z_m, d) for d in layer_depths_m]
)
columns = sample.resistivity_ohm_m[:, y_idx, x_idx]
true_log_rho = np.log10(columns[z_idx, :]).T # (n_sta, n_layers)
report = recovery_report(
pred_log_rho, true_log_rho, compute_ssim=False
)
rmse_vals.append(report.rmse)
mae_vals.append(report.mae)
r2_vals.append(report.r2)
return {
"rmse": float(np.mean(rmse_vals)),
"mae": float(np.mean(mae_vals)),
"r2": float(np.nanmean(r2_vals)),
"n_samples": len(held_out),
}
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 _profile_distance_km(coords_m: np.ndarray) -> np.ndarray:
"""Project station coordinates onto the dominant profile direction."""
n_sta = coords_m.shape[0]
cx = coords_m[:, 0] / 1000.0
cy = coords_m[:, 1] / 1000.0
if n_sta <= 1:
return np.zeros(max(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
return np.array(
[np.dot([cx[i] - cx[0], cy[i] - cy[0]], vec) for i in range(n_sta)]
)
def _is_profile_geometry(
coords_m: np.ndarray, ratio_threshold: float = 0.15
) -> bool:
"""Return True when *coords_m* is essentially a 1-D line (a profile),
rather than a genuine areal 2-D footprint.
Many AMT/CSAMT surveys (e.g. WILLY L18) place every station on one
line, so a plan-view E-W/N-S map (Delaunay-triangulated) degenerates
to a zero-width sliver with nothing to fill outside it. This is
detected via the ratio of the two principal spatial extents (SVD of
the mean-centred coordinates): a ratio below *ratio_threshold* means
the cross-line spread is negligible next to the along-line spread.
"""
if coords_m.shape[0] < 3:
return True
centred = coords_m - coords_m.mean(axis=0, keepdims=True)
singular_values = np.linalg.svd(centred, compute_uv=False)
if singular_values[0] <= 0:
return True
return bool((singular_values[1] / singular_values[0]) < ratio_threshold)
def _smooth_depth_axis(
mat: np.ndarray, depths: np.ndarray, n_upsample: int = 200
) -> tuple[np.ndarray, np.ndarray]:
"""Resample a (n_layers, n_sta) section onto a finer depth axis.
The agent's layer count (``n_layers``) is a network-capacity/training
choice, typically kept small (5-10); rendering it directly with
``imshow`` therefore produces a coarse, blocky section. This applies
a monotone cubic (PCHIP) interpolation independently per station so
the displayed section reads as a continuous resistivity profile
without inventing new inversion information between layers.
"""
from scipy.interpolate import PchipInterpolator
n_layers, n_sta = mat.shape
# 'depths' has one entry per layer (depths[0] == 0.0, the survey
# surface); it is used directly as each row's sample location, same
# as the pre-upsampling imshow's implicit even spacing between
# depths[0] and depths[-1].
layer_depths = np.asarray(depths, dtype=float)[:n_layers]
if n_layers < 2 or layer_depths.size < 2:
return mat, depths
fine_depths = np.linspace(layer_depths[0], layer_depths[-1], n_upsample)
fine_mat = np.empty((n_upsample, n_sta), dtype=mat.dtype)
for si in range(n_sta):
column = mat[:, si]
finite = np.isfinite(column)
if finite.sum() < 2:
fine_mat[:, si] = np.nanmean(column) if finite.any() else np.nan
continue
fine_mat[:, si] = PchipInterpolator(
layer_depths[finite], column[finite]
)(fine_depths)
return fine_mat, fine_depths
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
dist = _profile_distance_km(coords_m)
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)
mat, depth_axis = _smooth_depth_axis(mat, depths)
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,
depth_axis[-1],
depth_axis[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_section(
pred_unc: np.ndarray, # (n_sta, n_layers)
coords_m: np.ndarray,
station_names: list[str],
depths: np.ndarray, # km
) -> Any:
"""Uncertainty pseudo-section (profile distance vs depth).
Used instead of :func:`_plot_uncertainty_depth_map`'s plan-view map
when the station layout is a profile (see
:func:`_is_profile_geometry`): a Delaunay-triangulated E-W/N-S map of
near-collinear stations degenerates to a zero-width sliver, so the
same distance-vs-depth projection the resistivity section already
uses is far more informative here.
"""
import matplotlib.pyplot as plt
from ..api.section import PYCSAMT_SECTION
from ..api.station import PYCSAMT_STATION_RENDERING
n_sta, n_layers = pred_unc.shape
if n_sta == 0:
return None
dist = _profile_distance_km(coords_m)
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_unc.T # (n_layers, n_sta)
mat, depth_axis = _smooth_depth_axis(mat, depths)
vv = mat[np.isfinite(mat)]
vmax = float(np.percentile(vv, 95)) if vv.size else 1.0
im = ax.imshow(
mat,
aspect="auto",
origin="upper",
extent=(
dist[0] - 0.25,
dist[-1] + 0.25,
depth_axis[-1],
depth_axis[0],
),
cmap="Oranges",
vmin=0.0,
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₁₀ρ)")
ax.set_title(
"3-D GCN — MC-dropout uncertainty (σ log₁₀ρ)",
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.
Falls back to :func:`_plot_uncertainty_section` for profile-shaped
surveys, where a plan-view map has no real cross-line information.
"""
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
if _is_profile_geometry(coords_m):
return _plot_uncertainty_section(
pred_unc, coords_m, station_names, depths
)
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"]