Source code for pycsamt.agents.sensitivity

"""
pycsamt.agents.sensitivity
============================

:class:`SensitivityAgent` — Compute vertical resolution and Bostick
sensitivity kernels for MT data.

Answers "which depth range does each datum actually constrain?" using the
Bostick penetration depth and the analytical vertical-resolution formula.
Produces a sensitivity pseudosection that makes the depth of investigation
(DOI) visually explicit — information that is absent from every standard
pseudosection plot.

Wraps
-----
* :func:`~pycsamt.emtools.csumt.vertical_resolution` — per-station ΔD table
* :func:`~pycsamt.emtools.advanced.plot_sensitivity_depth_section` — figure
"""

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 data resolution and depth-of-investigation analysis.
Given a sensitivity analysis result, write 4-5 sentences that:
1. Describe the maximum Bostick depth reached by the data at the lowest frequency.
2. Identify depth ranges with poor resolution (large ΔD) and their cause.
3. State which stations have the best and worst overall depth coverage.
4. Advise on whether the target depth is within the data's sensitivity window.
5. Recommend frequency additions or deletions to improve depth resolution.
Reply in plain scientific English.
"""


[docs] class SensitivityAgent(BaseAgent): """Bostick sensitivity kernels and vertical resolution analysis. Parameters ---------- api_key, model, llm_provider : str component : {'xy', 'yx'} Impedance component for ρa and Bostick depth (default ``'xy'``). rho_override : float or None Fixed background resistivity (Ω·m) for the analytical ΔD formula. ``None`` uses the measured ρa per frequency pair. depth_max : float or None Clip depth axis at this value in km (default: auto from data). Input keys ---------- ``sites`` / ``path`` : Sites or str ``component`` : str, optional ``rho_override`` : float, optional ``period_range`` : [T_min, T_max], optional ``depth_max`` : float, optional — km ``output_dir`` : str, optional Output data keys ---------------- ``resolution_table`` pandas.DataFrame — per-(station, freq-pair) ``doi_per_station`` dict {station: float} — max Bostick depth (m) ``mean_doi_km`` float ``figures`` dict ``figure_paths`` dict Examples -------- >>> agent = SensitivityAgent(component='xy') >>> r = agent.execute({"path": "/data/WILLY_EDIs"}) >>> print(r["mean_doi_km"], "km mean DOI") """ SYSTEM_PROMPT = _SYSTEM_PROMPT def __init__( self, *, api_key: str | None = None, model: str | None = None, llm_provider: str = "claude", component: str = "xy", rho_override: float | None = None, depth_max: float | None = None, ) -> None: super().__init__( "SensitivityAgent", api_key=api_key, model=model, llm_provider=llm_provider, section_preset="pseudosection", ) self.component = component.lower() self.rho_override = rho_override self.depth_max = depth_max
[docs] def execute(self, input_data: dict[str, Any]) -> AgentResult: self._last_cost = 0.0 t0 = time.time() warnings: list[str] = [] try: from ..emtools.advanced import ( plot_sensitivity_depth_section, ) from ..emtools.csumt import vertical_resolution except ImportError as exc: return AgentResult.failed( f"emtools.csumt / emtools.advanced not available: {exc}", 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) component = str(input_data.get("component", self.component)) rho_override = input_data.get("rho_override", self.rho_override) period_range = input_data.get("period_range") depth_max = input_data.get("depth_max", self.depth_max) output_dir = input_data.get("output_dir") # ── compute resolution table ────────────────────────────────────── try: res_table = vertical_resolution( sites, rho_override=rho_override, verbose=0, ) except Exception as exc: return AgentResult.failed( f"vertical_resolution failed: {exc}", elapsed=time.time() - t0, ) # ── DOI per station (max Bostick depth_lo) ──────────────────────── doi_per_station: dict[str, float] = {} try: if hasattr(res_table, "groupby"): doi_per_station = ( res_table.groupby("station")["depth_lo_m"].max().to_dict() ) except Exception: pass mean_doi_km = float("nan") if doi_per_station: mean_doi_km = ( float(np.mean(list(doi_per_station.values()))) / 1000.0 ) # ── figures ─────────────────────────────────────────────────────── figures: dict[str, Any] = {} fig_paths: dict[str, str] = {} try: d_max_kw: dict = {} if depth_max is not None: d_max_kw = {"depth_max": depth_max} p_range_kw: dict = {} if period_range is not None: p_range_kw = {"period_range": tuple(period_range)} fig_sens = plot_sensitivity_depth_section( sites, component=component, depth_unit="km", **d_max_kw, **p_range_kw, ) if fig_sens is not None: figures["sensitivity_section"] = fig_sens p = self._save_figure( fig_sens, output_dir, "sensitivity_depth_section", warnings_list=warnings, ) if p: fig_paths["sensitivity_section"] = p except Exception as exc: warnings.append(f"plot_sensitivity_depth_section: {exc}") # resolution per station bar chart try: fig_doi = _plot_doi_bar(doi_per_station) if fig_doi is not None: figures["doi_bar"] = fig_doi p = self._save_figure( fig_doi, output_dir, "doi_per_station", warnings_list=warnings, ) if p: fig_paths["doi_bar"] = p except Exception as exc: warnings.append(f"DOI bar figure: {exc}") # ── LLM interpretation ──────────────────────────────────────────── interp: str | None = None if self.api_key and doi_per_station: max_doi_sta = max(doi_per_station, key=doi_per_station.get) min_doi_sta = min(doi_per_station, key=doi_per_station.get) prompt = ( f"Sensitivity analysis summary:\n" f" Component: Z{component}\n" f" Stations analysed: {len(doi_per_station)}\n" f" Mean DOI: {mean_doi_km:.2f} km\n" f" Max DOI: {doi_per_station[max_doi_sta] / 1000:.2f} km " f"at station {max_doi_sta}\n" f" Min DOI: {doi_per_station[min_doi_sta] / 1000:.2f} km " f"at station {min_doi_sta}\n" f" ρ override: {rho_override or 'from data'}\n" f" Warnings: {warnings[:3] if warnings else 'none'}\n\n" "Interpret the depth of investigation and resolution quality." ) interp = self.query_llm(prompt, max_tokens=220) elapsed = time.time() - t0 doi_str = ( f"{mean_doi_km:.2f} km" if not np.isnan(mean_doi_km) else "N/A" ) return AgentResult( status="success", summary=( f"Sensitivity analysis: {len(doi_per_station)} stations. " f"Mean DOI: {doi_str}. {len(figures)} figures." ), data={ "resolution_table": res_table, "doi_per_station": doi_per_station, "mean_doi_km": mean_doi_km, "component": component, "figures": figures, "figure_paths": fig_paths, }, warnings=warnings, llm_interpretation=interp, elapsed_seconds=elapsed, cost_estimate_usd=self._last_cost, )
# ── private helpers ─────────────────────────────────────────────────────────── def _plot_doi_bar(doi_per_station: dict[str, float]) -> Any: """Horizontal bar chart of DOI (km) per station.""" if not doi_per_station: return None import matplotlib.pyplot as plt stations = list(doi_per_station.keys()) depths = [doi_per_station[s] / 1000.0 for s in stations] fig, ax = plt.subplots(figsize=(6, max(3, len(stations) * 0.35))) ax.barh( range(len(stations)), depths, color="#2980b9", alpha=0.8, edgecolor="white", linewidth=0.4, ) ax.set_yticks(range(len(stations))) ax.set_yticklabels(stations, fontsize=7) ax.set_xlabel("Depth of investigation (km)", fontsize=9) ax.set_title("Max Bostick DOI per station", fontsize=9, fontweight="bold") ax.tick_params(labelsize=8) ax.invert_yaxis() fig.tight_layout() return fig __all__ = ["SensitivityAgent"]