Source code for pycsamt.agents.metrics

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
r"""
pycsamt.agents.metrics
======================

:class:`MetricsAgent` — answer **conversational questions about computed
values** of a survey line (or all lines), inline, without a modal or a figure.

Where :class:`~pycsamt.agents.tooling.ToolAgent` opens a parameter modal and
returns a table + figure, the metrics agent turns a question like *"what's the
strike of L22PLT?"* or *"azimuth of all lines?"* into a short, friendly text
answer with the actual number(s).

Supported value *kinds* (see :data:`METRIC_KINDS`):

* ``strike``        — regional geoelectric strike (consensus, ±90°),
* ``azimuth``       — profile bearing from station geometry (PCA, 0–180°N),
* ``dimensionality``— dominant 1-D / 2-D / 3-D class and the distribution,
* ``skew``          — mean phase-tensor skew |β|,
* ``stations``      — station count (and names),
* ``periods``       — period range (s),
* ``frequencies``   — frequency range (Hz),
* ``coordinates``   — lat/lon bounding box and approximate profile length,
* ``quality``       — mean QC score, flagged stations, tipper presence,
* ``summary``       — a one-paragraph digest combining the above.

The agent never calls an LLM. :func:`parse_metric_request` and
:func:`looks_like_metric_query` are shared, testable helpers the chat router
uses to detect a value question and pull out which kinds / scope were asked.
"""

from __future__ import annotations

import time
from typing import Any

from ._base import AgentResult
from .plotting import (
    _filter_sites,
    _has_tipper,
)
from .tooling import (
    _circular_strike_mean,
    _ll_to_utm,
    _station_coords,
)

__all__ = [
    "MetricsAgent",
    "METRIC_KINDS",
    "parse_metric_request",
    "looks_like_metric_query",
]

METRIC_KINDS = (
    "strike",
    "azimuth",
    "dimensionality",
    "skew",
    "stations",
    "periods",
    "frequencies",
    "coordinates",
    "quality",
    "summary",
)

# Ordered synonym table: most-specific phrases first. First match per kind.
_SYNONYMS: dict[str, tuple[str, ...]] = {
    "summary": (
        "summary",
        "overview",
        "tell me about",
        "describe the line",
        "describe this line",
        "about this line",
        "about the line",
        "give me a rundown",
        "brief on",
        "characterise",
        "characterize",
    ),
    "strike": (
        "geoelectric strike",
        "strike direction",
        "strike angle",
        "regional strike",
        "strike of",
        "strike for",
        "strike",
    ),
    "azimuth": (
        "azimuth",
        "bearing",
        "profile direction",
        "line direction",
        "profile orientation",
        "line orientation",
        "orientation",
    ),
    "dimensionality": (
        "dimensionality",
        "dimension",
        "1d 2d 3d",
        "1d/2d/3d",
        "how many dimensions",
    ),
    "skew": ("skew", "phase tensor skew", "beta", "asymmetry"),
    "stations": (
        "how many stations",
        "number of stations",
        "station count",
        "count of stations",
        "n stations",
        "stations",
    ),
    "periods": (
        "period range",
        "period band",
        "periods",
        "shortest period",
        "longest period",
    ),
    "frequencies": (
        "frequency range",
        "frequency band",
        "frequencies",
        "frequency",
    ),
    "coordinates": (
        "coordinates",
        "bounding box",
        "profile length",
        "line length",
        "how long is the line",
        "extent",
        "location",
    ),
    "quality": (
        "data quality",
        "quality score",
        "qc score",
        "quality",
    ),
}

# Words that mean "the full survey / every line".
_ALL_SCOPE = (
    "all lines",
    "all the lines",
    "every line",
    "each line",
    "all profiles",
    "across lines",
    "all surveys",
    "per line",
)

