Source code for pycsamt.agents.freq_decimation

"""
pycsamt.agents.freq_decimation
================================

:class:`FrequencyDecimationAgent` — Intelligent period selection for inversion.

Selects an optimal subset of periods from the observed data by:

1. Applying the SNR/QC flags from :class:`~pycsamt.agents.DataQCAgent` to
   mask dead-band and low-quality frequencies.
2. Choosing log-uniformly spaced survivors to give even depth coverage.
3. Enforcing user-defined period bounds and a minimum SNR threshold.

The output is a dictionary of selected periods per station, ready to be
consumed by :class:`~pycsamt.agents.InversionPrepAgent`,
:class:`~pycsamt.agents.Occam2DAgent`, or
:class:`~pycsamt.agents.ModEmAgent`.
"""

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 selection and frequency decimation for inversion.
Given a period decimation result, write 3-4 sentences that:
1. State how many periods were selected versus available, and the selection ratio.
2. Identify which frequency bands were excluded and the likely reason (dead band, low SNR).
3. Confirm whether the selected periods cover the target depth range adequately.
4. Recommend any additional frequencies that should be included or excluded.
Reply in plain English.
"""


[docs] class FrequencyDecimationAgent(BaseAgent): """Select optimal periods from MT data for inversion. Parameters ---------- api_key, model, llm_provider : str n_per_decade : int Number of periods to keep per decade of period range (default 6). snr_threshold : float Minimum SNR value to retain a frequency (default 3.0). Frequencies below this are excluded as dead-band. period_range : [T_min, T_max] or None Hard period bounds in seconds. ``None`` uses the full data range. component : {'xy', 'yx'} Component used for SNR proxy (default ``'xy'``). Input keys ---------- ``sites`` / ``path`` : Sites or str ``qc_result`` : AgentResult or dict, optional — output from DataQCAgent (provides per-frequency SNR scores; if absent a proxy is computed) ``n_per_decade`` : int, optional ``snr_threshold`` : float, optional ``period_range`` : [T_min, T_max], optional ``output_dir`` : str, optional Output data keys ---------------- ``selected_periods`` dict {station: ndarray} — selected periods (s) ``n_original`` int — total available (station, freq) cells ``n_selected`` int — retained cells ``selection_ratio`` float ``dead_band_mask`` dict {station: ndarray bool} ``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", n_per_decade: int = 6, snr_threshold: float = 3.0, period_range: list[float] | None = None, component: str = "xy", ) -> None: super().__init__( "FrequencyDecimationAgent", api_key=api_key, model=model, llm_provider=llm_provider, section_preset="pseudosection", ) self.n_per_decade = n_per_decade self.snr_threshold = snr_threshold self.period_range = period_range self.component = component.lower()
[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, ) 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) n_per_decade = int(input_data.get("n_per_decade", self.n_per_decade)) snr_threshold = float( input_data.get("snr_threshold", self.snr_threshold) ) period_range = input_data.get("period_range") or self.period_range component = str(input_data.get("component", self.component)).lower() output_dir = input_data.get("output_dir") ri, ci = (0, 1) if component == "xy" else (1, 0) # extract QC SNR flags from a prior DataQCAgent result (optional) qc_result = input_data.get("qc_result") if qc_result is not None: try: snr_section = ( qc_result.get("snr_section") if hasattr(qc_result, "get") else qc_result.get("data", {}).get("snr_section") ) if snr_section is not None and isinstance(snr_section, dict): pass except Exception: pass selected_periods: dict[str, np.ndarray] = {} dead_band_mask: dict[str, np.ndarray] = {} n_original = 0 n_selected = 0 for i, ed in enumerate(_iter_items(sites)): nm = _name(ed, i) _, z, fr = _get_z_block(ed) if z is None or fr is None or fr.size == 0: continue per = 1.0 / np.where(fr == 0, np.nan, fr) valid = np.isfinite(per) # ── period range filter ─────────────────────────────────────── if period_range is not None: t_min, t_max = float(period_range[0]), float(period_range[1]) valid &= (per >= t_min) & (per <= t_max) # ── SNR proxy: |Zxy| / std(|Zxy|) over station set ─────────── snr_proxy = np.ones(len(fr)) try: zcomp = np.abs(z[:, ri, ci]) np.nanmean(zcomp[valid]) if valid.any() else 1.0 sd = np.nanstd(zcomp[valid]) + 1e-30 snr_proxy = zcomp / sd except Exception: pass # ── SNR threshold ───────────────────────────────────────────── good = valid & (snr_proxy >= snr_threshold) dead = valid & ~good dead_band_mask[nm] = dead n_original += int(valid.sum()) if not good.any(): warnings.append( f"{nm}: no frequencies pass SNR threshold — skipped." ) selected_periods[nm] = np.array([]) continue good_periods = per[good] log_p = np.log10(np.clip(good_periods, 1e-12, None)) # ── log-spaced decimation ───────────────────────────────────── p_min, p_max = float(log_p.min()), float(log_p.max()) n_decades = max(1.0, p_max - p_min) n_target = max(1, int(np.round(n_decades * n_per_decade))) if n_target >= len(good_periods): chosen = good_periods else: targets = np.linspace(p_min, p_max, n_target) chosen_idx = set() for t in targets: idx = int(np.argmin(np.abs(log_p - t))) chosen_idx.add(idx) chosen = np.sort(good_periods[sorted(chosen_idx)]) selected_periods[nm] = chosen n_selected += len(chosen) # ── summary ─────────────────────────────────────────────────────── n_sta_sel = sum(1 for p in selected_periods.values() if p.size > 0) selection_ratio = n_selected / max(n_original, 1) # ── figures ─────────────────────────────────────────────────────── figures: dict[str, Any] = {} fig_paths: dict[str, str] = {} try: fig = _plot_selection_summary( selected_periods, dead_band_mask, sites, n_per_decade, snr_threshold, ) if fig is not None: figures["selection_summary"] = fig p = self._save_figure( fig, output_dir, "freq_decimation_summary", warnings_list=warnings, ) if p: fig_paths["selection_summary"] = p except Exception as exc: warnings.append(f"Selection summary figure: {exc}") # ── LLM interpretation ──────────────────────────────────────────── interp: str | None = None if self.api_key and n_sta_sel: prompt = ( f"Frequency decimation summary:\n" f" Stations: {n_sta_sel} with data\n" f" Original frequencies: {n_original}\n" f" Selected frequencies: {n_selected}\n" f" Selection ratio: {selection_ratio:.1%}\n" f" n_per_decade={n_per_decade}, SNR threshold={snr_threshold}\n" f" Period range: {period_range or 'full'}\n" f" Warnings: {warnings[:3] if warnings else 'none'}\n\n" "Assess whether the selected periods cover the target depth and advise." ) interp = self.query_llm(prompt, max_tokens=180) elapsed = time.time() - t0 return AgentResult( status="success" if n_sta_sel > 0 else "needs_review", summary=( f"Frequency decimation: {n_selected}/{n_original} periods retained " f"({selection_ratio:.0%}) across {n_sta_sel} stations." ), data={ "selected_periods": selected_periods, "n_original": n_original, "n_selected": n_selected, "selection_ratio": selection_ratio, "dead_band_mask": dead_band_mask, "figures": figures, "figure_paths": fig_paths, }, warnings=warnings, llm_interpretation=interp, elapsed_seconds=elapsed, cost_estimate_usd=self._last_cost, )
# ── private helpers ─────────────────────────────────────────────────────────── def _plot_selection_summary( selected_periods: dict[str, np.ndarray], dead_band_mask: dict[str, np.ndarray], sites: Any, n_per_decade: int, snr_threshold: float, ) -> Any: """Scatter plot: selected periods (green) and dead-band (red) per station.""" import matplotlib.pyplot as plt from ..emtools._core import ( _get_z_block, _iter_items, _name, ) station_names = list(selected_periods.keys()) if not station_names: return None fig, ax = plt.subplots(figsize=(max(8, len(station_names) * 0.5), 5)) for xi, nm in enumerate(station_names): sel = selected_periods.get(nm, np.array([])) dead_mask = dead_band_mask.get(nm) # all available periods for this station for i, ed in enumerate(_iter_items(sites)): if _name(ed, i) != nm: continue _, z, fr = _get_z_block(ed) if fr is None: break per_all = 1.0 / np.where(fr == 0, np.nan, fr) np.isfinite(per_all) if dead_mask is not None and dead_mask.any(): ax.scatter( [xi] * int(dead_mask.sum()), per_all[dead_mask], marker="x", s=18, color="#e74c3c", alpha=0.6, zorder=2, ) if sel.size > 0: ax.scatter( [xi] * len(sel), sel, marker="o", s=20, color="#27ae60", alpha=0.85, zorder=3, ) break ax.set_yscale("log") ax.set_xticks(range(len(station_names))) ax.set_xticklabels(station_names, rotation=90, fontsize=7) ax.set_ylabel("Period (s)", fontsize=9) ax.set_title( f"Frequency decimation " f"(n/decade={n_per_decade}, SNR≥{snr_threshold})\n" "Green = selected · Red × = dead-band / low SNR", fontsize=9, fontweight="bold", ) ax.tick_params(labelsize=8) fig.tight_layout() return fig __all__ = ["FrequencyDecimationAgent"]