Source code for pycsamt.agents.tensor_rotation

"""
pycsamt.agents.tensor_rotation
================================

:class:`TensorRotationAgent` — Apply a strike rotation to impedance tensors
and write corrected EDI files.

Closes the loop between :class:`~pycsamt.agents.PhaseAnalysisAgent` (which
identifies the optimal strike angle) and the inversion-preparation step:
rotated EDIs are written to disk and are ready to be consumed by Occam2D,
ModEM, or any external inversion code.

Rotation convention
-------------------
The standard two-sided rotation ``Z' = R(θ) Z R(θ)ᵀ`` is applied per
frequency using :func:`~pycsamt.seg.ops.rotate_impedance`.  Tipper vectors
are rotated with :func:`~pycsamt.seg.ops.rotate_tipper`.  A positive angle
θ rotates the measurement frame counter-clockwise (geological azimuth
convention: N → E positive).
"""

from __future__ import annotations

import os
import time
from typing import Any

import numpy as np

from ._base import AgentResult, BaseAgent

_SYSTEM_PROMPT = """\
You are an expert in MT tensor rotation and coordinate-frame correction.
Given a rotation result, write 3-4 sentences that:
1. State the rotation angle applied and the original coordinate frame.
2. Describe how the off-diagonal impedances (Zxy, Zyx) changed after rotation.
3. Assess whether the rotation removed apparent 2-D coupling from the diagonal terms.
4. Recommend whether further refinement (per-frequency rotation, decomposition) is needed.
Reply in plain English.
"""


