"""
pycsamt.agents.inversion_backend
==================================
:class:`InversionBackendAgent` — Drive the ``pycsamt.inversion`` physics-based
inversion backends from within the agent system.
Supports all backends registered in :mod:`pycsamt.inversion`:
* **builtin** — pure-Python FD MT/TDEM inversion (no extra dependencies)
* **simpeg** — SimPEG MT/3D natural-source inversion (optional)
* **pygimli** — pyGIMLi 1-D MT/TDEM inversion (optional)
* **occam2d** — Occam2D data-file runner (requires Occam binary)
* **modem** — ModEM3D data-file runner (requires ModEM binary)
The agent wraps :func:`~pycsamt.inversion.run_inversion` and
:class:`~pycsamt.inversion.InversionConfig`, converts the raw
:class:`~pycsamt.inversion.InversionResult` into a standard
:class:`~pycsamt.agents._base.AgentResult`, and plots the final resistivity
section using the PYCSAMT API.
"""
from __future__ import annotations
import time
from typing import Any
import numpy as np
from ._base import AgentResult, BaseAgent
_SYSTEM_PROMPT = """\
You are an expert in MT inversion and subsurface resistivity modelling.
Given an inversion result, write 4-5 sentences that:
1. State the backend used, dimensionality, and convergence (RMS, n_iter).
2. Describe the recovered resistivity model (range, dominant structures).
3. Assess the fit quality and whether the RMS target was reached.
4. Identify stations or depth ranges with elevated misfit.
5. Recommend regularisation adjustments or mesh refinements for the next run.
Reply in plain scientific English.
"""
[docs]
class InversionBackendAgent(BaseAgent):
"""Drive pycsamt.inversion physics-based backends.
Parameters
----------
api_key, model, llm_provider : str
backend : str
Inversion backend: ``'builtin'`` (default), ``'simpeg'``,
``'pygimli'``, ``'occam2d'``, ``'modem'``.
dimension : str
``'1d'`` (default), ``'2d'``, or ``'3d'``.
method : str
``'mt'`` (default), ``'amt'``, ``'csamt'``, or ``'tdem'``.
n_layers : int
Number of depth layers for 1-D inversion (default 5).
max_iter : int
Maximum inversion iterations (default 80).
regularization : str
``'smooth'`` (default), ``'damped'``, or ``'blocky'``.
error_floor : float
Relative data error floor (default 0.05 = 5 %).
Input keys
----------
``sites`` / ``path`` : Sites or str
``backend``, ``dimension``, ``method`` : str, optional overrides
``n_layers``, ``max_iter`` : int, optional overrides
``regularization``, ``error_floor`` : optional overrides
``backend_options`` : dict, optional — forwarded to InversionConfig
``output_dir`` : str, optional
Output data keys
----------------
``inversion_result`` InversionResult
``rms`` float
``n_iter`` int
``log_rho_section`` ndarray (n_layers, n_stations)
``station_names`` list[str] or None
``backend`` str
``dimension`` str
``figures`` dict
``figure_paths`` dict
"""
SYSTEM_PROMPT = _SYSTEM_PROMPT
def __init__(
self,
*,
api_key: str | None = None,
model: str | None = None,
llm_provider: str = "claude",
backend: str = "builtin",
dimension: str = "1d",
method: str = "mt",
n_layers: int = 5,
max_iter: int = 80,
regularization: str = "smooth",
error_floor: float = 0.05,
) -> None:
super().__init__(
"InversionBackendAgent",
api_key=api_key,
model=model,
llm_provider=llm_provider,
section_preset="inversion",
)
self.backend = backend
self.dimension = dimension
self.method = method
self.n_layers = n_layers
self.max_iter = max_iter
self.regularization = regularization
self.error_floor = error_floor
[docs]
def execute(self, input_data: dict[str, Any]) -> AgentResult:
self._last_cost = 0.0
t0 = time.time()
warnings: list[str] = []
try:
from ..inversion import (
InversionConfig,
available_backends,
run_inversion,
)
except ImportError as exc:
return AgentResult.failed(
f"pycsamt.inversion not available: {exc}",
hint="Ensure pycsamt.inversion is installed.",
elapsed=time.time() - t0,
)
from ..emtools._core import 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
)
try:
sites = ensure_sites(sites_raw, verbose=0)
except Exception as exc:
return AgentResult.failed(str(exc), elapsed=time.time() - t0)
backend = str(input_data.get("backend", self.backend))
dimension = str(input_data.get("dimension", self.dimension))
method = str(input_data.get("method", self.method))
n_layers = int(input_data.get("n_layers", self.n_layers))
max_iter = int(input_data.get("max_iter", self.max_iter))
regularization = str(
input_data.get("regularization", self.regularization)
)
error_floor = float(input_data.get("error_floor", self.error_floor))
backend_options = dict(input_data.get("backend_options", {}))
output_dir = input_data.get("output_dir", "pycsamt_inversion_output")
# ── check backend availability ────────────────────────────────────
# available_backends() returns the registered NAMES only; a name
# may still be unusable when its optional dependency is missing,
# so probe the lazy import before committing to it.
avail = available_backends()
if backend not in avail:
warnings.append(
f"Backend {backend!r} not in registry. "
f"Available: {list(avail)}. Falling back to 'builtin'."
)
backend = "builtin"
if backend != "builtin":
try:
from ..inversion.backends import get_backend
get_backend(backend)
except Exception as exc:
warnings.append(
f"Backend {backend!r} is registered but unavailable "
f"({exc}). Falling back to 'builtin'."
)
backend = "builtin"
# ── build InversionConfig ─────────────────────────────────────────
try:
cfg = InversionConfig(
method=method,
dimension=dimension,
backend=backend,
data=sites,
workdir=str(output_dir),
output_dir=str(output_dir),
n_layers=n_layers,
error_floor=error_floor,
regularization=regularization,
max_iter=max_iter,
backend_options=backend_options,
)
except Exception as exc:
return AgentResult.failed(
f"InversionConfig construction failed: {exc}",
elapsed=time.time() - t0,
)
# ── run inversion ─────────────────────────────────────────────────
self._log.info(
"Running inversion: backend=%s, dim=%s, method=%s, n_layers=%d",
backend,
dimension,
method,
n_layers,
)
try:
inv_result = run_inversion(cfg)
except Exception as exc:
return AgentResult.failed(
f"Inversion failed: {exc}",
hint=f"Check backend availability: available_backends() = {avail}",
elapsed=time.time() - t0,
)
# ── extract results ───────────────────────────────────────────────
rms = float(getattr(inv_result, "rms", float("nan")))
n_iter = int(getattr(inv_result, "n_iter", 0))
# log_rho_section: (n_layers, n_stations)
log_rho_section = None
station_names = None
try:
model = getattr(inv_result, "model", None)
if model is not None:
sec = (
model.get("log_rho_section")
if hasattr(model, "get")
else None
)
if sec is not None:
log_rho_section = np.asarray(sec, dtype=float)
if log_rho_section.ndim == 1:
log_rho_section = log_rho_section[:, np.newaxis]
except Exception as exc:
warnings.append(f"Could not extract log_rho_section: {exc}")
try:
station_names = list(
getattr(inv_result, "station_names", None) or []
)
except Exception:
pass
# ── figures ───────────────────────────────────────────────────────
figures: dict[str, Any] = {}
fig_paths: dict[str, str] = {}
if log_rho_section is not None:
try:
fig = _plot_inversion_section(
log_rho_section,
station_names,
rms,
backend,
dimension,
)
if fig is not None:
figures["inversion_section"] = fig
p = self._save_figure(
fig,
str(output_dir),
"inversion_backend_section",
warnings_list=warnings,
)
if p:
fig_paths["inversion_section"] = p
except Exception as exc:
warnings.append(f"Inversion section figure: {exc}")
# convergence curve
try:
history = getattr(inv_result, "history", None)
if history is not None:
from ..ai.plot.convergence import (
plot_convergence,
)
fig_cv = plot_convergence(history)
if fig_cv is not None:
f = (
fig_cv
if hasattr(fig_cv, "savefig")
else (
fig_cv.get_figure()
if hasattr(fig_cv, "get_figure")
else None
)
)
if f is not None:
figures["convergence"] = f
p = self._save_figure(
f,
str(output_dir),
"inversion_convergence",
warnings_list=warnings,
)
if p:
fig_paths["convergence"] = p
except Exception as exc:
warnings.append(f"Convergence figure: {exc}")
# ── LLM interpretation ────────────────────────────────────────────
interp: str | None = None
if self.api_key:
rms_str = f"{rms:.4f}" if not np.isnan(rms) else "N/A"
n_sta = (
log_rho_section.shape[1]
if log_rho_section is not None
else "?"
)
prompt = (
f"Inversion result:\n"
f" Backend: {backend} | Dimension: {dimension} | Method: {method}\n"
f" Stations: {n_sta} | Layers: {n_layers}\n"
f" RMS: {rms_str} | Iterations: {n_iter}\n"
f" Regularization: {regularization} | Error floor: {error_floor:.0%}\n"
f" Warnings: {warnings[:3] if warnings else 'none'}\n\n"
"Evaluate the inversion convergence and interpret the result."
)
interp = self.query_llm(prompt, max_tokens=230)
elapsed = time.time() - t0
rms_str = f"{rms:.4f}" if not np.isnan(rms) else "N/A"
return AgentResult(
status="success" if not np.isnan(rms) else "needs_review",
summary=(
f"Inversion ({backend}, {dimension}, {method}): "
f"RMS={rms_str}, {n_iter} iterations. "
f"{len(figures)} figures."
),
data={
"inversion_result": inv_result,
"rms": rms,
"n_iter": n_iter,
"log_rho_section": log_rho_section,
"station_names": station_names,
"backend": backend,
"dimension": dimension,
"method": method,
"figures": figures,
"figure_paths": fig_paths,
},
warnings=warnings,
llm_interpretation=interp,
elapsed_seconds=elapsed,
cost_estimate_usd=self._last_cost,
)
# ── private helpers ───────────────────────────────────────────────────────────
def _plot_inversion_section(
log_rho: np.ndarray,
station_names: list[str] | None,
rms: float,
backend: str,
dimension: str,
) -> Any:
"""Plot recovered log₁₀ρ section using PYCSAMT_SECTION API."""
import matplotlib.pyplot as plt
from ..api.section import PYCSAMT_SECTION
from ..api.station import PYCSAMT_STATION_RENDERING
n_layers, n_sta = log_rho.shape
if n_sta == 0:
return None
names = station_names or [f"S{i + 1}" for i in range(n_sta)]
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))
vv = log_rho[np.isfinite(log_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
im = ax.imshow(
log_rho,
aspect="auto",
origin="upper",
extent=(-0.5, n_sta - 0.5, n_layers, 0),
cmap="jet_r",
vmin=vmin,
vmax=vmax,
interpolation="bilinear",
)
PYCSAMT_STATION_RENDERING.apply(
ax,
np.arange(n_sta, dtype=float),
names,
preset="inversion",
xlim=(-0.5, n_sta - 0.5),
)
ax.set_ylabel("Layer index", fontsize=9)
ax.tick_params(labelsize=8)
section.add_colorbar(im, ax, label="$\\log_{10}\\rho$ (Ω·m)")
rms_str = f"RMS = {rms:.4f}" if not np.isnan(rms) else ""
ax.set_title(
f"Inversion ({backend}, {dimension}) {rms_str}",
fontsize=10,
fontweight="bold",
)
fig.tight_layout()
return fig
__all__ = ["InversionBackendAgent"]