# Phrasing that marks a *visual* / workflow request, not a value question.
_VISUAL_OR_WORKFLOW = (
    "plot",
    "rose",
    "map",
    "pseudosection",
    "pseudo-section",
    "section",
    "figure",
    "chart",
    "diagram",
    "analyzer",
    "analyser",
    "analysis",
    "run ",
    "compute",
    "estimate",
    "inspect",
    "screen",
    "flag",
    "render",
    "draw",
    "export",
    "save",
)

# Kinds whose names double as pyCSAMT *concepts* ("what is geoelectric
# strike?" is a QUESTION, "what's the strike of L22PLT?" is a value). These
# require explicit data scope to count as a metric question; the survey-only
# kinds (stations / periods / …) accept plain question phrasing.
_CONCEPT_OVERLAP = frozenset(
    {
        "strike",
        "azimuth",
        "dimensionality",
        "skew",
        "summary",
    }
)


[docs] def parse_metric_request(text: str) -> tuple[list[str], bool]: """Return ``(kinds, all_lines)`` parsed from *text*. ``kinds`` is the ordered, de-duplicated list of metric kinds the user asked about; ``all_lines`` is ``True`` when the request spans every line. Examples -------- >>> parse_metric_request("what is the strike of L22PLT?") (['strike'], False) >>> parse_metric_request("give me the azimuth for all lines") (['azimuth'], True) >>> parse_metric_request("tell me about this line") (['summary'], False) """ t = (text or "").lower() all_lines = any(p in t for p in _ALL_SCOPE) kinds: list[str] = [] for kind, phrases in _SYNONYMS.items(): if any(p in t for p in phrases) and kind not in kinds: kinds.append(kind) # "summary" is greedy — if it co-occurs with specific metrics, prefer the # specific ones (the user asked for particular values, not a digest). if "summary" in kinds and len(kinds) > 1: kinds.remove("summary") return kinds, all_lines
[docs] def looks_like_metric_query(text: str) -> bool: """Heuristic: does *text* ask for a computed value of a line? True when the message names a value (strike, azimuth, …) **and** is phrased as a question / scoped to a line, and is *not* a plot/workflow request. Examples -------- >>> looks_like_metric_query("what's the strike of L22PLT?") True >>> looks_like_metric_query("plot the strike rose") False >>> looks_like_metric_query("run phase tensor analysis") False """ t = (text or "").lower().strip() if not t: return False if any(w in t for w in _VISUAL_OR_WORKFLOW): return False kinds, all_lines = parse_metric_request(t) if not kinds: return False # A value word alone isn't enough ("strike" could be a workflow). Require # question-style or line-scoped phrasing. question_like = t.endswith("?") or t.startswith( ( "what", "how", "which", "give", "show", "tell", "list", "whats", "what's", ) ) scoped = ( all_lines or " of " in t or " for " in t or "this line" in t or "these lines" in t or "the line" in t or "the lines" in t or "the survey" in t or "this survey" in t or "my data" in t or "my survey" in t or "in the line" in t ) # Concept-overlapping value words ("strike", "summary", …) need explicit # data scope so "what is geoelectric strike?" / "tell me about the Sites # data model" stay package questions, not value lookups. if set(kinds) <= _CONCEPT_OVERLAP: return scoped return bool(question_like or scoped)
def _fmt_deg(v: float) -> str: return f"{v:.0f}°"
[docs] class MetricsAgent: """Compute and phrase per-line survey values as inline chat answers.""" def __init__(self, **_: Any) -> None: self._last_cost = 0.0 # ── public API ──────────────────────────────────────────────────────────
[docs] def execute(self, input_data: dict[str, Any]) -> AgentResult: t0 = time.time() self._last_cost = 0.0 kinds = input_data.get("kinds") or [input_data.get("kind", "summary")] kinds = [ str(k).strip() for k in kinds if str(k).strip() in METRIC_KINDS ] if not kinds: kinds = ["summary"] src = ( input_data.get("sites") or input_data.get("path") or input_data.get("data_path") ) if src is None: return AgentResult.failed( "No data available. Load an EDI dataset or name a survey line.", elapsed=time.time() - t0, ) from ..emtools._core import ensure_sites try: sites = ensure_sites(src, recursive=True, strict=False, verbose=0) except Exception as exc: # noqa: BLE001 return AgentResult.failed( f"Could not load survey data: {exc}", elapsed=time.time() - t0, ) stations = input_data.get("stations") if stations: from .plotting import _as_list sites = _filter_sites(sites, _as_list(stations)) label = str(input_data.get("label") or "").strip() warnings: list[str] = [] values: dict[str, str] = {} for kind in kinds: try: values[kind] = self._compute(kind, sites, warnings) except Exception as exc: # noqa: BLE001 values[kind] = "could not be computed" warnings.append(f"{kind}: {exc}") answer = self._phrase(label, kinds, values) return AgentResult( status="success", summary=answer, data={"values": values, "kinds": kinds, "tool_kind": "metrics"}, warnings=warnings, elapsed_seconds=time.time() - t0, cost_estimate_usd=0.0, )
# ── phrasing ──────────────────────────────────────────────────────────── @staticmethod def _phrase(label: str, kinds: list[str], values: dict[str, str]) -> str: where = f" for **{label}**" if label else "" if kinds == ["summary"]: return f"Here's the rundown{where}: {values['summary']}" if len(kinds) == 1: k = kinds[0] return f"The {k}{where}{values[k]}." lines = "\n".join(f"- **{k}**: {values[k]}" for k in kinds) return f"Here's what I found{where}:\n{lines}" # ── dispatch ──────────────────────────────────────────────────────────── def _compute(self, kind: str, sites, warnings) -> str: return getattr(self, f"_m_{kind}")(sites, warnings) # ── individual metrics ────────────────────────────────────────────────── def _m_strike(self, sites, warnings) -> str: from ..emtools.strike import estimate_strike_consensus band = None res = estimate_strike_consensus(sites, band=band, verbose=0) df = res.frame if hasattr(res, "frame") else res reg = ( _circular_strike_mean(df["ang"]) if "ang" in df else float("nan") ) import numpy as np if not np.isfinite(reg): return "no usable strike could be estimated" compass = f"N{reg:.0f}°E" if reg >= 0 else f"N{abs(reg):.0f}°W" return ( f"regional geoelectric strike ≈ {compass}, with the inherent " f"90° ambiguity, from {len(df)} station(s)" ) def _m_azimuth(self, sites, warnings) -> str: import numpy as np from ..gis.coord_correction import _pca_azimuth es, ns = [], [] for _, lat, lon in _station_coords(sites): if lat is None or lon is None: continue try: e, n, _ = _ll_to_utm(lat, lon, None, "N", "WGS84") es.append(e) ns.append(n) except Exception: # noqa: BLE001 continue if len(es) < 2: return "not enough located stations to estimate a profile azimuth" az = _pca_azimuth(np.asarray(es), np.asarray(ns)) return ( f"profile azimuth ≈ {_fmt_deg(az)} from north (0–180°), " f"from {len(es)} located station(s)" ) def _m_dimensionality(self, sites, warnings) -> str: from ..emtools.dimensionality import ( classify_dimensionality, ) res = classify_dimensionality(sites, verbose=0) df = res.frame if hasattr(res, "frame") else res if "dim" not in df or df.empty: return "could not be classified" lbl = {0: "1-D", 1: "2-D", 2: "3-D", 3: "3-D"} counts = df["dim"].value_counts().to_dict() total = int(sum(counts.values())) or 1 dist = ", ".join( f"{lbl.get(k, k)} {100 * v / total:.0f}%" for k, v in sorted(counts.items()) ) dom = max(counts, key=counts.get) return ( f"dominantly {lbl.get(dom, dom)} " f"({100 * counts[dom] / total:.0f}% of cells); distribution {dist}" ) def _m_skew(self, sites, warnings) -> str: import numpy as np from ..emtools.dimensionality import ( classify_dimensionality, ) res = classify_dimensionality(sites, verbose=0) df = res.frame if hasattr(res, "frame") else res if "beta_abs" not in df or df.empty: return "skew is unavailable" v = float(np.nanmean(df["beta_abs"])) tag = "→ 3-D / distorted" if v > 3.0 else "→ 1-D / 2-D regime" return f"mean phase-tensor skew |β| ≈ {v:.1f}° ({tag})" def _m_stations(self, sites, warnings) -> str: rows = self._scan(sites) names = [r.get("station", "?") for r in rows] head = ", ".join(str(n) for n in names[:6]) more = f" … (+{len(names) - 6} more)" if len(names) > 6 else "" return ( f"{len(names)} station(s): {head}{more}" if names else "no stations found" ) def _m_periods(self, sites, warnings) -> str: rows = self._scan(sites) tmins = [r["t_min_s"] for r in rows if r.get("t_min_s")] tmaxs = [r["t_max_s"] for r in rows if r.get("t_max_s")] nfr = [r["n_freq"] for r in rows if r.get("n_freq")] if not tmins or not tmaxs: return "period information is unavailable" return ( f"period range {min(tmins):.3g}{max(tmaxs):.3g} s " f"(up to {max(nfr) if nfr else 0} periods per station)" ) def _m_frequencies(self, sites, warnings) -> str: rows = self._scan(sites) tmins = [r["t_min_s"] for r in rows if r.get("t_min_s")] tmaxs = [r["t_max_s"] for r in rows if r.get("t_max_s")] if not tmins or not tmaxs: return "frequency information is unavailable" f_hi = 1.0 / min(tmins) f_lo = 1.0 / max(tmaxs) return f"frequency range {f_lo:.3g}{f_hi:.3g} Hz" def _m_coordinates(self, sites, warnings) -> str: import numpy as np coords = [ (la, lo) for _, la, lo in _station_coords(sites) if la is not None and lo is not None ] if not coords: return "no station coordinates are available" lats = np.array([c[0] for c in coords]) lons = np.array([c[1] for c in coords]) # approximate length: bounding-box diagonal in UTM length_km = float("nan") try: es, ns = [], [] for la, lo in coords: e, n, _ = _ll_to_utm(la, lo, None, "N", "WGS84") es.append(e) ns.append(n) es, ns = np.asarray(es), np.asarray(ns) length_km = ( float(np.hypot(es.max() - es.min(), ns.max() - ns.min())) / 1000.0 ) except Exception: # noqa: BLE001 pass span = ( f", profile span ≈ {length_km:.1f} km" if np.isfinite(length_km) else "" ) return ( f"spans lat {lats.min():.4f}{lats.max():.4f}°, " f"lon {lons.min():.4f}{lons.max():.4f}°{span} " f"({len(coords)} located station(s))" ) def _m_quality(self, sites, warnings) -> str: import numpy as np rows = self._scan(sites) if not rows: return "no stations with valid data found" scores = np.array([r.get("qc_score") or 0 for r in rows], float) n_flag = sum( 1 for r in rows if not r.get("has_z") or not r.get("has_coords") or (r.get("qc_score") or 0) < 50 ) tip = "present" if _has_tipper(sites) else "absent" return ( f"mean QC score {np.nanmean(scores):.0f}/100, " f"{n_flag} of {len(rows)} station(s) flagged; tipper {tip}" ) def _m_summary(self, sites, warnings) -> str: parts = [] for k in ( "stations", "periods", "strike", "dimensionality", "quality", ): try: parts.append(self._compute(k, sites, warnings)) except Exception as exc: # noqa: BLE001 warnings.append(f"{k}: {exc}") return "; ".join(parts) + "." # ── shared ────────────────────────────────────────────────────────────── @staticmethod def _scan(sites) -> list: from .loader import _quality_scan rows, _ = _quality_scan(sites) return rows or []