[docs] class TensorRotationAgent(BaseAgent): """Rotate impedance tensors and tipper vectors by a fixed strike angle. Parameters ---------- api_key, model, llm_provider : str strike_deg : float Default rotation angle (degrees). Overridden by ``input_data["strike_deg"]``. Input keys ---------- ``sites`` / ``path`` : Sites or str ``strike_deg`` : float — counter-clockwise rotation angle (degrees) ``output_dir`` : str — directory for rotated EDI files ``overwrite`` : bool — allow overwriting existing files (default False) ``file_suffix`` : str — appended to station name, e.g. ``"_rot"`` Output data keys ---------------- ``strike_deg`` float — angle applied ``written_paths`` list[str] — successfully written EDI paths ``failed_stations`` list[str] ``n_written`` int ``z_diag_reduction`` float — mean |Zxx/Zxy| before − after (proxy for rotation quality) ``figures`` dict ``figure_paths`` dict Examples -------- >>> agent = TensorRotationAgent(strike_deg=42.0) >>> r = agent.execute({ ... "path": "/data/WILLY_EDIs", ... "output_dir": "/data/WILLY_rotated", ... }) >>> print(r["n_written"], "EDIs written") """ SYSTEM_PROMPT = _SYSTEM_PROMPT def __init__( self, *, api_key: str | None = None, model: str | None = None, llm_provider: str = "claude", strike_deg: float = 0.0, ) -> None: super().__init__( "TensorRotationAgent", api_key=api_key, model=model, llm_provider=llm_provider, section_preset="pseudosection", ) self.strike_deg = float(strike_deg)
[docs] def execute(self, input_data: dict[str, Any]) -> AgentResult: self._last_cost = 0.0 t0 = time.time() warnings: list[str] = [] try: from ..seg.ops import ( rotate_impedance, rotate_tipper, ) except ImportError as exc: return AgentResult.failed( f"pycsamt.seg.ops not available: {exc}", elapsed=time.time() - t0, ) 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) theta = float(input_data.get("strike_deg", self.strike_deg)) output_dir = input_data.get("output_dir") overwrite = bool(input_data.get("overwrite", False)) suffix = str(input_data.get("file_suffix", "_rot")) if output_dir is None: return AgentResult.failed( "No 'output_dir' specified.", hint="Pass output_dir='/path/to/rotated_edis'.", elapsed=time.time() - t0, ) os.makedirs(output_dir, exist_ok=True) written_paths: list[str] = [] failed_stations: list[str] = [] diag_ratios_before: list[float] = [] diag_ratios_after: list[float] = [] for i, ed in enumerate(_iter_items(sites)): nm = _name(ed, i) _, z, fr = _get_z_block(ed) if z is None: warnings.append(f"{nm}: no Z data — skipped.") failed_stations.append(nm) continue # ── diagonal-suppression metric before rotation ───────────────── mask = np.isfinite(z[:, 0, 0]) & (np.abs(z[:, 0, 1]) > 1e-30) if mask.any(): ratio_b = float( np.nanmean(np.abs(z[mask, 0, 0]) / np.abs(z[mask, 0, 1])) ) diag_ratios_before.append(ratio_b) # ── rotate Z ─────────────────────────────────────────────────── try: z_rot = rotate_impedance(z, theta) # (n_freq, 2, 2) if z_rot.ndim == 2: z_rot = z_rot[np.newaxis] except Exception as exc: warnings.append(f"{nm}: rotate_impedance failed: {exc}") failed_stations.append(nm) continue if mask.any(): ratio_a = float( np.nanmean( np.abs(z_rot[mask, 0, 0]) / np.abs(z_rot[mask, 0, 1]) ) ) diag_ratios_after.append(ratio_a) # ── rotate tipper (if present) ──────────────────────────────── tip_rot = None try: tip_raw = getattr(getattr(ed, "Tip", None), "tipper", None) if tip_raw is not None: t_arr = np.asarray(tip_raw) if t_arr.ndim == 3 and t_arr.shape[1] == 1: t_arr = t_arr[:, 0, :] if t_arr.shape == (len(fr), 2): tip_rot = rotate_tipper(t_arr, theta) except Exception as exc: warnings.append(f"{nm}: tipper rotation failed: {exc}") # ── write rotated EDI ───────────────────────────────────────── out_path = os.path.join(output_dir, f"{nm}{suffix}.edi") if os.path.exists(out_path) and not overwrite: warnings.append( f"{nm}: {out_path} exists — skipped (set overwrite=True)." ) failed_stations.append(nm) continue try: written = _write_rotated_edi( ed, z_rot, tip_rot, out_path, nm, theta, warnings ) if written: written_paths.append(written) else: failed_stations.append(nm) except Exception as exc: warnings.append(f"{nm}: EDI write failed: {exc}") failed_stations.append(nm) n_written = len(written_paths) # ── diagonal-suppression summary ────────────────────────────────── diag_reduction = float("nan") if diag_ratios_before and diag_ratios_after: diag_reduction = float( np.mean(diag_ratios_before) - np.mean(diag_ratios_after) ) # ── figure: before/after Zxy pseudosection ───────────────────────── figures: dict[str, Any] = {} fig_paths: dict[str, str] = {} try: fig = _plot_rotation_summary(sites, theta, warnings) if fig is not None: figures["rotation_summary"] = fig p = self._save_figure( fig, output_dir, "tensor_rotation_summary", warnings_list=warnings, ) if p: fig_paths["rotation_summary"] = p except Exception as exc: warnings.append(f"Rotation summary figure: {exc}") # ── LLM interpretation ──────────────────────────────────────────── interp: str | None = None if self.api_key and n_written: dr_str = ( f"{diag_reduction:+.3f}" if not np.isnan(diag_reduction) else "N/A" ) prompt = ( f"Tensor rotation summary:\n" f" Rotation angle: {theta:.1f}°\n" f" EDIs written: {n_written}\n" f" Failed stations: {len(failed_stations)}\n" f" Mean |Zxx/Zxy| reduction: {dr_str} (positive = improvement)\n" f" Warnings: {warnings[:3] if warnings else 'none'}\n\n" "Evaluate the rotation result and advise on coordinate-frame correction." ) interp = self.query_llm(prompt, max_tokens=200) elapsed = time.time() - t0 dr_disp = ( f"{diag_reduction:+.3f}" if not np.isnan(diag_reduction) else "N/A" ) return AgentResult( status="success" if n_written > 0 else "needs_review", summary=( f"Rotation {theta:.1f}°: {n_written} EDIs written, " f"{len(failed_stations)} failed. " f"Diagonal suppression: {dr_disp}." ), data={ "strike_deg": theta, "written_paths": written_paths, "failed_stations": failed_stations, "n_written": n_written, "z_diag_reduction": diag_reduction, "figures": figures, "figure_paths": fig_paths, }, warnings=warnings, llm_interpretation=interp, elapsed_seconds=elapsed, cost_estimate_usd=self._last_cost, )
# ── private helpers ─────────────────────────────────────────────────────────── def _write_rotated_edi( ed: Any, z_rot: np.ndarray, tip_rot: np.ndarray | None, out_path: str, station_name: str, theta: float, warnings: list[str], ) -> str | None: """Write rotated impedance to a new EDI file. Returns path or None.""" try: # Site wrappers have no writer of their own: materialise the # underlying EDIFile (which has write/write_new_edi) if not ( hasattr(ed, "write") or hasattr(ed, "write_new_edi") ) and callable(getattr(ed, "to_edi", None)): ed = ed.to_edi() # Try write_new_edi if the EDI object supports it if ( hasattr(ed, "write_new_edi") and hasattr(ed, "Z") and ed.Z is not None ): import copy Z_copy = copy.deepcopy(ed.Z) Z_copy.z = z_rot Tip_copy = None if ( tip_rot is not None and hasattr(ed, "Tip") and ed.Tip is not None ): Tip_copy = copy.deepcopy(ed.Tip) if hasattr(Tip_copy, "tipper"): Tip_copy.tipper = tip_rot[:, np.newaxis, :] return ed.write_new_edi( edi_fn=out_path, Z=Z_copy, Tipper=Tip_copy, ) # Fallback: patch Z on a deepcopy and call write() if hasattr(ed, "write"): import copy ed_copy = copy.deepcopy(ed) if hasattr(ed_copy, "Z") and ed_copy.Z is not None: ed_copy.Z.z = z_rot if ( tip_rot is not None and hasattr(ed_copy, "Tip") and ed_copy.Tip is not None ): ed_copy.Tip.tipper = tip_rot[:, np.newaxis, :] return ed_copy.write(new_edifn=out_path) warnings.append(f"{station_name}: EDI object has no write method.") return None except Exception as exc: warnings.append(f"{station_name}: _write_rotated_edi: {exc}") return None def _plot_rotation_summary( sites: Any, theta: float, warnings: list[str] ) -> Any: """Simple bar chart: mean |Zxx/Zxy| per station before rotation.""" import matplotlib.pyplot as plt from ..emtools._core import ( _get_z_block, _iter_items, _name, ) from ..seg.ops import rotate_impedance station_names, ratios_before, ratios_after = [], [], [] for i, ed in enumerate(_iter_items(sites)): nm = _name(ed, i) _, z, fr = _get_z_block(ed) if z is None: continue try: mask = np.isfinite(z[:, 0, 0]) & (np.abs(z[:, 0, 1]) > 1e-30) if not mask.any(): continue rb = float( np.nanmean(np.abs(z[mask, 0, 0]) / np.abs(z[mask, 0, 1])) ) z_r = rotate_impedance(z, theta) if z_r.ndim == 2: z_r = z_r[np.newaxis] ra = float( np.nanmean(np.abs(z_r[mask, 0, 0]) / np.abs(z_r[mask, 0, 1])) ) station_names.append(nm) ratios_before.append(rb) ratios_after.append(ra) except Exception: continue if not station_names: return None n = len(station_names) x = np.arange(n) fig, ax = plt.subplots(figsize=(max(6, n * 0.6), 4)) ax.bar( x - 0.18, ratios_before, width=0.35, label="Before", color="#3498db", alpha=0.8, ) ax.bar( x + 0.18, ratios_after, width=0.35, label=f"After θ={theta:.1f}°", color="#e74c3c", alpha=0.8, ) ax.set_xticks(x) ax.set_xticklabels(station_names, rotation=90, fontsize=7) ax.set_ylabel("|Zxx| / |Zxy| (lower = better suppression)", fontsize=8) ax.set_title( f"Tensor rotation — diagonal suppression (θ = {theta:.1f}°)", fontsize=9, fontweight="bold", ) ax.legend(fontsize=8) ax.tick_params(labelsize=7) fig.tight_layout() return fig __all__ = ["TensorRotationAgent"]