"""
pycsamt.agents.joint_agent
===========================
:class:`JointInversionAgent` — Multi-modal joint MT inversion via DRCNN.
Wraps :class:`~pycsamt.ai.inversion.joint.JointInverter`:
* Fuses a **primary** MT dataset with a **secondary** modality (TEM, CSAMT,
gravity proxy, or a second MT profile at a different frequency range).
* Both modalities are observed at the same stations. When no secondary
dataset is supplied the agent synthesises a complementary low-frequency
response from the same :class:`~pycsamt.forward.synthetic.LayeredModel`
to demonstrate the joint-inversion pipeline.
* Produces a **joint resistivity section** (station × depth) that benefits
from the complementary depth sensitivities of the two modalities.
Architecture
------------
The :class:`~pycsamt.ai.nets.drcnn.DRCNNNet` (Dense-Residual CNN) is used
as the shared feature extractor; each modality has its own encoding branch
before the fused prediction head.
Requires PyTorch **or** TensorFlow.
"""
from __future__ import annotations
import time
from typing import Any
import numpy as np
from ._base import AgentResult, BaseAgent
from .ai_inversion import _default_thicknesses, _z_to_features
_SYSTEM_PROMPT = """\
You are an expert in multi-modal geophysical joint inversion using deep learning.
Given a joint MT inversion result, write 4-5 sentences that:
1. Describe the two modalities fused and their complementary depth sensitivities.
2. Assess the joint prediction quality (RMS, depth range, station count).
3. Compare the joint result to a single-modality approach where possible.
4. Identify where the secondary modality most improved the primary inversion.
5. Recommend validation (borehole, gravity, seismic) and next modelling steps.
Reply in plain scientific English.
"""
_DEFAULT_FREQS_MT = np.logspace(
-4, 3, 40
) # primary: 1e-4 – 1e3 Hz (40 pts)
_DEFAULT_FREQS_SEC = np.logspace(
-4, 1, 20
) # secondary: 1e-4 – 10 Hz (20 pts)
_N_COMP_MT = 4 # Re/Im of Zxy + Zyx
_N_COMP_SEC = 2 # |Z| + phase for one component
[docs]
class JointInversionAgent(BaseAgent):
"""Multi-modal MT joint inversion using DRCNN.
Parameters
----------
api_key, model, llm_provider : str
modalities : list[str]
Names of the two modalities, e.g. ``["mt", "tem"]``.
The first entry is the primary modality (loaded from ``sites``/``path``);
the second is loaded from ``secondary_path`` or synthesised when absent.
n_layers : int
Number of depth layers in the output model (default 5).
n_freqs_primary : int
Frequencies for the primary MT response features (default 40).
n_freqs_secondary : int
Frequencies for the secondary modality features (default 20).
n_train_samples : int
Synthetic training samples shared across both modalities (default 2000).
epochs : int
Training epochs (default 30).
growth_rate : int
DRCNN dense-block growth rate (default 32).
Input keys
----------
``sites`` / ``path`` : Sites or str — primary MT dataset
``secondary_path`` : str, optional — secondary modality EDI/TEM path
``output_dir`` : str, optional
``period_range`` : [T_min, T_max], optional
Output data keys
----------------
``inverter`` JointInverter
``predictions`` dict {station: ndarray} — mean log₁₀ ρ per layer
``rms_per_station`` dict {station: float}
``rms_global`` float
``modalities`` list[str]
``figures`` dict
``figure_paths`` dict
Examples
--------
>>> agent = JointInversionAgent(modalities=["mt", "tem"], n_layers=5, epochs=20)
>>> result = agent.execute({"path": "/data/L22PLT"})
>>> result["rms_global"]
0.31
"""
SYSTEM_PROMPT = _SYSTEM_PROMPT
def __init__(
self,
*,
api_key: str | None = None,
model: str | None = None,
llm_provider: str = "claude",
modalities: list[str] | None = None,
n_layers: int = 5,
n_freqs_primary: int = 40,
n_freqs_secondary: int = 20,
n_train_samples: int = 2_000,
epochs: int = 30,
growth_rate: int = 32,
) -> None:
super().__init__(
"JointInversionAgent",
api_key=api_key,
model=model,
llm_provider=llm_provider,
section_preset="inversion",
)
self.modalities = modalities or ["mt", "tem"]
self.n_layers = n_layers
self.n_freqs_primary = n_freqs_primary
self.n_freqs_secondary = n_freqs_secondary
self.n_train_samples = n_train_samples
self.epochs = epochs
self.growth_rate = growth_rate
# ── 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.joint import JointInverter
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"JointInversionAgent requires PyTorch or TensorFlow: {exc}",
hint="pip install torch or pip install tensorflow",
elapsed=time.time() - t0,
)
from ..emtools._core import (
_get_z_block,
_iter_items,
_name,
ensure_sites,
)
# ── load primary MT sites ─────────────────────────────────────────────
sites_raw = input_data.get("sites") or input_data.get("path")
if sites_raw is None:
return AgentResult.failed(
"No 'sites' or 'path'.", elapsed=time.time() - t0
)
try:
sites = ensure_sites(sites_raw, verbose=0)
except Exception as exc:
return AgentResult.failed(str(exc), elapsed=time.time() - t0)
output_dir = input_data.get("output_dir")
import os
if output_dir:
os.makedirs(output_dir, exist_ok=True)
freqs_mt = _DEFAULT_FREQS_MT[: self.n_freqs_primary]
freqs_sec = _DEFAULT_FREQS_SEC[: self.n_freqs_secondary]
n_feat_mt = self.n_freqs_primary * _N_COMP_MT
n_feat_sec = self.n_freqs_secondary * _N_COMP_SEC
# ── load optional secondary sites ─────────────────────────────────────
sec_path = input_data.get("secondary_path")
sec_sites = None
if sec_path:
try:
sec_sites = ensure_sites(sec_path, verbose=0)
except Exception as exc:
warnings.append(
f"Could not load secondary sites: {exc}. "
"Synthesising secondary responses."
)
# ── collect observed primary features ─────────────────────────────────
station_names: list[str] = []
X_mt_obs: list[np.ndarray] = []
for i, ed in enumerate(_iter_items(sites)):
nm = _name(ed, i)
_, z, fr = _get_z_block(ed)
if z is None:
continue
feat = _z_to_features(ed, z, fr, freqs_mt)
if feat is None:
warnings.append(f"{nm}: skipped (bad MT data).")
continue
station_names.append(nm)
X_mt_obs.append(feat.reshape(-1)) # (n_feat_mt,)
if len(station_names) < 2:
return AgentResult.failed(
"Need ≥ 2 usable stations for joint inversion.",
elapsed=time.time() - t0,
)
len(station_names)
X_mt_obs_arr = np.stack(X_mt_obs, axis=0).astype(
np.float32
) # (n_sta, n_feat_mt)
# ── collect or synthesise secondary features ──────────────────────────
X_sec_obs_arr = _collect_secondary_features(
station_names,
sites,
sec_sites,
freqs_mt,
freqs_sec,
n_feat_sec,
warnings,
)
# ── generate synthetic training data ──────────────────────────────────
self._log.info(
"Generating %d synthetic samples for joint training…",
self.n_train_samples,
)
try:
ds_mt = generate_dataset(
solver="mt1d",
n_samples=self.n_train_samples,
freqs=freqs_mt,
n_layers=self.n_layers,
noise_level=0.03,
seed=42,
n_jobs=1,
verbose=False,
)
X_mt_train = ds_mt.X.reshape(len(ds_mt.X), -1).astype(np.float32)
y_train = ds_mt.y.astype(np.float32)
# secondary: same models, low-frequency range, 2 components
ds_sec = generate_dataset(
solver="mt1d",
n_samples=self.n_train_samples,
freqs=freqs_sec,
n_layers=self.n_layers,
noise_level=0.05,
seed=123,
n_jobs=1,
verbose=False,
)
# keep only first 2 components (log|Zxy|, phase Zxy)
X_sec_raw = ds_sec.X # (n_samp, n_freqs_sec, 4)
X_sec_train = (
X_sec_raw[:, :, :_N_COMP_SEC]
.reshape(len(X_sec_raw), -1)
.astype(np.float32)
)
# align feature sizes if mismatch
X_mt_train = _pad_or_trim(X_mt_train, n_feat_mt)
X_sec_train = _pad_or_trim(X_sec_train, n_feat_sec)
except Exception as exc:
return AgentResult.failed(
f"Synthetic dataset generation failed: {exc}",
elapsed=time.time() - t0,
)
# ── train JointInverter ───────────────────────────────────────────────
self._log.info(
"Training JointInverter (DRCNN) — modalities %s — %d epochs…",
self.modalities,
self.epochs,
)
try:
inverter = JointInverter(
n_features_list=(n_feat_mt, n_feat_sec),
n_layers=self.n_layers,
growth_rate=self.growth_rate,
)
inverter.fit(
[X_mt_train, X_sec_train],
y_train,
epochs=self.epochs,
batch_size=min(256, self.n_train_samples // 4),
patience=max(5, self.epochs // 5),
verbose=False,
)
except Exception as exc:
return AgentResult.failed(
f"JointInverter training failed: {exc}",
elapsed=time.time() - t0,
)
# ── predict on observed stations ──────────────────────────────────────
predictions: dict[str, np.ndarray] = {}
rms_per: dict[str, float] = {}
X_mt_obs_arr = _pad_or_trim(X_mt_obs_arr, n_feat_mt)
X_sec_obs_arr = _pad_or_trim(X_sec_obs_arr, n_feat_sec)
try:
y_pred_all = inverter.predict(
[X_mt_obs_arr, X_sec_obs_arr]
) # (n_sta, n_layers)
except Exception as exc:
return AgentResult.failed(
f"Joint prediction failed: {exc}",
elapsed=time.time() - t0,
)
for si, nm in enumerate(station_names):
log_rho = y_pred_all[si]
predictions[nm] = log_rho
rms = _forward_rms_joint(
sites, nm, si, log_rho, freqs_mt, self.n_layers
)
if rms is not None:
rms_per[nm] = rms
rms_global = (
float(np.nanmean(list(rms_per.values()))) if rms_per else np.nan
)
n_pred = len(predictions)
# ── figures ───────────────────────────────────────────────────────────
figures: dict[str, Any] = {}
fig_paths: dict[str, str] = {}
try:
fig_sec = _plot_joint_section(
predictions,
self.n_layers,
freqs_mt,
station_names,
self.modalities,
)
if fig_sec is not None:
figures["joint_section"] = fig_sec
p = self._save_figure(
fig_sec,
output_dir,
"joint_inversion_section",
warnings_list=warnings,
)
if p:
fig_paths["joint_section"] = p
except Exception as exc:
warnings.append(f"Joint section figure: {exc}")
# ── LLM interpretation ────────────────────────────────────────────────
interp: str | None = None
if self.api_key and n_pred:
rms_str = (
f"{rms_global:.3f}" if not np.isnan(rms_global) else "N/A"
)
prompt = (
f"Joint inversion summary:\n"
f" Modalities: {' + '.join(self.modalities)}\n"
f" Stations predicted: {n_pred}\n"
f" Layers: {self.n_layers}, max depth: "
f"{_default_thicknesses(self.n_layers, freqs_mt).sum() / 1000:.1f} km\n"
f" Global RMS: {rms_str} log₁₀(Ω·m)\n"
f" Secondary synthesised: {sec_sites is None}\n"
f" Warnings: {warnings[:3] if warnings else 'none'}\n\n"
"Interpret the joint inversion result and compare to single-modality approaches."
)
interp = self.query_llm(prompt, max_tokens=250)
elapsed = time.time() - t0
rms_disp = (
f"RMS {rms_global:.3f}" if not np.isnan(rms_global) else "RMS N/A"
)
return AgentResult(
status="success" if n_pred > 0 else "needs_review",
summary=(
f"Joint inversion ({' + '.join(self.modalities)}): "
f"{n_pred} stations, {self.n_layers} layers. "
f"{rms_disp}. {len(figures)} figure(s)."
),
data={
"inverter": inverter,
"predictions": predictions,
"rms_per_station": rms_per,
"rms_global": rms_global,
"modalities": self.modalities,
"freqs_mt": freqs_mt,
"freqs_secondary": freqs_sec,
"figures": figures,
"figure_paths": fig_paths,
},
warnings=warnings,
llm_interpretation=interp,
elapsed_seconds=elapsed,
cost_estimate_usd=self._last_cost,
)
# ── private helpers ───────────────────────────────────────────────────────────
def _collect_secondary_features(
station_names: list[str],
primary_sites: Any,
secondary_sites: Any | None,
freqs_mt: np.ndarray,
freqs_sec: np.ndarray,
n_feat_sec: int,
warnings: list[str],
) -> np.ndarray:
"""Build secondary feature matrix (n_sta, n_feat_sec).
When *secondary_sites* are available they are matched by station index.
Otherwise a low-frequency sub-set of the primary impedance is used to
simulate a complementary modality (TEM-like amplitude + phase).
"""
from ..emtools._core import (
_get_z_block,
_iter_items,
)
n_sta = len(station_names)
X_sec = np.zeros((n_sta, n_feat_sec), dtype=np.float32)
if secondary_sites is not None:
sec_iter = list(_iter_items(secondary_sites))
for si in range(min(n_sta, len(sec_iter))):
ed = sec_iter[si]
_, z, fr = _get_z_block(ed)
if z is None:
continue
# log|Zxy| + phase Zxy at secondary frequencies
feat = _extract_sec_features(z, fr, freqs_sec)
if feat is not None:
X_sec[si, : len(feat)] = feat[:n_feat_sec]
return X_sec
# fallback: derive from primary sites at low-frequency subset
primary_iter = list(_iter_items(primary_sites))
for si, ed in enumerate(primary_iter):
if si >= n_sta:
break
_, z, fr = _get_z_block(ed)
if z is None:
continue
feat = _extract_sec_features(z, fr, freqs_sec)
if feat is not None:
X_sec[si, : len(feat)] = feat[:n_feat_sec]
warnings.append(
"No secondary dataset provided — low-frequency MT sub-band used as "
"proxy for secondary modality."
)
return X_sec
def _extract_sec_features(
z: np.ndarray,
fr: np.ndarray,
freqs_target: np.ndarray,
) -> np.ndarray | None:
"""Return (n_freqs_target * 2,) — log|Zxy| and phase at target freqs."""
try:
per = 1.0 / np.where(fr == 0, np.nan, fr)
per_t = 1.0 / np.where(freqs_target == 0, np.nan, freqs_target)
n_t = len(freqs_target)
zxy = np.abs(z[:, 0, 1])
pha = np.degrees(np.angle(z[:, 0, 1]))
mask = np.isfinite(per) & np.isfinite(zxy) & (zxy > 0)
if mask.sum() < 2:
return None
log_z = np.log10(np.clip(zxy[mask], 1e-30, None))
log_z_interp = np.interp(
np.log10(per_t[np.isfinite(per_t)]),
np.log10(per[mask]),
log_z,
)
pha_interp = np.interp(
np.log10(per_t[np.isfinite(per_t)]),
np.log10(per[mask]),
pha[mask],
)
arr = np.concatenate([log_z_interp[:n_t], pha_interp[:n_t]])
return arr.astype(np.float32)
except Exception:
return None
def _pad_or_trim(X: np.ndarray, target_cols: int) -> np.ndarray:
"""Ensure X has exactly *target_cols* columns."""
n, c = X.shape
if c == target_cols:
return X
if c > target_cols:
return X[:, :target_cols]
# pad with zeros
pad = np.zeros((n, target_cols - c), dtype=X.dtype)
return np.concatenate([X, pad], axis=1)
def _forward_rms_joint(
sites: Any,
station_name: str,
station_idx: int,
log_rho: np.ndarray,
freqs: np.ndarray,
n_layers: int,
) -> float | None:
"""Compute forward-response RMS for one predicted station."""
try:
from ..emtools._core import (
_get_z_block,
_iter_items,
_name,
)
from ..forward import LayeredModel, MT1DForward
for i, ed in enumerate(_iter_items(sites)):
if _name(ed, i) != station_name and i != station_idx:
continue
_, z, fr = _get_z_block(ed)
if z is None:
return None
rhos = 10**log_rho
ths = _default_thicknesses(n_layers, freqs)
lm = LayeredModel(resistivity=rhos, thickness=ths[: n_layers - 1])
fwd = MT1DForward(freqs=freqs)
resp = fwd.run(lm)
rho_fwd = np.asarray(resp.rho_a)
rho_xy = rho_fwd[:, 0, 1] if rho_fwd.ndim == 3 else rho_fwd
rho_raw = getattr(ed, "rho", None)
rho_obs = (
rho_raw[:, 0, 1]
if rho_raw is not None
else (0.2 / np.where(fr == 0, np.nan, fr))
* np.abs(z[:, 0, 1]) ** 2
)
per = 1.0 / np.where(fr == 0, np.nan, fr)
per_fwd = 1.0 / np.where(freqs == 0, np.nan, freqs)
mask = np.isfinite(per) & (rho_obs > 0)
if mask.sum() < 2:
return None
interp = np.interp(
np.log10(per[mask]),
np.log10(per_fwd[np.isfinite(per_fwd)]),
np.log10(np.clip(rho_xy[np.isfinite(per_fwd)], 1e-6, None)),
)
obs_log = np.log10(np.clip(rho_obs[mask], 1e-6, None))
return float(np.sqrt(np.mean((obs_log - interp) ** 2)))
except Exception:
return None
def _plot_joint_section(
predictions: dict[str, np.ndarray],
n_layers: int,
freqs: np.ndarray,
station_names: list[str],
modalities: list[str],
) -> Any:
"""Plot the joint-inversion predicted log₁₀ρ section."""
import matplotlib.pyplot as plt
from ..api.section import PYCSAMT_SECTION
from ..api.station import PYCSAMT_STATION_RENDERING
n_st = len(station_names)
if n_st == 0:
return None
mat = np.full((n_layers, n_st), np.nan)
for si, nm in enumerate(station_names):
v = predictions.get(nm)
if v is None:
continue
n = min(len(v), n_layers)
mat[:n, si] = v[:n]
ths = _default_thicknesses(n_layers, freqs)
depths = np.concatenate([[0], np.cumsum(ths)]) / 1000.0 # km
section = PYCSAMT_SECTION.style_for("inversion")
fig_w, fig_h = section.figsize_for(n_stations=n_st, n_y=n_layers)
fig, ax = plt.subplots(figsize=(fig_w, fig_h))
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=(-0.5, n_st - 0.5, depths[-1], depths[0]),
cmap="jet_r",
vmin=vmin,
vmax=vmax,
interpolation="bilinear",
)
PYCSAMT_STATION_RENDERING.apply(
ax,
np.arange(n_st, dtype=float),
station_names,
preset="inversion",
xlim=(-0.5, n_st - 0.5),
)
ax.set_ylabel("Depth (km)", fontsize=9)
ax.tick_params(axis="y", labelsize=8)
section.add_colorbar(im, ax, label="$\\log_{10}\\rho$ (Ω·m)")
title = f"Joint inversion ({' + '.join(modalities).upper()}) — predicted section"
ax.set_title(title, fontsize=10, fontweight="bold")
fig.tight_layout()
return fig
__all__ = ["JointInversionAgent"]