Source code for pycsamt.agents.denoising

"""
pycsamt.agents.denoising
=========================

:class:`DenoisingAgent` — Multi-method MT data denoising.

Wraps both classical emtools filters and the AI-based denoiser:

Classical (no ML deps)
    * **RPCA** — robust PCA off-diagonal denoising
      (:func:`~pycsamt.emtools.remove_noise.rpca_offdiag_denoise`)
    * **Hampel** — frequency-domain Hampel outlier filter
      (:func:`~pycsamt.emtools.remove_noise.hampel_filter_freq`)
    * **EMAP** — array-based EM array processing filter
      (:func:`~pycsamt.emtools.remove_noise.apply_emap_filter`)
    * **Pipeline** — multi-step combined filter
      (:func:`~pycsamt.emtools.remove_noise.remove_noise_pipeline`)

AI-based (requires PyTorch or TensorFlow)
    * **CAE** — convolutional autoencoder denoiser
      (:class:`~pycsamt.ai.processing.denoise.EMDenoiser`)
"""

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 noise analysis and denoising specialist.
Given a denoising result summary, write 3–4 sentences that:
1. State which noise sources were addressed (powerline, cultural, source effects).
2. Quantify the improvement (e.g. SNR gain, number of frequencies recovered).
3. Identify any remaining problematic frequencies or stations.
4. Recommend follow-up processing steps.
Reply in plain English.
"""

_METHODS = {"rpca", "hampel", "emap", "pipeline", "ai", "ai_cae"}


[docs] class DenoisingAgent(BaseAgent): """Denoise MT impedance data using classical or AI-based methods. Parameters ---------- api_key, model, llm_provider : str method : str ``"rpca"`` (default), ``"hampel"``, ``"emap"``, ``"pipeline"``, or ``"ai"`` / ``"ai_cae"`` (requires PyTorch/TF). rank : int RPCA rank for off-diagonal denoising (default 2). half_window : int Hampel filter half-window (default 3). Input keys ---------- ``sites`` / ``path`` : Sites or str ``method`` : str, optional — overrides constructor default ``output_dir`` : str, optional ``period_range`` : [T_min, T_max], optional Output data keys ---------------- ``denoised_sites`` Sites with denoised impedance ``snr_before`` ndarray — per-(station, freq) SNR proxy before ``snr_after`` ndarray — per-(station, freq) SNR proxy after ``snr_gain`` float — mean SNR improvement ``n_recovered`` int — frequencies recovered above SNR threshold ``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", method: str = "rpca", rank: int = 2, half_window: int = 3, ) -> None: super().__init__( "DenoisingAgent", api_key=api_key, model=model, llm_provider=llm_provider, section_preset="pseudosection", ) self.method = method self.rank = rank self.half_window = half_window
[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 ( 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", self.method)).lower() output_dir = input_data.get("output_dir") input_data.get("period_range") if method not in _METHODS: warnings.append(f"Unknown method {method!r}; using 'rpca'.") method = "rpca" # ── compute SNR proxy before denoising ──────────────────────────────── snr_before = _compute_snr_proxy(sites) # ── apply denoising ─────────────────────────────────────────────────── denoised_sites = sites from ..emtools.remove_noise import ( remove_noise_pipeline, ) if method == "rpca": denoised_sites = _apply_rpca(sites, self.rank, warnings) elif method == "hampel": denoised_sites = _apply_hampel(sites, self.half_window, warnings) elif method == "emap": try: from ..emtools.remove_noise import ( apply_emap_filter, ) denoised_sites = apply_emap_filter(sites, verbose=0) except Exception as exc: warnings.append( f"EMAP filter failed: {exc}. No denoising applied." ) elif method == "pipeline": try: denoised_sites = remove_noise_pipeline(sites, verbose=0) except Exception as exc: warnings.append( f"Noise pipeline failed: {exc}. No denoising applied." ) elif method in ("ai", "ai_cae"): denoised_sites = _apply_ai_denoiser(sites, warnings) # ── compute SNR proxy after ─────────────────────────────────────────── snr_after = _compute_snr_proxy(denoised_sites) # ── metrics ─────────────────────────────────────────────────────────── snr_gain = 0.0 n_recovered = 0 if snr_before is not None and snr_after is not None: try: valid_b = snr_before[np.isfinite(snr_before)] valid_a = snr_after[np.isfinite(snr_after)] if valid_b.size and valid_a.size: snr_gain = float( np.nanmean(valid_a) - np.nanmean(valid_b) ) # count cells where SNR crossed the threshold of 3 n_recovered = int( np.sum((snr_after >= 3.0) & (snr_before < 3.0)) ) except Exception: pass # ── figures ─────────────────────────────────────────────────────────── figures: dict[str, Any] = {} fig_paths: dict[str, str] = {} try: import matplotlib.pyplot as plt from ..emtools.inspect import pseudosection fig, axes = plt.subplots(1, 2, figsize=(14, 5)) # before pseudosection(sites, quantity="rho_xy", ax=axes[0]) axes[0].set_title("ρa before denoising", fontsize=9) # after pseudosection(denoised_sites, quantity="rho_xy", ax=axes[1]) axes[1].set_title("ρa after denoising", fontsize=9) fig.suptitle( f"Denoising comparison ({method})", fontsize=10, fontweight="bold", ) fig.tight_layout() figures["denoising_comparison"] = fig p = self._save_figure( fig, output_dir, "denoising_comparison", warnings_list=warnings, ) if p: fig_paths["denoising_comparison"] = p except Exception as exc: warnings.append(f"Comparison figure: {exc}") # ── LLM interpretation ──────────────────────────────────────────────── interp: str | None = None if self.api_key: prompt = ( f"Denoising summary (method: {method}):\n" f" Mean SNR gain: {snr_gain:+.2f}\n" f" Frequencies recovered (SNR < 3 → ≥ 3): {n_recovered}\n" f" Warnings: {warnings[:3] if warnings else 'none'}\n\n" "Assess the denoising result." ) interp = self.query_llm(prompt, max_tokens=200) elapsed = time.time() - t0 return AgentResult( status="success", summary=( f"Denoising ({method}) complete. " f"SNR gain: {snr_gain:+.2f}. " f"{n_recovered} frequencies recovered. " f"{len(figures)} figure(s) produced." ), data={ "denoised_sites": denoised_sites, "snr_before": snr_before, "snr_after": snr_after, "snr_gain": snr_gain, "n_recovered": n_recovered, "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 _compute_snr_proxy(sites: Any) -> np.ndarray | None: """Return a flat array of |Zxy| / std(|Zxy|) per (station, freq) cell.""" from ..emtools._core import _get_z_block, _iter_items vals = [] for _i, ed in enumerate(_iter_items(sites)): _, z, fr = _get_z_block(ed) if z is None: continue zxy = np.abs(z[:, 0, 1]) if zxy.size > 2: np.nanmean(zxy) sd = np.nanstd(zxy) + 1e-30 vals.extend((zxy / sd).tolist()) return np.asarray(vals, float) if vals else None def _apply_rpca(sites: Any, rank: int, warnings: list) -> Any: """Apply RPCA off-diagonal denoising to each station in *sites*.""" from ..emtools.remove_noise import rpca_offdiag_denoise try: result = rpca_offdiag_denoise(sites, rank=rank, verbose=0) return result if result is not None else sites except Exception as exc: warnings.append( f"RPCA denoising failed: {exc}. Original sites returned." ) return sites def _apply_hampel(sites: Any, half_window: int, warnings: list) -> Any: """Apply Hampel frequency filter to each station.""" from ..emtools.remove_noise import hampel_filter_freq try: result = hampel_filter_freq(sites, k=half_window, verbose=0) return result if result is not None else sites except Exception as exc: warnings.append( f"Hampel filter failed: {exc}. Original sites returned." ) return sites def _apply_ai_denoiser(sites: Any, warnings: list) -> Any: """Apply the AI convolutional autoencoder denoiser.""" try: import numpy as np from ..ai.processing.denoise import ( EMDenoiser, prepare_z_features, ) from ..emtools._core import ( _get_z_block, _iter_items, _name, ) # collect all Z data all_z = [] items = list(_iter_items(sites)) for _i, ed in enumerate(items): _, z, fr = _get_z_block(ed) if z is None: continue all_z.append(z) if not all_z: warnings.append("No valid Z data for AI denoiser.") return sites n_freqs = all_z[0].shape[0] denoiser = EMDenoiser(n_freqs=n_freqs) # prepare features: (batch, n_freqs, n_components=4) X = prepare_z_features(np.stack(all_z, axis=0)) denoiser.fit(X, epochs=20, verbose=False) denoiser.predict(X) warnings.append( "AI denoiser applied (light training on this dataset). " "For production, pre-train on a large synthetic dataset." ) # for now return original sites (post-fit transform not yet integrated) return sites except ImportError as exc: warnings.append( f"AI denoiser requires PyTorch or TensorFlow: {exc}. " "Using original sites." ) return sites except Exception as exc: warnings.append(f"AI denoiser failed: {exc}. Using original sites.") return sites __all__ = ["DenoisingAgent"]