Source code for pycsamt.agents.static_shift

"""
pycsamt.agents.static_shift
============================

:class:`StaticShiftAgent` — Detect and correct galvanic static shift.

Wraps :mod:`pycsamt.emtools.ss`:

* :func:`~pycsamt.emtools.ss.correct_ss_ama`             — AMA correction (default)
* :func:`~pycsamt.emtools.ss.correct_ss_loess`           — LOESS smooth correction
* :func:`~pycsamt.emtools.ss.estimate_ss_refmedian`      — reference-median estimate
* :func:`~pycsamt.emtools.ss.ss_comparison_psection`     — before/after section
* :func:`~pycsamt.emtools.ss.plot_ss_summary`            — summary dashboard
* :func:`~pycsamt.emtools.ss.plot_ss_1d_curves`          — per-station 1-D curves

Supported methods: ``"ama"`` (default), ``"loess"``, ``"refmedian"``,
``"bilateral"``.
"""

from __future__ import annotations

import logging
import time
from typing import Any

import numpy as np

logger = logging.getLogger(__name__)

from ._base import AgentResult, BaseAgent

_SYSTEM_PROMPT = """\
You are an expert in galvanic distortion and static-shift correction for \
magnetotelluric data.
Given a static-shift correction summary, write 3–4 sentences that:
1. State whether significant static shift was detected.
2. Identify stations with the largest corrections and their magnitude.
3. Assess whether the correction method was appropriate for this dataset.
4. Recommend any follow-up action (e.g., additional spatial filtering).
Reply in plain English. No bullet points or markdown.
"""


