"""
pycsamt.agents.phase_analysis
==============================
:class:`PhaseAnalysisAgent` — Phase tensor, strike, and dimensionality analysis.
Wraps:
* :func:`~pycsamt.emtools.tensor.build_phase_tensor_table`
* :func:`~pycsamt.emtools.tensor.plot_phase_tensor_psection`
* :func:`~pycsamt.emtools.tensor.plot_phase_tensor_rose`
* :func:`~pycsamt.emtools.strike.estimate_strike_consensus`
* :func:`~pycsamt.emtools.strike.plot_strike_analysis`
* :func:`~pycsamt.emtools.strike.plot_strike_rose`
* :func:`~pycsamt.emtools.dimensionality.classify_dimensionality`
* :func:`~pycsamt.emtools.dimensionality.plot_dim_confidence_grid`
* :func:`~pycsamt.emtools.advanced.plot_impedance_mohr_circles`
* :func:`~pycsamt.emtools.advanced.plot_survey_fingerprint`
All figures are governed by :data:`~pycsamt.api.section.PYCSAMT_SECTION`
and :data:`~pycsamt.api.style.PYCSAMT_STYLE`.
"""
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 phase tensor analysis and geological interpretation.
Given a survey phase tensor summary, write 4–5 sentences that:
1. State the dominant dimensionality (1-D, 2-D, or 3-D) with evidence.
2. Report the consensus geoelectric strike direction and its reliability.
3. Identify periods / depth ranges where 3-D structure becomes significant.
4. Note any anomalous stations (high skew, low ellipticity).
5. Recommend whether to rotate data to strike before inversion.
Reply in plain English. No bullet points or markdown.
"""
[docs]
class PhaseAnalysisAgent(BaseAgent):
"""Run a full phase tensor, strike, and dimensionality survey analysis.
Parameters
----------
api_key, model, llm_provider : str
skew_th : float
Skewness |β| threshold for 3-D classification (°).
ellipt_th : float
Ellipticity λ threshold for 2-D classification.
band : (T_min, T_max) or None
Period band for strike estimation.
Input keys
----------
``sites`` / ``path`` : Sites or str
``period_range`` : [T_min, T_max], optional
``output_dir`` : str, optional
``run_mohr`` : bool, optional — also produce Mohr circles (default False)
``run_fingerprint`` : bool, optional — produce fingerprint grid (default True)
Output data keys
----------------
``pt_table`` pandas DataFrame — full PT metrics per (station, period)
``strike_consensus`` float — consensus strike angle (°)
``strike_iqr`` float — IQR of strike across all stations
``dim_table`` pandas DataFrame — per-(station, period) classification
``n_1d``, ``n_2d``, ``n_3d`` int — count of observations per class
``figures`` dict — matplotlib Figure objects
``figure_paths`` dict — saved file paths
Examples
--------
>>> agent = PhaseAnalysisAgent()
>>> result = agent.execute({"path": "/data/L22PLT",
... "output_dir": "/out/pt"})
>>> result["strike_consensus"]
42.5
"""
SYSTEM_PROMPT = _SYSTEM_PROMPT
def __init__(
self,
*,
api_key: str | None = None,
model: str | None = None,
llm_provider: str = "claude",
skew_th: float = 5.0,
ellipt_th: float = 0.1,
band: tuple[float, float] | None = None,
) -> None:
super().__init__(
"PhaseAnalysisAgent",
api_key=api_key,
model=model,
llm_provider=llm_provider,
section_preset="pseudosection",
)
self.skew_th = skew_th
self.ellipt_th = ellipt_th
self.band = band
[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)
output_dir = input_data.get("output_dir")
period_range = input_data.get("period_range")
run_mohr = bool(input_data.get("run_mohr", False))
run_fingerprint = bool(input_data.get("run_fingerprint", True))
band = period_range or self.band
# ── phase tensor table ────────────────────────────────────────────────
from ..emtools.dimensionality import (
classify_dimensionality,
plot_dim_confidence_grid,
)
from ..emtools.strike import (
estimate_strike_consensus,
plot_strike_analysis,
)
from ..emtools.tensor import (
build_phase_tensor_table,
plot_phase_tensor_psection,
plot_phase_tensor_rose,
)
pt_table = None
dim_table = None
figures: dict[str, Any] = {}
fig_paths: dict[str, str] = {}
try:
pt_table = build_phase_tensor_table(sites, verbose=0)
except Exception as exc:
warnings.append(f"build_phase_tensor_table: {exc}")
# ── dimensionality classification ─────────────────────────────────────
try:
dim_table = classify_dimensionality(
sites,
skew_th=self.skew_th,
ellipt_th=self.ellipt_th,
verbose=0,
)
except Exception as exc:
warnings.append(f"classify_dimensionality: {exc}")
# compute class counts from pt_table
n_1d = n_2d = n_3d = 0
if pt_table is not None and not pt_table.empty:
try:
beta = np.abs(pt_table["beta"].to_numpy(float))
ellipt = pt_table["ellipt"].to_numpy(float)
u3d = np.clip(beta / self.skew_th, 0, 1)
u1d = (1 - u3d) * np.clip(1 - ellipt / self.ellipt_th, 0, 1)
u2d = 1 - u1d - u3d
dom = np.argmax(np.column_stack([u1d, u2d, u3d]), axis=1)
n_1d = int((dom == 0).sum())
n_2d = int((dom == 1).sum())
n_3d = int((dom == 2).sum())
except Exception:
pass
# ── strike estimation ─────────────────────────────────────────────────
strike_consensus = np.nan
strike_iqr = np.nan
try:
st_result = estimate_strike_consensus(
sites,
band=band,
verbose=0,
)
# returns a DataFrame with columns: station, ang, iqr, lo, hi, n
if hasattr(st_result, "ang"):
ang_vals = st_result["ang"].dropna()
iqr_vals = st_result["iqr"].dropna()
if not ang_vals.empty:
# circular median (double-angle trick → stay in [-90°, 90°])
rad2 = np.radians(2.0 * ang_vals.to_numpy())
med2 = 0.5 * np.degrees(
np.arctan2(np.sin(rad2).mean(), np.cos(rad2).mean())
)
strike_consensus = float(med2)
strike_iqr = float(np.nanmedian(iqr_vals.to_numpy()))
except Exception as exc:
warnings.append(f"estimate_strike_consensus: {exc}")
# ── figures ───────────────────────────────────────────────────────────
# 1. Phase tensor pseudosection
try:
ax_pt = plot_phase_tensor_psection(
sites,
period_range=band,
figsize=(10.0, 5.5),
verbose=0,
)
fig = (
ax_pt.get_figure() if hasattr(ax_pt, "get_figure") else ax_pt
)
figures["pt_psection"] = fig
p = self._save_figure(
fig, output_dir, "pt_psection", warnings_list=warnings
)
if p:
fig_paths["pt_psection"] = p
except Exception as exc:
warnings.append(f"plot_phase_tensor_psection: {exc}")
# 2. Phase tensor rose
try:
fig_rose = plot_phase_tensor_rose(sites, band=band, verbose=0)
if fig_rose is not None:
f = (
fig_rose
if hasattr(fig_rose, "savefig")
else (
fig_rose.get_figure()
if hasattr(fig_rose, "get_figure")
else None
)
)
if f is not None:
figures["pt_rose"] = f
p = self._save_figure(
f, output_dir, "pt_rose", warnings_list=warnings
)
if p:
fig_paths["pt_rose"] = p
except Exception as exc:
warnings.append(f"plot_phase_tensor_rose: {exc}")
# 3. Strike analysis (3-rose panel)
try:
fig_strike = plot_strike_analysis(sites, band=band, verbose=0)
if fig_strike is not None:
f = (
fig_strike
if hasattr(fig_strike, "savefig")
else (
fig_strike.get_figure()
if hasattr(fig_strike, "get_figure")
else None
)
)
if f is not None:
figures["strike_analysis"] = f
p = self._save_figure(
f,
output_dir,
"strike_analysis",
warnings_list=warnings,
)
if p:
fig_paths["strike_analysis"] = p
except Exception as exc:
warnings.append(f"plot_strike_analysis: {exc}")
# 4. Dimensionality confidence grid
try:
ax_dim = plot_dim_confidence_grid(
sites,
skew_th=self.skew_th,
ellipt_th=self.ellipt_th,
verbose=0,
)
fig = (
ax_dim.get_figure()
if hasattr(ax_dim, "get_figure")
else ax_dim
)
if fig is not None:
figures["dim_confidence"] = fig
p = self._save_figure(
fig,
output_dir,
"dim_confidence_grid",
warnings_list=warnings,
)
if p:
fig_paths["dim_confidence"] = p
except Exception as exc:
warnings.append(f"plot_dim_confidence_grid: {exc}")
# 5. Optional: survey fingerprint (station × period, 4 metrics)
if run_fingerprint:
try:
from ..emtools.advanced import (
plot_survey_fingerprint,
)
fig_fp = plot_survey_fingerprint(
sites,
quantities=["skew", "ellipt", "theta", "s1"],
period_range=band,
)
figures["survey_fingerprint"] = fig_fp
p = self._save_figure(
fig_fp,
output_dir,
"survey_fingerprint",
warnings_list=warnings,
)
if p:
fig_paths["survey_fingerprint"] = p
except Exception as exc:
warnings.append(f"plot_survey_fingerprint: {exc}")
# 6. Optional: Mohr circles for first station
if run_mohr:
try:
from ..emtools.advanced import (
plot_impedance_mohr_circles,
)
fig_mohr = plot_impedance_mohr_circles(
sites,
n_periods=8,
verbose=0,
)
figures["mohr_circles"] = fig_mohr
p = self._save_figure(
fig_mohr,
output_dir,
"mohr_circles",
warnings_list=warnings,
)
if p:
fig_paths["mohr_circles"] = p
except Exception as exc:
warnings.append(f"plot_impedance_mohr_circles: {exc}")
# ── LLM interpretation ────────────────────────────────────────────────
interp: str | None = None
n_obs = (n_1d + n_2d + n_3d) or 1
if self.api_key:
prompt = (
f"Phase tensor analysis summary:\n"
f" 1-D observations: {n_1d} ({100 * n_1d / n_obs:.0f}%)\n"
f" 2-D observations: {n_2d} ({100 * n_2d / n_obs:.0f}%)\n"
f" 3-D observations: {n_3d} ({100 * n_3d / n_obs:.0f}%)\n"
f" Consensus strike: {strike_consensus:.1f}° "
f" IQR: {strike_iqr:.1f}°\n"
f" Skew threshold: {self.skew_th}°, "
f" ellipticity threshold: {self.ellipt_th}\n"
f" Warnings: {warnings[:3] if warnings else 'none'}\n\n"
"Interpret the dimensionality and strike, and recommend "
"whether to rotate the data before inversion."
)
interp = self.query_llm(prompt, max_tokens=250)
elapsed = time.time() - t0
frac_3d = 100 * n_3d / n_obs
return AgentResult(
status="success",
summary=(
f"Phase analysis complete: "
f"{n_1d}/{n_2d}/{n_3d} (1-D/2-D/3-D). "
f"Strike {strike_consensus:.1f}° ± {strike_iqr:.1f}°. "
f"3-D fraction {frac_3d:.0f}%. "
f"{len(figures)} figure(s) produced."
),
data={
"pt_table": pt_table,
"dim_table": dim_table,
"n_1d": n_1d,
"n_2d": n_2d,
"n_3d": n_3d,
"strike_consensus": float(strike_consensus),
"strike_iqr": float(strike_iqr),
"figures": figures,
"figure_paths": fig_paths,
"sites": sites,
},
warnings=warnings,
llm_interpretation=interp,
elapsed_seconds=elapsed,
cost_estimate_usd=self._last_cost,
)
__all__ = ["PhaseAnalysisAgent"]