"""
pycsamt.agents.inv2d_agent
===========================
:class:`Inv2DAgent` — U-Net based 2-D MT profile inversion.
Wraps :class:`~pycsamt.ai.inversion.inv2d.EMInverter2D`:
* Assembles a **2-D pseudosection** image from the observed Sites (station ×
frequency × impedance component) as the U-Net input.
* Generates a matching synthetic **2-D training dataset**, using one of two
``physics`` modes (see below).
* Trains the U-Net and produces a **resistivity section** output:
(n_depth × n_stations) in log₁₀ Ω·m.
* Visualises the result with
:func:`~pycsamt.ai.plot.inversion.plot_inversion_result_2d`.
The U-Net treats the whole profile at once, so it naturally captures
lateral continuity — a key advantage over station-by-station 1-D inversion.
Physics modes
-------------
``physics="mt1d"`` (default)
Tiles independent 1-D forward models into profile-shaped arrays
(:func:`~pycsamt.forward.batch.generate_dataset`). This is the
original smoke/demo path from the AI-inversion implementation
plan: nothing enforces lateral continuity between the tiled 1-D
columns, so the plan explicitly does not call it genuine 2-D
inversion. Kept unconditionally as the default and as an explicit
fallback, per the plan's requirement.
``physics="mt2d"``
Generates genuinely 2-D correlated training models and solves
them with the verified
:class:`~pycsamt.forward.maxwell.mt2d.MT2DAdapter`
(:func:`~pycsamt.ai.training.dataset2d.generate_2d_maxwell_dataset`),
and can add spatial regularization
(:meth:`~pycsamt.ai.inversion.inv2d.EMInverter2D.fit`'s
``lambda_x``/``lambda_z``/``lambda_tv``). Only the TE-mode
(``zxy``) response is requested here, even though
:mod:`pycsamt.ai.training.dataset2d` now validates the TM-mode
(``zyx``) response too (see its module docstring): this agent's
feature layout is still TE-only, so requesting ``zyx`` here would
only cost an extra, unused forward solve per realization, not
change accuracy. 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.
``physics="mt2d_tri"``
Generates triangular-mesh correlated training models
(:func:`~pycsamt.ai.training.dataset2d_tri.generate_2d_tri_maxwell_dataset`)
solved with :class:`~pycsamt.forward.maxwell.mare2dem.Mare2DEMAdapter`
-- **unverified physics**, see that adapter's own module docstring;
it needs a real compiled MARE2DEM binary this environment does not
have. Uses the same synthetic uniform ``station_spacing_m`` geometry
``physics="mt2d"`` already documents (real station coordinates are
not read here either). Output is a per-triangle
``log10(resistivity)`` field, not a ``(depth, station)`` grid, so it
is trained and rendered differently from the other two modes: a
graph-convolutional inverter
(:class:`~pycsamt.ai.inversion.inv3d.GCNInverter3D` with
``n_layers=1``, reused rather than duplicated -- its architecture
has nothing 3-D/station-specific about it, only its ``n_layers``
naming, here repurposed to mean "one scalar per graph node" instead
of "one depth column per station") over the mesh's
triangle-centroid adjacency graph, rendered with
:func:`~pycsamt.api.mesh.draw_tri_mesh`. Not plotted via
:func:`~pycsamt.ai.plot.inversion.plot_inversion_result_2d` (that
function is rectilinear-only) or draped with real topography (that
needs real station coordinates, which this mode -- like
``physics="mt2d"`` -- does not read).
Requires PyTorch **or** TensorFlow (``physics="mt2d"``'s spatial
regularization is PyTorch-only, and ``physics="mt2d_tri"``'s GCN
inverter always needs one; see ``EMInverter2D.fit``/``GCNInverter3D``).
"""
from __future__ import annotations
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 INV2D_PARAM_CONSTRAINTS
from .ai_inversion import _default_thicknesses, _z_to_features
_SYSTEM_PROMPT = """\
You are an expert in 2-D MT inversion using deep learning (U-Net architecture).
Given a 2-D AI inversion result, write 4-5 sentences that:
1. Describe the input pseudosection geometry (stations × frequencies).
2. Interpret the dominant structural features in the resistivity section.
3. Assess lateral continuity and compare to classical smoothness-constrained results.
4. Identify artefacts or stations with poor convergence.
5. Recommend follow-up (regularisation, 3-D verification, drilling targets).
Reply in plain scientific English.
"""
_DEFAULT_FREQS_2D = np.logspace(-4, 3, 32) # 32 frequencies — U-Net input size
[docs]
class Inv2DAgent(BaseAgent):
"""2-D MT profile inversion using a U-Net convolutional architecture.
Parameters
----------
api_key, model, llm_provider : str
n_depth : int
Number of depth cells in the output section (default 40).
n_freqs : int
Number of input frequencies (default 32).
freqs : array-like or None
Explicit positive frequency grid in hertz.
depth_max : float or None
Maximum cumulative model depth in metres. ``None`` preserves the
legacy frequency-derived parameterization.
n_components : int
Number of channels in the input pseudosection (default 2:
log10 apparent resistivity and phase for the xy component).
arch : str
U-Net variant (default ``"unet"``).
n_train_profiles : int
Number of synthetic 2-D profiles for training (default 200).
n_stations_per_profile : int
Stations per synthetic profile (default 20).
epochs : int
Training epochs (default 30).
patience : int or None
Early-stopping patience. ``None`` uses one fifth of ``epochs`` with
a minimum of five. Set greater than ``epochs`` only for a controlled
fixed-epoch experiment; validation-based stopping is normally safer.
physics : {"mt1d", "mt2d", "mt2d_tri"}, default "mt1d"
Synthetic training-data physics; see the module docstring.
station_spacing_m : float, default 500.0
Uniform synthetic station spacing used by ``physics="mt2d"``
and ``physics="mt2d_tri"``; the actual survey's real station
geometry is not read by either.
correlation_length_x_m, correlation_length_z_m : (float, float)
``physics="mt2d"``/``"mt2d_tri"`` only: horizontal/vertical
correlation length ranges forwarded to
:class:`~pycsamt.ai.training.dataset2d.Maxwell2DDatasetConfig`/
:class:`~pycsamt.ai.training.dataset2d_tri.MaxwellTri2DDatasetConfig`.
log_resistivity_mean, log_resistivity_std : float
``physics="mt2d"``/``"mt2d_tri"`` only: affine map from the
standardized correlated field to ``log10(resistivity_ohm_m)``.
mesh_safety_factor, max_mesh_cells : float, int
``physics="mt2d"`` only: forwarded to
``Maxwell2DDatasetConfig``.
lambda_x, lambda_z, lambda_tv : float, default 0.0
Spatial-regularization weights forwarded to
:meth:`~pycsamt.ai.inversion.inv2d.EMInverter2D.fit`. Zero by
default, so nothing changes unless explicitly requested.
``physics="mt2d"`` only.
mesh_target_cell_m, field_grid_cell_m : float
``physics="mt2d_tri"`` only: forwarded to
``MaxwellTri2DDatasetConfig``.
topo_x_m, topo_z_m : array-like, optional
``physics="mt2d_tri"`` only: real topography (z positive down)
forwarded to ``MaxwellTri2DDatasetConfig``/
:func:`~pycsamt.forward.maxwell.tri_mesh_gen.build_graded_tri_mesh`.
Both default to ``None`` (flat surface at ``z=0``, unchanged from
before this parameter existed) -- when given, training stations
sit at their true interpolated elevation instead. This builds a
real topography-following *training mesh*; it is unrelated to
the ``topography`` execute()-time input key above, which only
re-renders an already-flat ``mt1d``/``mt2d`` prediction in an
absolute-elevation display frame.
gcn_hidden : tuple of int, default (64, 32, 16)
``physics="mt2d_tri"`` only: hidden-layer widths forwarded to
:class:`~pycsamt.ai.inversion.inv3d.GCNInverter3D`.
gcn_adjacency_radius_m : float, default 300.0
``physics="mt2d_tri"`` only: triangle-centroid adjacency
radius forwarded to
:func:`~pycsamt.ai.nets.gcn.build_adjacency`. Must be set
relative to the actual mesh scale, not left at the default: it
is a hard cutoff in the same metres as ``mesh_target_cell_m``,
and if it is smaller than the typical distance between
neighbouring triangle centroids, ``build_adjacency`` returns
the identity matrix (every triangle connected only to itself)
and the GCN silently degenerates into a per-triangle lookup
with no spatial message-passing at all -- no error is raised.
A radius of roughly 1.5-2x ``mesh_target_cell_m`` is a
reasonable starting point; verify with
``build_adjacency(mesh.triangle_centroids_m, radius).sum() >
mesh.n_triangles`` (more than just the self-loops) before
trusting a training run.
mare2dem_adapter : object, optional
``physics="mt2d_tri"`` only: pre-built
:class:`~pycsamt.forward.maxwell.mare2dem.Mare2DEMAdapter`
(e.g. pointed at a specific compiled binary). Defaults to
``Mare2DEMAdapter()``, resolved from the environment.
Input keys
----------
``sites`` / ``path`` : Sites or str
``output_dir`` : str, optional
``topography`` : bool or dict, optional
Extract terrain from ``sites`` and render the predicted section in an
absolute-elevation frame. A mapping can provide ``elevation_m`` and
``chainage_km`` plus ``exaggeration`` and ``interp_method``.
``period_range`` : [T_min, T_max], optional
``freqs`` : array-like, optional — execution-time frequency override
``depth_max`` : float, optional — cumulative model depth in metres
Output data keys
----------------
``pred_section`` ndarray (n_depth × n_stations) — log₁₀ ρ
(``physics="mt1d"``/``"mt2d"`` only)
``pred_triangles`` dict with ``"mesh"``/``"log10_resistivity"``
(``physics="mt2d_tri"`` only)
``depths_km`` ndarray — depth axis (km)
(``physics="mt1d"``/``"mt2d"`` only)
``station_names`` list[str]
``topography`` dict or None — resolved terrain metadata
(``physics="mt1d"``/``"mt2d"`` only)
``rms_global`` float (``physics="mt1d"``/``"mt2d"`` only)
``inverter`` EMInverter2D or GCNInverter3D
``figures`` dict
``figure_paths`` dict
``physics`` str — ``"mt1d"``, ``"mt2d"``, or
``"mt2d_tri"``, mode used
``mt2d_recovery`` dict or None — held-out known-truth
recovery metrics (``physics="mt2d"`` only)
``mt2d_tri_recovery`` dict or None — held-out known-truth
recovery metrics (``physics="mt2d_tri"`` only)
"""
SYSTEM_PROMPT = _SYSTEM_PROMPT
@validate_params(INV2D_PARAM_CONSTRAINTS, prefer_skip_nested_validation=True)
def __init__(
self,
*,
api_key: str | None = None,
model: str | None = None,
llm_provider: str = "claude",
n_depth: int = 40,
n_freqs: int = 32,
freqs: Any = None,
depth_max: float | None = None,
n_components: int = 2,
arch: str = "unet",
n_train_profiles: int = 200,
n_stations_per_profile: int = 20,
epochs: int = 30,
patience: int | None = None,
physics: str = "mt1d",
station_spacing_m: float = 500.0,
correlation_length_x_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 = 8.0,
max_mesh_cells: int = 200_000,
lambda_x: float = 0.0,
lambda_z: float = 0.0,
lambda_tv: float = 0.0,
mesh_target_cell_m: float = 100.0,
field_grid_cell_m: float = 50.0,
topo_x_m: Any = None,
topo_z_m: Any = None,
gcn_hidden: tuple[int, ...] = (64, 32, 16),
gcn_adjacency_radius_m: float = 300.0,
mare2dem_adapter: Any | None = None,
verbose: bool | int | str = False,
) -> None:
super().__init__(
"Inv2DAgent",
api_key=api_key,
model=model,
llm_provider=llm_provider,
section_preset="inversion",
verbose=verbose,
)
self.n_depth = n_depth
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_components = n_components
self.arch = arch
self.n_train_profiles = n_train_profiles
self.n_stations_per_profile = n_stations_per_profile
self.epochs = epochs
self.patience = (
None if patience is None else max(1, int(patience))
)
self.physics = physics
self.station_spacing_m = float(station_spacing_m)
self.correlation_length_x_m = correlation_length_x_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.lambda_x = float(lambda_x)
self.lambda_z = float(lambda_z)
self.lambda_tv = float(lambda_tv)
self.mesh_target_cell_m = float(mesh_target_cell_m)
self.field_grid_cell_m = float(field_grid_cell_m)
self.topo_x_m = topo_x_m
self.topo_z_m = topo_z_m
self.gcn_hidden = tuple(int(h) for h in gcn_hidden)
self.gcn_adjacency_radius_m = float(gcn_adjacency_radius_m)
self.mare2dem_adapter = mare2dem_adapter
[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.inv2d import EMInverter2D
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"Inv2DAgent 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,
)
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")
import os
if output_dir:
os.makedirs(output_dir, exist_ok=True)
n_sta_obs = sum(1 for _ in _iter_items(sites))
n_sta_use = min(n_sta_obs, self.n_stations_per_profile)
freqs_cfg = input_data.get("freqs", self.freqs)
if freqs_cfg is None:
freqs = _DEFAULT_FREQS_2D[: 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)
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,
)
# ── build observed pseudosection ───────────────────────────────────────
station_names: list[str] = []
obs_feats: list[np.ndarray] = [] # each: (n_freqs, n_components)
for i, ed in enumerate(_iter_items(sites)):
if len(station_names) >= n_sta_use:
break
nm = _name(ed, i)
_, z, fr = _get_z_block(ed)
if z is None:
continue
feat = _z_to_features(ed, z, fr, freqs)
if feat is None:
warnings.append(f"{nm}: skipped (bad data).")
continue
station_names.append(nm)
obs_feats.append(feat)
if len(station_names) < 3:
return AgentResult.failed(
"Fewer than 3 usable stations — cannot run 2-D inversion.",
elapsed=time.time() - t0,
warnings=warnings,
)
n_sta = len(station_names)
# each observed feature is flat [log10(rho_xy) | phase_xy]
# (length 2 * n_freqs): fold back into (n_components=2, n_freqs)
comp_feats = [
np.asarray(f, dtype=np.float32).reshape(2, n_freqs)
for f in obs_feats
]
X_obs = np.stack(comp_feats, axis=2) # (n_components, n_freqs, n_sta)
X_obs_4d = X_obs[None, ...] # (1, n_components, n_freqs, n_sta)
# ── triangular-mesh path: separate output shape, separate return ──────
if self.physics == "mt2d_tri":
return self._execute_mt2d_tri(
station_names=station_names,
comp_feats=comp_feats,
freqs=freqs,
output_dir=output_dir,
warnings=warnings,
t0=t0,
)
# ── generate synthetic 2-D training data ───────────────────────────────
mt2d_dataset = None
if self.physics == "mt2d":
self._log.info(
"Generating %d synthetic 2-D Maxwell realizations "
"(%d stations × %d freqs)…",
self.n_train_profiles,
n_sta,
n_freqs,
)
try:
mt2d_dataset, X2d, y2d = _generate_mt2d_training_data(
n_sta=n_sta,
freqs=freqs,
n_depth=self.n_depth,
depth_max_m=depth_max_cfg,
station_spacing_m=self.station_spacing_m,
correlation_length_x_m=self.correlation_length_x_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,
verbose=self.verbose,
)
n_samp = len(mt2d_dataset.select("train"))
except Exception as exc:
return AgentResult.failed(
f"2-D Maxwell dataset assembly failed: {exc}",
elapsed=time.time() - t0,
warnings=warnings,
)
else:
self._log.info(
"Generating %d synthetic 2-D profiles (%d stations × %d freqs)…",
self.n_train_profiles,
n_sta,
n_freqs,
)
try:
n_layers = max(3, self.n_depth // 8)
n_1d = self.n_train_profiles * n_sta
ds1d = generate_dataset(
solver="mt1d",
n_samples=n_1d,
freqs=freqs,
n_layers=n_layers,
noise_level=0.03,
seed=0,
n_jobs=1,
verbose=self.verbose,
)
# X1d: (n_1d, n_freqs * 2) flat [log10(rho_xy) | phase_xy]
# → reshape to profiles
X1d = ds1d.X # (n_1d, n_freqs * 2)
# ds1d.y is [log10(rho) (n_layers) | log10(h) (n_layers - 1)];
# the 2-D section target is log10(rho) by depth only.
y1d = ds1d.y[:, :n_layers] # (n_1d, n_layers)
n_samp = n_1d // n_sta
X1d = X1d[: n_samp * n_sta]
y1d = y1d[: n_samp * n_sta]
# reshape: (n_samp, n_sta, n_freqs, 2)
X2d_raw = X1d.reshape(n_samp, n_sta, n_freqs, 2)
# → (n_samp, 2, n_freqs, n_sta)
X2d = X2d_raw.transpose(0, 3, 2, 1)
y2d_raw = y1d.reshape(n_samp, n_sta, n_layers)
# → (n_samp, n_layers, n_sta) then upsample to n_depth
y2d = y2d_raw.transpose(0, 2, 1) # (n_samp, n_layers, n_sta)
if n_layers < self.n_depth:
from scipy.ndimage import zoom
y2d = zoom(
y2d, (1, self.n_depth / n_layers, 1), order=1
) # (n_samp, n_depth, n_sta)
except Exception as exc:
return AgentResult.failed(
f"2-D dataset assembly failed: {exc}",
elapsed=time.time() - t0,
warnings=warnings,
)
# ── train U-Net ────────────────────────────────────────────────────────
self._log.info(
"Training EMInverter2D (%s) for %d epochs…",
self.arch,
self.epochs,
)
try:
inv2d = EMInverter2D(
n_components=self.n_components,
n_depth=self.n_depth,
n_stations=n_sta,
n_freqs=n_freqs,
arch=self.arch,
)
inv2d.fit(
X2d,
y2d,
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,
lambda_x=self.lambda_x,
lambda_z=self.lambda_z,
lambda_tv=self.lambda_tv,
)
except Exception as exc:
return AgentResult.failed(
f"U-Net training failed: {exc}",
elapsed=time.time() - t0,
warnings=warnings,
)
# ── predict ────────────────────────────────────────────────────────────
try:
pred_2d = inv2d.predict(X_obs_4d)[0] # (n_depth, n_sta)
except Exception as exc:
return AgentResult.failed(
f"2-D prediction failed: {exc}",
elapsed=time.time() - t0,
warnings=warnings,
)
# ── known-truth recovery check (physics="mt2d" 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.
mt2d_recovery: dict[str, Any] | None = None
if mt2d_dataset is not None:
try:
mt2d_recovery = _mt2d_recovery_check(inv2d, mt2d_dataset)
except Exception as exc:
warnings.append(f"mt2d recovery check failed: {exc}")
ths = _inv2d_thicknesses(
self.n_depth,
freqs,
None if depth_max_cfg is None else float(depth_max_cfg),
)
depths = np.concatenate([[0.0], np.cumsum(ths)]) / 1000.0 # km
# ── figures ───────────────────────────────────────────────────────────
figures: dict[str, Any] = {}
fig_paths: dict[str, str] = {}
try:
from ..ai.plot.inversion import (
plot_inversion_result_2d,
)
station_pos = np.arange(n_sta, dtype=float) * 0.5 # 0.5 km spacing
fig_inv = plot_inversion_result_2d(
pred_2d,
depths=depths * 1000.0, # expects metres
stations=station_pos,
station_labels=station_names,
depth_max=float(depths[-1]) * 1000.0,
show_misfit=False,
show_convergence=False,
show_rmse=False,
suptitle="2-D AI inversion (U-Net)",
)
if fig_inv is not None:
figures["inv2d_section"] = fig_inv
p = self._save_figure(
fig_inv,
output_dir,
"inv2d_section",
warnings_list=warnings,
)
if p:
fig_paths["inv2d_section"] = p
except Exception as exc:
warnings.append(f"plot_inversion_result_2d: {exc}")
# fallback: simple imshow
try:
import matplotlib.pyplot as plt
from ..api.station import (
PYCSAMT_STATION_RENDERING,
)
fig, ax = plt.subplots(figsize=(12, 5))
vv = pred_2d[np.isfinite(pred_2d)]
im = ax.imshow(
pred_2d,
aspect="auto",
origin="upper",
extent=(-0.5, n_sta - 0.5, depths[-1], depths[0]),
cmap="jet_r",
vmin=float(np.percentile(vv, 5)) if vv.size else 0,
vmax=float(np.percentile(vv, 95)) if vv.size else 4,
interpolation="bilinear",
)
PYCSAMT_STATION_RENDERING.apply(
ax,
np.arange(n_sta, dtype=float),
station_names,
preset="inversion",
xlim=(-0.5, n_sta - 0.5),
)
ax.set_ylabel("Depth (km)", fontsize=9)
ax.tick_params(axis="y", labelsize=8)
self._section.add_colorbar(
im, ax, label="$\\log_{10}\\rho$ (Ω·m)"
)
ax.set_title(
"2-D AI inversion (U-Net)", fontsize=10, fontweight="bold"
)
fig.tight_layout()
figures["inv2d_section"] = fig
p = self._save_figure(
fig, output_dir, "inv2d_section", warnings_list=warnings
)
if p:
fig_paths["inv2d_section"] = p
except Exception:
pass
from ._topography import resolve_agent_topography
topography = resolve_agent_topography(
input_data.get("topography", False),
sites=sites,
station_names=station_names,
coords_m=None,
warnings_list=warnings,
)
if topography is not None and topography["applied"]:
try:
from ..topo import plot_topo_section
# The shared terrain adapter expects station x depth. The
# U-Net prediction is depth x station, hence the transpose.
topo_model = {
"pred_rho": pred_2d.T,
"depths_km": depths,
"station_names": station_names,
"rms_global": np.nan,
}
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="2-D U-Net inversion draped below station topography",
)
fig_topo = ax.get_figure()
figures["topography_section"] = fig_topo
p = self._save_figure(
fig_topo,
output_dir,
"inv2d_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_2d))
rho_std = float(np.nanstd(10**pred_2d))
prompt = (
f"2-D AI inversion (U-Net) summary:\n"
f" Profile: {n_sta} stations × {n_freqs} frequencies\n"
f" Section: {self.n_depth} depth cells, "
f" max depth {depths[-1]:.1f} km\n"
f" Mean resistivity: {rho_mean:.0f} Ω·m ± {rho_std:.0f}\n"
f" Warnings: {warnings[:3] if warnings else 'none'}\n\n"
"Interpret the 2-D resistivity section."
)
interp = self.query_llm(prompt, max_tokens=250)
# ── data-space RMS ────────────────────────────────────────
rms_global = _compute_rms_2d(X_obs, pred_2d, ths, freqs)
elapsed = time.time() - t0
recovery_note = ""
if mt2d_recovery is not None:
recovery_note = (
f" Held-out recovery RMSE={mt2d_recovery['rmse']:.3f} "
f"(log10 Ω·m, n={mt2d_recovery['n_samples']})."
)
return AgentResult(
status="success",
summary=(
f"2-D AI inversion (U-Net, physics={self.physics}): "
f"{n_sta} stations x "
f"{self.n_depth} depth cells. "
f"RMS={rms_global:.3f}. "
f"{len(figures)} figures."
f"{recovery_note}"
),
data={
"pred_section": pred_2d,
"depths_km": depths,
"frequency_grid_hz": freqs,
"configured_depth_max_m": depth_max_cfg,
"station_names": station_names,
"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"]
),
"rms_global": rms_global,
"inverter": inv2d,
"figures": figures,
"figure_paths": fig_paths,
"physics": self.physics,
"mt2d_recovery": mt2d_recovery,
},
warnings=warnings,
llm_interpretation=interp,
elapsed_seconds=elapsed,
cost_estimate_usd=self._last_cost,
)
def _execute_mt2d_tri(
self,
*,
station_names: list[str],
comp_feats: list[np.ndarray],
freqs: np.ndarray,
output_dir: str | None,
warnings: list[str],
t0: float,
) -> AgentResult:
"""``physics="mt2d_tri"`` branch: GCN inversion on a triangular mesh.
Separate from the shared U-Net path above because its output is
a per-triangle field, not a ``(depth, station)`` grid -- see the
module docstring's ``physics="mt2d_tri"`` section for why this
return is self-contained rather than merged into the shared
``AgentResult`` at the end of :meth:`execute`.
"""
n_sta = len(station_names)
n_freqs = len(freqs)
station_x = np.arange(n_sta, dtype=float) * self.station_spacing_m
self._log.info(
"Generating %d synthetic triangular-mesh 2-D Maxwell "
"realizations (%d stations x %d freqs)…",
self.n_train_profiles,
n_sta,
n_freqs,
)
try:
dataset, mesh, X_train, y_train = _generate_mt2d_tri_training_data(
station_x_m=station_x,
freqs=freqs,
depth_max_m=self.depth_max,
correlation_length_x_m=self.correlation_length_x_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_target_cell_m=self.mesh_target_cell_m,
field_grid_cell_m=self.field_grid_cell_m,
topo_x_m=self.topo_x_m,
topo_z_m=self.topo_z_m,
n_realizations=self.n_train_profiles,
adapter=self.mare2dem_adapter,
verbose=self.verbose,
)
except Exception as exc:
return AgentResult.failed(
f"Triangular-mesh Maxwell dataset assembly failed: {exc}",
elapsed=time.time() - t0,
warnings=warnings,
)
self._log.info(
"Training GCNInverter3D (n_layers=1) for %d epochs…", self.epochs
)
try:
from ..ai.inversion.inv3d import GCNInverter3D
gcn = GCNInverter3D(
n_features=2 * n_freqs + N_POSITION_FEATURES,
n_layers=1,
hidden=self.gcn_hidden,
)
gcn.fit(
X_train,
y_train,
coords=mesh.triangle_centroids_m,
radius=self.gcn_adjacency_radius_m,
epochs=self.epochs,
batch_size=max(2, min(8, len(X_train) // 5)),
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"GCN training failed: {exc}",
elapsed=time.time() - t0,
warnings=warnings,
)
try:
X_obs = _maxwell_tri_samples_observed_features(
comp_feats, mesh, station_x
)
# No coords=/adjacency= here: reuses the exact adjacency built
# from this same shared mesh during fit() (self._A_stored),
# rather than rebuilding one at predict()'s different default
# radius (5_000.0 m) than gcn_adjacency_radius_m used to train.
pred_log_rho = gcn.predict(X_obs[None, ...])[0, :, 0]
except Exception as exc:
return AgentResult.failed(
f"Triangular-mesh prediction failed: {exc}",
elapsed=time.time() - t0,
warnings=warnings,
)
mt2d_tri_recovery: dict[str, Any] | None = None
try:
mt2d_tri_recovery = _mt2d_tri_recovery_check(
gcn, dataset, mesh, station_x
)
except Exception as exc:
warnings.append(f"mt2d_tri recovery check failed: {exc}")
figures: dict[str, Any] = {}
fig_paths: dict[str, str] = {}
try:
import matplotlib.pyplot as plt
from ..api.mesh import draw_tri_mesh
from ..api.station import StationAxisStyle, StationMarkerStyle
fig, ax = plt.subplots(figsize=(10, 5))
fill, _edges = draw_tri_mesh(
ax, mesh, pred_log_rho, preset="review", cmap="jet_r"
)
station_z = (
np.interp(station_x, self.topo_x_m, self.topo_z_m)
if self.topo_x_m is not None
else np.zeros_like(station_x)
)
ax.scatter(
station_x,
station_z,
label="Stations",
**StationMarkerStyle().kwargs(),
)
y0, y1 = ax.get_ylim()
label_pad = 0.03 * abs(y1 - y0)
# Thin labels the same way StationAxisStyle does for every other
# station-axis figure: every one of the n markers stays visible,
# but only a subset gets a text label, so this scales from a
# ten-station line (all labelled) to a fifty-station one
# (crowding and title collisions otherwise) without a separate
# code path.
visible = StationAxisStyle().label_indices(
station_names, figwidth_in=fig.get_figwidth()
)
for i in visible:
ax.annotate(
station_names[i],
(station_x[i], station_z[i] - label_pad),
ha="center",
va="bottom",
fontsize=7,
rotation=90,
clip_on=False,
zorder=6,
)
ax.invert_yaxis()
# The triangular mesh's own topography-following top edge
# already marks the surface; a plain box-frame line there
# besides is redundant and, once rotated station labels are
# added above it, is what they visually collide with -- the
# same fix applied to pycsamt.topo.overlay.draw_topo_section
# for every flat-grid topography-draped section in the
# package. The y-axis is inverted here (depth increases
# downward), so "the surface side" is the *shallow* end of
# ylim, not simply ylim[1].
ax.spines["top"].set_visible(False)
if len(visible) > 0:
try:
fig.canvas.draw()
renderer = fig.canvas.get_renderer()
inv_transform = ax.transData.inverted()
shallow_y = min(
inv_transform.transform(
(t.get_window_extent(renderer=renderer).x0,
t.get_window_extent(renderer=renderer).y1)
)[1]
for t in ax.texts
)
cur_lo, cur_hi = ax.get_ylim()
shallow_bound = min(cur_lo, cur_hi)
if np.isfinite(shallow_y) and shallow_y < shallow_bound:
pad = 0.02 * abs(cur_hi - cur_lo)
new_shallow = shallow_y - pad
if cur_lo <= cur_hi:
ax.set_ylim(new_shallow, cur_hi)
else:
ax.set_ylim(cur_lo, new_shallow)
except Exception:
pass
if ((self.depth_max is not None and self.depth_max >= 10_000.0)
or np.nanmax(station_x) >= 10_000.0):
from matplotlib.ticker import FuncFormatter
km = FuncFormatter(lambda value, _pos: f"{value / 1000:g}")
ax.xaxis.set_major_formatter(km)
ax.yaxis.set_major_formatter(km)
ax.set_xlabel("Profile distance (km)")
ax.set_ylabel("Depth below elevation datum (km)")
else:
ax.set_xlabel("x (m)")
ax.set_ylabel("Depth (m)")
ax.legend(loc="lower right", frameon=True)
fig.colorbar(fill, ax=ax, label=r"$\log_{10}\rho$ (Ohm.m)")
# Rotated station labels sit outside the axes box (clip_on=False)
# above each marker, and PlotConfig's default bbox_inches="tight"
# crops away any empty margin reserved via subplots_adjust, so
# only a real points-based title pad (not axes-fraction margin)
# reliably keeps the two apart across both a ten- and a
# fifty-station line.
ax.set_title(
"2-D AI inversion (triangular mesh, GCN)",
fontsize=10,
fontweight="bold",
pad=90,
)
figures["inv2d_tri_section"] = fig
p = self._save_figure(
fig, output_dir, "inv2d_tri_section", warnings_list=warnings
)
if p:
fig_paths["inv2d_tri_section"] = p
except Exception as exc:
warnings.append(f"draw_tri_mesh: {exc}")
interp: str | None = None
if self.llm_available:
rho_mean = float(np.nanmean(10.0**pred_log_rho))
rho_std = float(np.nanstd(10.0**pred_log_rho))
prompt = (
f"2-D AI inversion (triangular mesh, GCN) summary:\n"
f" Profile: {n_sta} stations x {n_freqs} frequencies\n"
f" Mesh: {mesh.n_triangles} triangles\n"
f" Mean resistivity: {rho_mean:.0f} Ohm.m +/- {rho_std:.0f}\n"
f" Warnings: {warnings[:3] if warnings else 'none'}\n\n"
"Interpret the triangular-mesh resistivity section."
)
interp = self.query_llm(prompt, max_tokens=250)
elapsed = time.time() - t0
recovery_note = ""
if mt2d_tri_recovery is not None:
recovery_note = (
f" Held-out recovery RMSE={mt2d_tri_recovery['rmse']:.3f} "
f"(log10 Ohm.m, n={mt2d_tri_recovery['n_samples']})."
)
return AgentResult(
status="success",
summary=(
f"2-D AI inversion (GCN, physics=mt2d_tri): {n_sta} "
f"stations x {mesh.n_triangles} triangles. "
f"{len(figures)} figures.{recovery_note}"
),
data={
"pred_triangles": {
"mesh": mesh,
"log10_resistivity": pred_log_rho,
},
"frequency_grid_hz": freqs,
"station_names": station_names,
"inverter": gcn,
"figures": figures,
"figure_paths": fig_paths,
"physics": self.physics,
"mt2d_tri_recovery": mt2d_tri_recovery,
"training_history": dict(getattr(gcn, "_history", {})),
"epochs_completed": len(
getattr(gcn, "_history", {}).get("train_loss", [])
),
"best_validation_loss": float(
np.nanmin(getattr(gcn, "_history", {}).get("val_loss", [np.nan]))
),
},
warnings=warnings,
llm_interpretation=interp,
elapsed_seconds=elapsed,
cost_estimate_usd=self._last_cost,
)
def _generate_mt2d_training_data(
*,
n_sta: int,
freqs: np.ndarray,
n_depth: int,
depth_max_m: float | None,
station_spacing_m: float,
correlation_length_x_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,
verbose: bool | int | str = False,
):
"""Build a genuinely 2-D correlated training set and convert it
to :class:`~pycsamt.ai.inversion.inv2d.EMInverter2D`'s tensors.
Uses :func:`~pycsamt.ai.training.dataset2d.\
generate_2d_maxwell_dataset`
on a synthetic grid whose ``nx`` stations sit exactly
``station_spacing_m`` apart (the real survey's actual station
geometry is not read at this stage) and whose ``nz`` matches
``n_depth`` exactly, so no resize is needed to align the label
with :meth:`EMInverter2D.predict`'s output shape.
Returns
-------
dataset : Maxwell2DDataset
Full dataset (train/validation/test), kept for a later
known-truth recovery check.
X : ndarray (n_train, 2, n_freqs, n_stations)
y : ndarray (n_train, n_depth, n_stations)
"""
from ..ai.geology import GeologyGrid
from ..ai.training.dataset2d import (
Maxwell2DDatasetConfig,
generate_2d_maxwell_dataset,
)
depth_total_m = (
float(depth_max_m)
if depth_max_m is not None
else float(np.sum(_default_thicknesses(n_depth, freqs)))
)
grid = GeologyGrid.regular_2d(
nx=n_sta,
nz=n_depth,
dx_m=station_spacing_m,
dz_m=depth_total_m / n_depth,
)
config = Maxwell2DDatasetConfig(
dataset_id="inv2dagent-mt2d",
grid=grid,
correlation_length_x_m=correlation_length_x_m,
correlation_length_z_m=correlation_length_z_m,
frequencies_hz=freqs,
station_x_m=grid.x_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,
validation_fraction=0.1,
test_fraction=0.1,
verbose=verbose,
)
dataset = generate_2d_maxwell_dataset(config)
X, y = _maxwell_samples_to_unet_arrays(dataset.select("train"), freqs)
return dataset, X, y
def _maxwell_samples_to_unet_arrays(
samples,
freqs: np.ndarray,
) -> tuple[np.ndarray, np.ndarray]:
"""Convert ``Maxwell2DSample`` objects into ``EMInverter2D``'s
``(X, y)`` convention.
Channels 0/1 of ``X`` are log10(apparent resistivity)/phase
(degrees) for ``zxy``, matching :func:`_z_to_features`'s feature
layout so synthetic training data and the observed pseudosection
share one convention.
"""
if not samples:
raise ValueError("samples must contain at least one entry.")
n_freq = len(freqs)
n_depth, n_sta = samples[0].resistivity_ohm_m.shape
X = np.empty((len(samples), 2, n_freq, n_sta), dtype=np.float32)
y = np.empty((len(samples), n_depth, n_sta), dtype=np.float32)
for i, sample in enumerate(samples):
zxy = sample.survey.impedance[:, :, 0] # (n_sta, n_freq)
f = sample.survey.frequencies_hz[None, :]
mu0 = 4.0e-7 * np.pi
rho_a = np.abs(zxy) ** 2 / (2.0 * np.pi * f * mu0)
phase = np.degrees(np.angle(zxy))
X[i, 0] = np.log10(np.clip(rho_a, 1e-12, None)).T
X[i, 1] = phase.T
y[i] = np.log10(sample.resistivity_ohm_m)
return X, y
def _mt2d_recovery_check(inv2d, dataset) -> 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
freqs = held_out[0].survey.frequencies_hz
rmse_vals, mae_vals, r2_vals = [], [], []
for sample in held_out:
X_i, _ = _maxwell_samples_to_unet_arrays([sample], freqs)
pred_log_rho = inv2d.predict(X_i)[0] # (n_depth, n_sta)
true_log_rho = np.log10(sample.resistivity_ohm_m)
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 _generate_mt2d_tri_training_data(
*,
station_x_m: np.ndarray,
freqs: np.ndarray,
depth_max_m: float | None,
correlation_length_x_m: tuple[float, float],
correlation_length_z_m: tuple[float, float],
log_resistivity_mean: float,
log_resistivity_std: float,
mesh_target_cell_m: float,
field_grid_cell_m: float,
n_realizations: int,
adapter: Any | None,
topo_x_m: Any = None,
topo_z_m: Any = None,
verbose: bool | int | str = False,
):
"""Build a triangular-mesh training set and convert it to
:class:`~pycsamt.ai.inversion.inv3d.GCNInverter3D`'s ``(X, y)``
convention.
Returns
-------
dataset : MaxwellTri2DDataset
Full dataset (train/validation/test), kept for a later
known-truth recovery check.
mesh : TriMesh
Shared triangular geometry (same object dataset.mesh).
X : ndarray (n_train, n_triangles, 2 * n_freqs + N_POSITION_FEATURES)
y : ndarray (n_train, n_triangles, 1)
"""
from ..ai.training.dataset2d_tri import (
MaxwellTri2DDatasetConfig,
generate_2d_tri_maxwell_dataset,
)
n_sta = len(station_x_m)
depth_total_m = (
float(depth_max_m)
if depth_max_m is not None
else float(np.sum(_default_thicknesses(max(n_sta, 8), freqs)))
)
x_max = float(station_x_m[-1]) if n_sta > 1 else float(station_x_m[0]) + 1.0
config = MaxwellTri2DDatasetConfig(
dataset_id="inv2dagent-mt2dtri",
x_range_m=(0.0, x_max),
z_range_m=(0.0, depth_total_m),
correlation_length_x_m=correlation_length_x_m,
correlation_length_z_m=correlation_length_z_m,
frequencies_hz=freqs,
station_x_m=station_x_m,
n_realizations=n_realizations,
seed=0,
log_resistivity_mean=log_resistivity_mean,
log_resistivity_std=log_resistivity_std,
components=("zxy",),
mesh_target_cell_m=mesh_target_cell_m,
field_grid_cell_m=field_grid_cell_m,
topo_x_m=topo_x_m,
topo_z_m=topo_z_m,
validation_fraction=0.1,
test_fraction=0.1,
verbose=verbose,
)
dataset = generate_2d_tri_maxwell_dataset(config, adapter=adapter)
X, y = _maxwell_tri_samples_to_gcn_arrays(
dataset.select("train"), dataset.mesh, station_x_m
)
return dataset, dataset.mesh, X, y
def _nearest_station_features(
comp_feats: list[np.ndarray] | np.ndarray,
mesh: Any,
station_x_m: np.ndarray,
) -> np.ndarray:
"""Broadcast each triangle's nearest station's ``(2, n_freq)`` feature
block onto that triangle, flattened to ``(n_triangles, 2 * n_freq)``.
Nearest is by x-distance only, since receivers sit on the mesh's own
``z=0`` surface and every triangle's centroid has a well-defined x.
Every triangle assigned to the same nearest station receives an
*identical* feature block here, regardless of its own depth or exact
position -- this function alone gives a GCN nothing to key a
depth-varying prediction on. See :func:`_triangle_position_features`
for the per-triangle signal that fixes that.
"""
station_x = np.asarray(station_x_m, dtype=float)
centroid_x = mesh.triangle_centroids_m[:, 0]
nearest = np.argmin(
np.abs(centroid_x[:, None] - station_x[None, :]), axis=1
)
flat = np.stack(
[np.asarray(f, dtype=np.float32).reshape(-1) for f in comp_feats],
axis=0,
) # (n_sta, 2 * n_freq)
return flat[nearest]
def _triangle_position_features(mesh: Any) -> np.ndarray:
"""Return each triangle's own ``[x_norm, z_norm]`` position, both
normalized to the mesh's own bounding box (``[0, 1]``).
:func:`_nearest_station_features` broadcasts the *same* sounding
curve onto every triangle nearest a given station, so two triangles
at very different depths under the same station otherwise look
identical to the network. Appending each triangle's own normalized
position gives it something to actually vary a depth-dependent
prediction on, rather than relying solely on message-passing
between differently-stationed neighbours to break the symmetry.
"""
centroids = mesh.triangle_centroids_m
lo = centroids.min(axis=0)
span = np.maximum(centroids.max(axis=0) - lo, 1.0)
return ((centroids - lo) / span).astype(np.float32)
N_POSITION_FEATURES = 2
def _maxwell_tri_samples_observed_features(
comp_feats: list[np.ndarray],
mesh: Any,
station_x_m: np.ndarray,
) -> np.ndarray:
"""Return ``(n_triangles, 2 * n_freq + 2)`` observed features for
prediction: the nearest station's sounding curve plus the triangle's
own normalized ``[x, z]`` position.
"""
nearest_feats = _nearest_station_features(comp_feats, mesh, station_x_m)
return np.concatenate(
[nearest_feats, _triangle_position_features(mesh)], axis=1
)
def _maxwell_tri_samples_to_gcn_arrays(
samples,
mesh: Any,
station_x_m: np.ndarray,
) -> tuple[np.ndarray, np.ndarray]:
"""Convert ``MaxwellTri2DSample`` objects into
:class:`~pycsamt.ai.inversion.inv3d.GCNInverter3D`'s ``(X, y)``
convention: per-triangle nearest-station ``[log10(rho_a), phase]``
features for ``zxy`` concatenated with the triangle's own normalized
``[x, z]`` position, and per-triangle ``log10(resistivity)`` targets.
"""
if not samples:
raise ValueError("samples must contain at least one entry.")
n_tri = mesh.n_triangles
n_freq = len(samples[0].survey.frequencies_hz)
n_feat = 2 * n_freq + N_POSITION_FEATURES
pos_feats = _triangle_position_features(mesh)
X = np.empty((len(samples), n_tri, n_feat), dtype=np.float32)
y = np.empty((len(samples), n_tri, 1), dtype=np.float32)
for i, sample in enumerate(samples):
comp_idx = sample.survey.components.index("zxy")
zxy = sample.survey.impedance[:, :, comp_idx] # (n_sta, n_freq)
f = sample.survey.frequencies_hz[None, :]
mu0 = 4.0e-7 * np.pi
rho_a = np.abs(zxy) ** 2 / (2.0 * np.pi * f * mu0)
phase = np.degrees(np.angle(zxy))
log_rho_a = np.log10(np.clip(rho_a, 1e-12, None))
per_station = np.concatenate([log_rho_a, phase], axis=1) # (n_sta, 2*n_freq)
nearest_feats = _nearest_station_features(per_station, mesh, station_x_m)
X[i] = np.concatenate([nearest_feats, pos_feats], axis=1)
y[i, :, 0] = np.log10(sample.resistivity_ohm_m)
return X, y
def _mt2d_tri_recovery_check(gcn, dataset, mesh, station_x_m) -> 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.
"""
from ..ai.validation import recovery_report
held_out = dataset.select("test") or dataset.select("validation")
if not held_out:
return None
rmse_vals, mae_vals, r2_vals = [], [], []
for sample in held_out:
X_i, _ = _maxwell_tri_samples_to_gcn_arrays([sample], mesh, station_x_m)
pred = gcn.predict(X_i)[0, :, 0] # reuses self._A_stored, see above
true = np.log10(sample.resistivity_ohm_m)
# recovery_report requires a 2-D/3-D grid; a triangle mesh has no
# such shape, so reshape to a (n_triangles, 1) column -- degenerate
# but valid, and the per-cell metrics (rmse/mae/r2) are unaffected.
report = recovery_report(
pred.reshape(-1, 1), true.reshape(-1, 1), 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 _inv2d_thicknesses(
n_depth: int,
freqs: np.ndarray,
depth_max: float | None,
) -> np.ndarray:
"""Return an n-depth display grid with an optional explicit bottom."""
if depth_max is None:
return _default_thicknesses(n_depth, freqs)
n_intervals = max(int(n_depth) - 1, 1)
weights = np.geomspace(1.0, 3.0, n_intervals)
return (float(depth_max) * weights / weights.sum()).astype(float)
def _compute_rms_2d(
X_obs: np.ndarray,
pred_2d: np.ndarray,
thicknesses: np.ndarray,
freqs: np.ndarray,
) -> float:
r"""
Data-space RMS for the 2-D section.
Uses the Bostick approximation to convert the
predicted log10-resistivity section back to
apparent resistivity and phase, then compares to
the observed log10(rho_a) stored in X_obs.
Parameters
----------
X_obs : ndarray, shape (n_components, n_freqs, n_sta)
Observed features; component 0 assumed to be
log10(rho_a).
pred_2d : ndarray, shape (n_depth, n_sta)
Predicted log10(rho) section.
thicknesses : ndarray, shape (n_depth - 1,) or (n_depth,)
Layer thicknesses in metres.
freqs : ndarray, shape (n_freqs,)
Frequency array in Hz.
Returns
-------
float
Global normalised RMS in log-resistivity space.
"""
try:
n_comp, n_freqs, n_sta = X_obs.shape
n_depth = pred_2d.shape[0]
depths_m = np.concatenate([[0.0], np.cumsum(thicknesses[:n_depth])])
periods = 1.0 / np.maximum(freqs, 1e-9)
obs_log_rho = X_obs[0] # (n_freqs, n_sta)
# Bostick: rho_Bostick(T) ~ rho_a(T) * (phi/45 - 1)
# Here we simply read off the predicted profile at the
# Bostick depth d_B = 503 * sqrt(rho_a / f)
rms_vals: list[float] = []
for s in range(n_sta):
profile = pred_2d[:, s] # log10(rho), n_depth cells
pred_log_rho_a = np.zeros(n_freqs)
for fi, T in enumerate(periods):
rho_a_obs = 10.0 ** obs_log_rho[fi, s]
d_b = 503.0 * np.sqrt(max(rho_a_obs, 1.0) * T)
idx = int(np.searchsorted(depths_m, d_b))
idx = min(idx, n_depth - 1)
pred_log_rho_a[fi] = profile[idx]
diff = pred_log_rho_a - obs_log_rho[:, s]
finite = np.isfinite(diff)
if finite.any():
rms_vals.append(float(np.sqrt(np.mean(diff[finite] ** 2))))
return float(np.mean(rms_vals)) if rms_vals else np.nan
except Exception:
return np.nan
__all__ = ["Inv2DAgent"]