# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
r"""
pycsamt.agents.plotting
=======================
:class:`PlotAgent` — produce the common visual products the Agent Master
could not make before:
* ``rhophi`` — apparent-resistivity / phase sounding curves
(:func:`~pycsamt.emtools.inspect.plot_rhoa_phi`),
* ``phase_psection`` — scalar phase pseudo-section
(:func:`~pycsamt.emtools.inspect.pseudosection` with ``quantity="phi_*"``),
* ``pt_psection`` — phase-tensor (Φ) ellipse pseudo-section
(:func:`~pycsamt.emtools.tensor.plot_phase_tensor_psection`),
* ``pt_strip`` — single-station phase-tensor ellipse strip vs period
(:func:`~pycsamt.emtools.tensor.plot_phase_tensor_strip`),
* ``pt_strip_grid`` — phase-tensor ellipse strips tiled by survey line
(:func:`~pycsamt.emtools.tensor.plot_phase_tensor_strip_grid`).
The agent never calls an LLM; it just turns a validated parameter set into
matplotlib figures, returned under ``AgentResult.data["figures"]`` keyed by a
human title. The Agent Master collects those figures into the chat + the
Figures panel.
"""
from __future__ import annotations
import time
from typing import Any
from ._base import AgentResult
__all__ = ["PlotAgent", "PLOT_KINDS"]
PLOT_KINDS = (
"rhophi",
"phase_psection",
"pt_psection",
"tipper",
"pt_map",
"station_response",
"strike_profile",
"pt_strip",
"pt_strip_grid",
)
# Publication-quality rcParams — applied in a context so global state is
# never mutated.
_PUB_RC = {
"font.size": 12,
"axes.titlesize": 13,
"axes.labelsize": 12,
"xtick.labelsize": 10,
"ytick.labelsize": 10,
"legend.fontsize": 9,
"axes.linewidth": 1.1,
"savefig.dpi": 300,
"figure.dpi": 150,
"axes.grid": True,
"grid.alpha": 0.25,
}
def _as_list(val: Any) -> list[str]:
"""Normalise a comma-string / list / None into a clean list of tokens."""
if val is None:
return []
if isinstance(val, (list, tuple)):
items = list(val)
else:
items = str(val).replace(";", ",").split(",")
out = [str(x).strip() for x in items if str(x).strip()]
return out
def _norm_components(val: Any) -> tuple[str, ...]:
"""Resolve a component selection to a tuple of ``xy`` / ``yx`` / ``det``."""
toks = [t.lower() for t in _as_list(val)]
if not toks or "both" in toks or "all" in toks:
return ("xy", "yx")
out: list[str] = []
for t in toks:
t = t.replace("rho_", "").replace("phi_", "").replace("z", "")
if t in ("xy", "yx", "det") and t not in out:
out.append(t)
return tuple(out) or ("xy", "yx")
def _period_range(d: dict[str, Any]):
lo = d.get("period_min")
hi = d.get("period_max")
try:
lo = float(lo) if lo not in (None, "") else None
hi = float(hi) if hi not in (None, "") else None
except (TypeError, ValueError):
return None
if lo is None and hi is None:
return None
# tolerate a single-sided bound
lo = lo if lo is not None else 1e-9
hi = hi if hi is not None else 1e9
return (lo, hi)
def _truthy(v: Any) -> bool:
if isinstance(v, bool):
return v
return str(v).strip().lower() in ("1", "true", "yes", "on", "y")
def _has_tipper(sites: Any) -> bool:
"""True when at least one station carries a vertical-field tipper (T)."""
from ..emtools._core import _get_t_block, _iter_items
for ed in _iter_items(sites):
try:
_, t, fr = _get_t_block(ed)
except Exception: # noqa: BLE001
continue
if t is not None and fr is not None:
return True
return False
def _norm_parts(val: Any) -> tuple[str, ...]:
"""Resolve tipper real/imag selection."""
toks = [t.lower() for t in _as_list(val)]
if not toks or "both" in toks:
return ("real", "imag")
out = [t for t in toks if t in ("real", "imag")]
return tuple(out) or ("real", "imag")
def _filter_sites(sites: Any, station_names: list[str]):
"""Return a Sites with only *station_names* (case-insensitive); the full
set when the selection is empty or matches nothing."""
if not station_names:
return sites
from ..emtools._core import (
_iter_items,
_name,
_sites_from_items,
_unwrap,
)
wanted = {s.lower() for s in station_names}
keep = []
for i, ed in enumerate(_iter_items(sites)):
if _name(ed, i).lower() in wanted:
keep.append(_unwrap(ed))
if not keep:
return sites
return _sites_from_items(keep)
[docs]
class PlotAgent:
"""Render rho/phi curves and phase / phase-tensor pseudo-sections."""
def __init__(self, **_: Any) -> None:
# Accept (and ignore) LLM kwargs so the agent registry can build it
# the same way as every other agent.
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
kind = str(input_data.get("kind", "")).strip()
if kind not in PLOT_KINDS:
return AgentResult.failed(
f"Unknown plot kind {kind!r}. Expected one of {PLOT_KINDS}.",
elapsed=time.time() - t0,
)
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 supplied. Load an EDI dataset first.",
elapsed=time.time() - t0,
)
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
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 EDI data: {exc}",
elapsed=time.time() - t0,
)
# Tipper requires a vertical-field transfer function — many CSAMT /
# AMT datasets don't record it. Fail clearly (with a reason the caller
# can turn into "tipper is not available for <line>") instead of
# drawing an empty axes.
if kind == "tipper" and not _has_tipper(sites):
return AgentResult(
status="failed",
summary="No tipper (vertical magnetic transfer function) "
"data is available in this dataset.",
data={"reason": "no_tipper"},
elapsed_seconds=time.time() - t0,
)
stations = _as_list(input_data.get("stations"))
pub = _truthy(input_data.get("publication"))
warnings: list[str] = []
_METHODS = {
"rhophi": self._rhophi,
"phase_psection": self._phase_psection,
"tipper": self._tipper,
"pt_map": self._pt_map,
"pt_psection": self._pt_psection,
"station_response": self._station_response,
"strike_profile": self._strike_profile,
"pt_strip": self._pt_strip,
"pt_strip_grid": self._pt_strip_grid,
}
rc = _PUB_RC if pub else {}
try:
with plt.rc_context(rc):
out = _METHODS[kind](
plt, sites, stations, input_data, warnings
)
except Exception as exc: # noqa: BLE001
return AgentResult.failed(
f"Plot failed: {exc}",
hint="Check the station names, components and period range.",
elapsed=time.time() - t0,
)
# A kind returns either (fig, title) or a {title: fig} dict.
if isinstance(out, dict):
figures, title = out, kind.replace("_", " ")
else:
fig, title = out
figures = {title: fig}
if pub:
for f in figures.values():
try:
f.tight_layout()
except Exception: # noqa: BLE001
pass
n_st = stations and len(stations) or "all"
summary = (
f"{title} produced ({n_st} station(s)"
+ (", publication style" if pub else "")
+ ")."
)
return AgentResult(
status="success",
summary=summary,
data={"figures": figures, "plot_kind": kind},
warnings=warnings,
elapsed_seconds=time.time() - t0,
cost_estimate_usd=0.0,
)
# ── kinds ───────────────────────────────────────────────────────────────
def _rhophi(self, plt, sites, stations, d, warnings):
from ..emtools.inspect import plot_rhoa_phi
comps = _norm_components(d.get("components"))
errorbar = _truthy(d.get("errorbar", True))
sub = _filter_sites(sites, stations)
if stations and sub is sites:
warnings.append(
"Requested stations not found; plotting all stations."
)
figsize = (8.0, 6.5) if _truthy(d.get("publication")) else (7.5, 6.0)
fig, (ax_r, ax_p) = plt.subplots(2, 1, figsize=figsize, sharex=True)
plot_rhoa_phi(
sub,
components=comps,
axis="period",
errorbar=errorbar,
ax_r=ax_r,
ax_p=ax_p,
verbose=0,
)
ax_r.set_title(
"Apparent resistivity & phase (" + ", ".join(comps) + ")"
)
return fig, "Rho/Phi sounding curves"
def _phase_psection(self, plt, sites, stations, d, warnings):
from ..emtools.inspect import pseudosection
comps = _norm_components(d.get("components"))
pr = _period_range(d)
sub = _filter_sites(sites, stations)
n = len(comps)
figsize = (
(9.0, 3.4 * n)
if _truthy(d.get("publication"))
else (8.0, 3.0 * n)
)
fig, axes = plt.subplots(n, 1, figsize=figsize, squeeze=False)
for ax, comp in zip(axes[:, 0], comps):
pseudosection(
sub,
quantity=f"phi_{comp}",
axis_x="station",
axis_y="period",
period_range=pr,
ax=ax,
verbose=0,
)
ax.set_title(f"Phase pseudo-section φ_{comp} (deg)")
return fig, "Phase pseudo-section"
def _tipper(self, plt, sites, stations, d, warnings):
sub = _filter_sites(sites, stations)
if stations and sub is sites:
warnings.append(
"Requested stations not found; using all stations."
)
view = str(d.get("view", "components") or "components").lower()
pub = _truthy(d.get("publication"))
if view in ("arrows", "arrow", "induction", "map", "profile"):
from ..emtools.tf import plot_induction_arrows
conv = str(d.get("convention", "park") or "park").lower()
per = d.get("period")
if per in (None, ""):
per = d.get("period_min")
try:
periods = [float(per)] if per not in (None, "") else (1.0,)
except (TypeError, ValueError):
periods = (1.0,)
figsize = (8.0, 3.8) if pub else (7.2, 3.4)
fig, ax = plt.subplots(figsize=figsize)
plot_induction_arrows(
sub, periods=periods, convention=conv, ax=ax, verbose=0
)
ax.set_title(
f"Tipper induction arrows (T≈{periods[0]:g}s, {conv})"
)
return fig, "Tipper induction arrows"
# default: component curves (Tx/Ty, real/imag)
from ..emtools.inspect import plot_tipper_components
parts = _norm_parts(d.get("parts"))
figsize = (8.0, 4.8) if pub else (7.5, 4.5)
fig, ax = plt.subplots(figsize=figsize)
plot_tipper_components(
sub, kind=parts, axis="period", ax=ax, verbose=0
)
ax.set_title("Tipper components (" + ", ".join(parts) + ")")
return fig, "Tipper components"
def _station_response(self, plt, sites, stations, d, warnings):
"""Per-station impedance response (Bode ρa/φ consistency diagram),
one figure per requested component."""
from ..emtools._core import _iter_items, _name
from ..emtools.advanced import plot_rho_phase_bode
comps = [
c
for c in _norm_components(d.get("components"))
if c in ("xy", "yx")
] or ["xy"]
station = stations[0] if stations else None
if station is None:
# default to the first station so the inspector always shows one
for i, ed in enumerate(_iter_items(sites)):
station = _name(ed, i)
break
warnings.append(
f"No station given; showing the first station ({station})."
)
pr = _period_range(d)
figures: dict = {}
for comp in comps:
fig = plot_rho_phase_bode(
sites,
station=station,
component=comp,
period_range=pr,
verbose=0,
)
figures[f"Response {comp} — {station}"] = fig
return figures
def _strike_profile(self, plt, sites, stations, d, warnings):
from ..emtools.strike import plot_strike_profile
method = str(d.get("method", "consensus") or "consensus").lower()
if method not in ("consensus", "sweep", "pt"):
method = "consensus"
pr = _period_range(d)
sort_by = str(d.get("sort_by", "auto") or "auto").lower()
pub = _truthy(d.get("publication"))
figsize = (9.2, 4.2) if pub else (8.6, 3.8)
fig, ax = plt.subplots(figsize=figsize)
plot_strike_profile(
_filter_sites(sites, stations),
method=method,
band=pr,
sort_by=sort_by,
ax=ax,
verbose=0,
)
ax.set_title(f"Strike profile ({method})")
return fig, "Strike profile"
def _pt_map(self, plt, sites, stations, d, warnings):
from ..emtools.tensor import plot_phase_tensor_map
per = d.get("period")
if per in (None, ""):
per = d.get("period_min")
try:
period = float(per) if per not in (None, "") else 10.0
except (TypeError, ValueError):
period = 10.0
c_by = str(d.get("color_by", "") or "").strip().lower() or None
kw: dict[str, Any] = {}
if c_by:
kw["c_by"] = c_by
scale = d.get("scale")
if scale not in (None, ""):
try:
kw["scale"] = float(scale)
except (TypeError, ValueError):
pass
sub = _filter_sites(sites, stations)
pub = _truthy(d.get("publication"))
figsize = (9.0, 7.5) if pub else (8.0, 6.5)
fig, ax = plt.subplots(figsize=figsize)
plot_phase_tensor_map(
sub,
period=period,
show_tipper=_has_tipper(sub),
ax=ax,
verbose=0,
**kw,
)
ax.set_title(f"Phase-tensor map (T≈{period:g} s)")
return fig, "Phase-tensor map"
def _pt_psection(self, plt, sites, stations, d, warnings):
from ..emtools.tensor import (
plot_phase_tensor_psection,
)
pr = _period_range(d)
c_by = str(d.get("color_by", "") or "").strip().lower() or None
kw: dict[str, Any] = {}
if c_by:
kw["c_by"] = c_by
scale = d.get("scale")
if scale not in (None, ""):
try:
kw["scale"] = float(scale)
except (TypeError, ValueError):
pass
figsize = (
(11.0, 6.0) if _truthy(d.get("publication")) else (10.0, 5.5)
)
fig, ax = plt.subplots(figsize=figsize)
plot_phase_tensor_psection(
sites,
stations=stations or None,
period_range=pr,
ax=ax,
verbose=0,
**kw,
)
return fig, "Phase-tensor (Φ) pseudo-section"
def _pt_strip(self, plt, sites, stations, d, warnings):
from ..emtools._core import _iter_items, _name
from ..emtools.tensor import plot_phase_tensor_strip
station = stations[0] if stations else None
if station is None:
for i, ed in enumerate(_iter_items(sites)):
station = _name(ed, i)
break
warnings.append(
f"No station given; showing the first station ({station})."
)
pr = _period_range(d)
pub = _truthy(d.get("publication"))
figsize = (7.5, 1.8) if pub else (7.0, 1.6)
fig, ax = plt.subplots(figsize=figsize)
plot_phase_tensor_strip(
sites,
station=station,
period_range=pr,
ax=ax,
verbose=0,
)
return fig, f"Phase-tensor ellipse strip — {station}"
def _pt_strip_grid(self, plt, sites, stations, d, warnings):
from ..emtools._core import _iter_items, _name
from ..emtools.tensor import (
plot_phase_tensor_strip_grid,
)
from ..site.lines import (
detect_lines_from_station_ids,
pick_representative_stations,
)
names = [_name(ed, i) for i, ed in enumerate(_iter_items(sites))]
lines = detect_lines_from_station_ids(names)
if not lines:
raise ValueError("No survey lines detected for the strip grid.")
try:
per_line = max(1, int(d.get("per_line") or 4))
except (TypeError, ValueError):
per_line = 4
profiles = {
ln: pick_representative_stations(sts, per_line)
for ln, sts in lines.items()
}
fig = plot_phase_tensor_strip_grid(
sites, profiles=profiles, verbose=0
)
return fig, "Phase-tensor ellipse strip grid (by line)"