[docs] class StaticShiftAgent(BaseAgent): """Detect and correct galvanic static shift in MT/AMT data. Parameters ---------- api_key, model, llm_provider : str method : {"ama", "loess", "refmedian", "bilateral"} Correction algorithm. Default ``"ama"`` (adaptive moving average). half_window : int Spatial half-window for AMA / LOESS smoothing. pband : (T_min, T_max) or None Period band used to estimate shift factors. inplace : bool Modify the input Sites in-place. Default ``False`` (returns a copy). Input keys ---------- ``sites`` / ``path`` : Sites or str ``method`` : str, optional — overrides constructor default ``output_dir`` : str, optional Output data keys ---------------- ``corrected_sites`` Sites with static shift removed ``shift_factors`` dict {station: factor} ``rho_before`` ndarray (n_freq × n_sta) — log₁₀ ρa before ``rho_after`` ndarray — log₁₀ ρa after ``delta_stats`` dict — min/max/mean shift magnitude ``figures`` dict — matplotlib Figure objects ``figure_paths`` dict — saved file paths Examples -------- >>> agent = StaticShiftAgent(method="ama") >>> result = agent.execute({"path": "/data/L22PLT", ... "output_dir": "/out/ss"}) >>> result["delta_stats"] {'mean': 0.18, 'max': 0.42, 'n_shifted': 7} """ SYSTEM_PROMPT = _SYSTEM_PROMPT def __init__( self, *, api_key: str | None = None, model: str | None = None, llm_provider: str = "claude", method: str = "ama", half_window: int = 3, pband: tuple[float, float] | None = None, inplace: bool = False, ) -> None: super().__init__( "StaticShiftAgent", api_key=api_key, model=model, llm_provider=llm_provider, section_preset="pseudosection", ) self.method = method self.half_window = half_window self.pband = pband self.inplace = inplace
[docs] def execute(self, input_data: dict[str, Any]) -> AgentResult: self._last_cost = 0.0 t0 = time.time() warnings: list[str] = [] # ── resolve sites ───────────────────────────────────────────────────── 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) method = str( input_data.get("method") or input_data.get("ss_method") or self.method ).lower() output_dir = input_data.get("output_dir") # ── import ss functions ─────────────────────────────────────────────── from ..emtools.ss import ( apply_ss_factors, correct_ss_ama, estimate_ss_loess, estimate_ss_refmedian, plot_ss_1d_curves, plot_ss_summary, ss_comparison_psection, ) # ── capture log₁₀ ρa before correction ─────────────────────────────── rho_before, freqs_all, sta_labels = _collect_rho(sites) # ── apply correction ────────────────────────────────────────────────── corrected_sites = None shift_factors: dict[str, float] = {} try: if method in ("none", "skip"): # User explicitly chose no correction. corrected_sites = sites warnings.append( "Static-shift correction skipped (method='none')." ) elif method == "ama": corrected_sites = correct_ss_ama( sites, half_window=self.half_window, pband=self.pband, inplace=self.inplace, verbose=0, ) elif method == "loess": result_loess = estimate_ss_loess( sites, half_window=self.half_window, pband=self.pband, verbose=0, ) corrected_sites = apply_ss_factors( sites, result_loess, inplace=self.inplace, verbose=0, ) elif method == "refmedian": result_ss = estimate_ss_refmedian( sites, pband=self.pband, verbose=0, ) if hasattr(result_ss, "iterrows"): for _, row in result_ss.iterrows(): st = str(row.get("station", "")) fac = float(row.get("fac_z", row.get("factor", 1.0))) shift_factors[st] = fac corrected_sites = apply_ss_factors( sites, result_ss, inplace=self.inplace, verbose=0, ) else: warnings.append( "estimate_ss_refmedian returned" " unexpected type; using AMA." ) corrected_sites = correct_ss_ama( sites, half_window=self.half_window, inplace=self.inplace, verbose=0, ) else: warnings.append( f"Unknown method {method!r}; falling back to AMA." ) corrected_sites = correct_ss_ama( sites, half_window=self.half_window, inplace=self.inplace, verbose=0, ) except Exception as exc: logger.warning( f"Static-shift correction ({method}) failed: {exc}", exc_info=True, ) warnings.append( f"Static-shift correction" f" ({method}) failed: {exc}." " Raw (uncorrected) data used." ) corrected_sites = sites # ── capture log₁₀ ρa after correction ──────────────────────────────── rho_after = ( _collect_rho(corrected_sites)[0] if corrected_sites is not None else rho_before ) # ── delta statistics ────────────────────────────────────────────────── delta_stats: dict[str, float] = {} if rho_before is not None and rho_after is not None: try: delta = rho_after - rho_before col_delta = np.nanmedian(np.abs(delta), axis=0) delta_stats = { "mean": float(np.nanmean(col_delta)), "max": float(np.nanmax(col_delta)), "min": float(np.nanmin(col_delta)), "n_shifted": int(np.sum(col_delta > 0.05)), } except Exception: pass # ── figures ─────────────────────────────────────────────────────────── figures: dict[str, Any] = {} fig_paths: dict[str, str] = {} if ( rho_before is not None and rho_after is not None and freqs_all is not None ): # summary dashboard: before / after / delta try: # _collect_rho is (n_freq, n_sta); plotters want (n_st, n_f) fig_sum = plot_ss_summary( rho_before.T, rho_after.T, freqs=freqs_all, station_labels=sta_labels, ) figures["ss_summary"] = fig_sum p = self._save_figure( fig_sum, output_dir, "ss_summary", warnings_list=warnings ) if p: fig_paths["ss_summary"] = p except Exception as exc: warnings.append(f"plot_ss_summary: {exc}") # per-station 1-D ρa curves try: fig_1d = plot_ss_1d_curves( rho_before.T, rho_after.T, freqs=freqs_all, station_labels=sta_labels, ) figures["ss_curves"] = fig_1d p = self._save_figure( fig_1d, output_dir, "ss_1d_curves", warnings_list=warnings ) if p: fig_paths["ss_curves"] = p except Exception as exc: warnings.append(f"plot_ss_1d_curves: {exc}") # comparison pseudosection (before / after side-by-side) try: fig_cmp = ss_comparison_psection( sites, method=method, verbose=0, ) if fig_cmp is not None: fig_c = ( fig_cmp if hasattr(fig_cmp, "savefig") else ( fig_cmp.get_figure() if hasattr(fig_cmp, "get_figure") else None ) ) if fig_c is not None: figures["ss_comparison"] = fig_c p = self._save_figure( fig_c, output_dir, "ss_comparison", warnings_list=warnings, ) if p: fig_paths["ss_comparison"] = p except Exception as exc: warnings.append(f"ss_comparison_psection: {exc}") # ── LLM interpretation ──────────────────────────────────────────────── interp: str | None = None if self.api_key and delta_stats: prompt = ( f"Static-shift correction summary ({method}):\n" f" Mean correction magnitude: {delta_stats.get('mean', '?'):.3f} " f" log₁₀(Ω·m)\n" f" Max correction: {delta_stats.get('max', '?'):.3f}\n" f" Stations significantly shifted (>0.05): " f"{delta_stats.get('n_shifted', '?')}\n" f" Warnings: {warnings[:3] if warnings else 'none'}\n\n" "Assess the static-shift correction result." ) interp = self.query_llm(prompt, max_tokens=200) elapsed = time.time() - t0 n_sig = delta_stats.get("n_shifted", 0) # When nothing was corrected, say so explicitly and explain why — # otherwise the identical before/after curves and empty delta # look like a bug rather than the expected "no shift detected". if n_sig == 0 and method not in ("none", "skip"): note = ( "No significant static shift was detected, so the data is" " unchanged and the before/after curves are identical. If" " you expected corrections, the dataset may be strongly" " 3-D (high skew), too sparse in the chosen period band," " or have too few stations for spatial comparison." ) warnings.append(note) summary = ( f"Static-shift ({method}): no significant shift detected" f" — data unchanged. {len(figures)} figure(s) produced." ) else: summary = ( f"Static-shift correction ({method}) applied. " f"{n_sig} station(s) shifted >0.05 log₁₀. " f"{len(figures)} figure(s) produced." ) return AgentResult( status="success", summary=summary, data={ "corrected_sites": corrected_sites, "shift_factors": shift_factors, "rho_before": rho_before, "rho_after": rho_after, "delta_stats": delta_stats, "figures": figures, "figure_paths": fig_paths, }, warnings=warnings, llm_interpretation=interp, elapsed_seconds=elapsed, cost_estimate_usd=self._last_cost, )
# ── helpers ─────────────────────────────────────────────────────────────────── def _collect_rho( sites: Any, ) -> tuple[np.ndarray | None, np.ndarray | None, list[str]]: """Collect log₁₀ ρa_xy into a 2-D array (n_freq × n_sta). Returns (rho_mat, freqs, station_labels). Any of these may be None if data are unavailable. """ from ..emtools._core import ( _get_z_block, _iter_items, _name, ) cols: list[np.ndarray] = [] freqs_ref: np.ndarray | None = None labels: list[str] = [] for i, ed in enumerate(_iter_items(sites)): nm = _name(ed, i) Z_obj, z, fr = _get_z_block(ed) if z is None or fr is None: continue # Always compute from Z — ed.rho is a # cached attribute that is stale after # impedance-tensor correction modifies Z. rho_xy = (0.2 / np.where(fr == 0, np.nan, fr)) * np.abs( z[:, 0, 1] ) ** 2 log_rho = np.log10(np.clip(rho_xy, 1e-6, None)) if freqs_ref is None: freqs_ref = fr cols.append(log_rho) labels.append(nm) else: # interpolate onto common freq grid if len(fr) == len(freqs_ref): cols.append(log_rho) labels.append(nm) else: # skip stations with different freq counts for now cols.append( log_rho[: len(freqs_ref)] if len(log_rho) >= len(freqs_ref) else np.full(len(freqs_ref), np.nan) ) labels.append(nm) if not cols or freqs_ref is None: return None, None, [] n_freq = len(freqs_ref) mat = np.full((n_freq, len(cols)), np.nan) for j, col in enumerate(cols): n = min(len(col), n_freq) mat[:n, j] = col[:n] return mat, freqs_ref, labels __all__ = ["StaticShiftAgent"]