"""
pycsamt.agents.inversion_eval
==============================
:class:`InversionEvaluationAgent` — Evaluate inversion result quality.
Computes:
* Per-station RMS misfit between observed and model-predicted responses.
* Residual phase tensor section (data PT minus model PT).
* Misfit pseudosection (normalised ρa residuals).
Uses :func:`~pycsamt.emtools.inspect.plot_station_response` for per-station
response overlay and the phase tensor pipeline for residual PT.
"""
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 MT inversion quality assessor.
Given a misfit summary, write 3–4 sentences that:
1. State whether the inversion converged acceptably (RMS 0.8–1.5 = good).
2. Identify stations or period bands with elevated misfit.
3. Diagnose likely causes (3-D effects, noise, model inadequacy).
4. Recommend whether to re-run with adjusted regularisation.
Reply in plain English.
"""
[docs]
class InversionEvaluationAgent(BaseAgent):
"""Evaluate inversion quality: RMS, residual PT, misfit section.
Input keys
----------
``sites_obs`` / ``path_obs`` : Sites or str — observed data
``sites_mod`` / ``path_mod`` : Sites or str — model-predicted responses
``output_dir`` : str, optional
``component`` : str — default ``"xy"``
Output data keys
----------------
``rms_per_station`` dict {station: rms}
``rms_global`` float
``residual_pt_table`` pandas DataFrame
``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",
) -> None:
super().__init__(
"InversionEvaluationAgent",
api_key=api_key,
model=model,
llm_provider=llm_provider,
section_preset="inversion",
)
[docs]
def execute(self, input_data: dict[str, Any]) -> AgentResult:
self._last_cost = 0.0
t0 = time.time()
warnings: list[str] = []
from ..emtools._core import (
_get_z_block,
_iter_items,
_name,
ensure_sites,
)
from ..emtools.tensor import build_phase_tensor_table
obs_raw = input_data.get("sites_obs") or input_data.get("path_obs")
mod_raw = input_data.get("sites_mod") or input_data.get("path_mod")
component = str(input_data.get("component", "xy")).lower()
output_dir = input_data.get("output_dir")
ri, ci = (0, 1) if component == "xy" else (1, 0)
if obs_raw is None:
return AgentResult.failed(
"No 'sites_obs' or 'path_obs' provided.",
elapsed=time.time() - t0,
)
try:
sites_obs = ensure_sites(obs_raw, verbose=0)
except Exception as exc:
return AgentResult.failed(str(exc), elapsed=time.time() - t0)
if mod_raw is None:
warnings.append(
"No model response provided; RMS cannot be computed."
)
sites_mod = None
else:
try:
sites_mod = ensure_sites(mod_raw, verbose=0)
except Exception as exc:
warnings.append(f"Could not load model response: {exc}")
sites_mod = None
# ── per-station RMS ───────────────────────────────────────────────────
rms_per: dict[str, float] = {}
if sites_mod is not None:
obs_dict = {
_name(ed, i): ed
for i, ed in enumerate(_iter_items(sites_obs))
}
for i, ed_m in enumerate(_iter_items(sites_mod)):
nm = _name(ed_m, i)
ed_o = obs_dict.get(nm)
if ed_o is None:
continue
try:
_, z_o, fr_o = _get_z_block(ed_o)
_, z_m, fr_m = _get_z_block(ed_m)
if z_o is None or z_m is None:
continue
rho_o = (
0.2 / np.where(fr_o == 0, np.nan, fr_o)
) * np.abs(z_o[:, ri, ci]) ** 2
rho_m = (
0.2 / np.where(fr_m == 0, np.nan, fr_m)
) * np.abs(z_m[:, ri, ci]) ** 2
n = min(len(rho_o), len(rho_m))
mask = (
np.isfinite(rho_o[:n])
& (rho_o[:n] > 0)
& np.isfinite(rho_m[:n])
& (rho_m[:n] > 0)
)
if mask.sum() < 2:
continue
res = np.log10(rho_o[:n][mask]) - np.log10(
rho_m[:n][mask]
)
rms_per[nm] = float(np.sqrt(np.mean(res**2)))
except Exception as exc:
warnings.append(f"RMS for {nm}: {exc}")
rms_global = (
float(np.mean(list(rms_per.values()))) if rms_per else np.nan
)
# ── residual PT ───────────────────────────────────────────────────────
residual_pt = None
if sites_mod is not None:
try:
pt_obs = build_phase_tensor_table(sites_obs, verbose=0)
pt_mod = build_phase_tensor_table(sites_mod, verbose=0)
if not pt_obs.empty and not pt_mod.empty:
merged = pt_obs.merge(
pt_mod,
on=["station", "period"],
suffixes=("_obs", "_mod"),
)
for col in ("skew", "ellipt", "theta"):
if (
f"{col}_obs" in merged.columns
and f"{col}_mod" in merged.columns
):
merged[f"d_{col}"] = (
merged[f"{col}_obs"] - merged[f"{col}_mod"]
)
residual_pt = merged
except Exception as exc:
warnings.append(f"Residual PT computation: {exc}")
# ── figures ───────────────────────────────────────────────────────────
figures: dict[str, Any] = {}
fig_paths: dict[str, str] = {}
if sites_mod is not None:
try:
from ..emtools.inspect import (
plot_station_response,
)
fig_resp = plot_station_response(
sites_obs,
sites_model=sites_mod,
)
if fig_resp is not None:
f = (
fig_resp
if hasattr(fig_resp, "savefig")
else (
fig_resp.get_figure()
if hasattr(fig_resp, "get_figure")
else None
)
)
if f is not None:
figures["station_response"] = f
p = self._save_figure(
f,
output_dir,
"inversion_response",
warnings_list=warnings,
)
if p:
fig_paths["station_response"] = p
except Exception as exc:
warnings.append(f"plot_station_response: {exc}")
# ── LLM interpretation ────────────────────────────────────────────────
interp: str | None = None
if self.api_key and not np.isnan(rms_global):
bad = [f"{st}={v:.2f}" for st, v in rms_per.items() if v > 1.5]
prompt = (
f"Inversion evaluation:\n"
f" Global RMS: {rms_global:.3f}\n"
f" Stations with RMS > 1.5: {bad or 'none'}\n"
f" Warnings: {warnings[:3] if warnings else 'none'}\n\n"
"Assess inversion quality and recommend next steps."
)
interp = self.query_llm(prompt, max_tokens=200)
elapsed = time.time() - t0
return AgentResult(
status="success" if not np.isnan(rms_global) else "needs_review",
summary=(
f"Inversion evaluation: global RMS = {rms_global:.3f}"
if not np.isnan(rms_global)
else "Inversion evaluation: no model response provided."
),
data={
"rms_per_station": rms_per,
"rms_global": rms_global,
"residual_pt_table": residual_pt,
"figures": figures,
"figure_paths": fig_paths,
},
warnings=warnings,
llm_interpretation=interp,
elapsed_seconds=elapsed,
cost_estimate_usd=self._last_cost,
)
__all__ = ["InversionEvaluationAgent"]