# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
r"""
pycsamt.agents.iot_agent
========================
:class:`IoTFieldAgent` — AI-assisted monitoring for IoT-enabled AMT / MT /
CSAMT / CSEM field acquisition.
Where :class:`~pycsamt.agents.qc.DataQCAgent` reasons about *processed* EDI
data, this agent reasons about the **live field campaign**: the telemetry
stream coming off edge nodes while the survey is still running. It is a thin,
defensive orchestration layer over :mod:`pycsamt.iot` that turns whatever
acquisition evidence the caller has — a :class:`~pycsamt.iot.session.FieldSession`,
a bag of telemetry packets, or an existing EDI survey to re-occupy — into:
* a single :class:`~pycsamt.iot.monitoring.MonitoringStatus` (OK / WARN /
CRITICAL) with the concrete issues that tripped it,
* per-topic telemetry, per-station, and pipeline hand-off tables,
* optional clock-sync and power-budget summaries,
* an optional reproducible :class:`~pycsamt.iot.provenance.AcquisitionManifest`
(written and, on request, signed),
* dashboard / edge-QC / power / sync figures rendered with the shared pycsamt
plotting configuration, and
* a plain-English field-ops interpretation from the configured LLM.
Every sub-step is wrapped so that a failure in one diagnostic degrades to a
warning rather than sinking the whole assessment — the agent always returns a
usable :class:`~pycsamt.agents._base.AgentResult`.
Wraps :mod:`pycsamt.iot`:
* :func:`~pycsamt.iot.assess_telemetry` / :class:`~pycsamt.iot.TelemetryMonitor`
* :func:`~pycsamt.iot.telemetry_summary`, :func:`~pycsamt.iot.packet_table`,
:func:`~pycsamt.iot.monitoring_status_table`
* :func:`~pycsamt.iot.batch_assess_sync`, :func:`~pycsamt.iot.sync_status_table`
* :func:`~pycsamt.iot.estimate_deployment_energy`
* :func:`~pycsamt.iot.build_acquisition_manifest`
* :func:`~pycsamt.iot.field_session_from_edis`,
:func:`~pycsamt.iot.deployment_report`
* :func:`~pycsamt.iot.plot_field_dashboard`,
:func:`~pycsamt.iot.plot_edge_qc_summary`,
:func:`~pycsamt.iot.plot_power_budget`,
:func:`~pycsamt.iot.plot_sync_quality`
"""
from __future__ import annotations
import time
from typing import Any
from ._base import AgentResult, BaseAgent
_SYSTEM_PROMPT = """\
You are an expert IoT field-operations analyst for pycsamt v2, supervising a
live AMT/MT/CSAMT/CSEM acquisition through its edge telemetry stream.
Given a survey monitoring summary, write 3-5 sentences that:
1. State the overall health of the acquisition (healthy / degraded / critical).
2. Call out the specific stations, channels, or devices that need field
attention, and the dominant failure mode (packet loss, high latency,
low battery, clock drift, edge rejections, sensor dropout).
3. Explain the most likely field cause (power budget, GPS/clock, comms link,
contact resistance, powerline or near-field interference).
4. Recommend the single most important field action to take right now.
Reply in plain English. No bullet points, no markdown headings.
"""
[docs]
class IoTFieldAgent(BaseAgent):
r"""Assess and monitor an IoT-enabled EM field acquisition.
Parameters
----------
api_key, model, llm_provider : str, optional
LLM configuration. When ``api_key`` is ``None`` the agent runs fully
offline and ``llm_interpretation`` is ``None``.
method : str, optional
EM method hint (``"amt"``, ``"mt"``, ``"csamt"``, ``"csem"``,
``"tdem"``, ``"tem"``). Used to seed sessions built from packets and
to tag the manifest. When omitted it is inferred from the telemetry.
write_manifest : bool, default False
When ``True`` an :class:`~pycsamt.iot.provenance.AcquisitionManifest`
is built for every run (and written when ``manifest_path`` /
``output_dir`` is available).
Input keys
----------
One acquisition source is required (tried in this order):
``session`` : FieldSession or mapping
A live session, or a mapping from
:meth:`~pycsamt.iot.FieldSession.to_dict`.
``packets`` : iterable of TelemetryPacket or mapping
Raw telemetry to fold into a fresh session.
``path`` / ``edis`` / ``sites`` : EDI survey
An existing survey to seed a re-occupation session via
:func:`~pycsamt.iot.field_session_from_edis` (no live packets).
``deployment`` : DeploymentConfig, optional
Declared device capabilities; tabulated via
:func:`~pycsamt.iot.deployment_report`.
Other optional keys:
``survey_id`` : str
Identifier for sessions built from packets/EDIs (default
``"iot_survey"``).
``now`` : float
Reference epoch seconds for live latency / gap calculations.
``output_dir`` : str
Directory for saved figures and (when requested) the manifest.
``manifest`` : bool
Force building the acquisition manifest for this run.
``manifest_path`` : str
Explicit path to write the manifest JSON.
``sign_key`` : str or bytes
When given with a manifest, the written manifest is HMAC-signed.
``sync_references`` : mapping
Per-device reference clocks for :func:`~pycsamt.iot.batch_assess_sync`.
``energy_configs`` : iterable of EnergyConfig
Device energy budgets for :func:`~pycsamt.iot.estimate_deployment_energy`.
``figures`` : bool, default True
Whether to render dashboard/edge/power/sync figures.
``api`` : bool
Passed through to pycsamt table builders (API-object vs raw frame).
Output data keys
----------------
``session`` the resolved :class:`~pycsamt.iot.FieldSession`
``status`` :class:`~pycsamt.iot.MonitoringStatus`
``status_table`` one-row monitoring-status table
``telemetry_summary`` packet counts by device and topic
``packet_table`` full telemetry packet table
``station_table`` registered stations
``pipeline_input`` acquisition hand-off dict for the processing flow
``deployment_table`` device-capability table (when ``deployment`` given)
``sync_table`` clock-sync table (when ``sync_references`` given)
``power_table`` energy-budget table (when ``energy_configs`` given)
``manifest`` :class:`~pycsamt.iot.AcquisitionManifest` (optional)
``manifest_path`` written manifest path (optional)
``signature`` manifest HMAC signature dict (when ``sign_key`` given)
``level`` monitoring level string (``ok`` / ``warn`` / ``critical``)
``issues`` list of issue strings
``n_packets`` / ``n_stations`` / ``n_devices`` : int
``figures`` dict of matplotlib Figure objects
``figure_paths`` dict of saved figure paths (when ``output_dir`` set)
Examples
--------
>>> agent = IoTFieldAgent()
>>> res = agent.execute({"packets": packets, "output_dir": "/out/iot"})
>>> res["level"]
'warn'
>>> res["status"].issues
['battery_min_v below 11.5 V on 2 device(s)']
>>> res["figures"]["dashboard"]
<Figure ...>
"""
SYSTEM_PROMPT = _SYSTEM_PROMPT
def __init__(
self,
*,
api_key: str | None = None,
model: str | None = None,
llm_provider: str = "claude",
method: str | None = None,
write_manifest: bool = False,
) -> None:
super().__init__(
"IoTFieldAgent",
api_key=api_key,
model=model,
llm_provider=llm_provider,
section_preset="pseudosection",
)
self.method = method
self.write_manifest = write_manifest
# ── main entry point ──────────────────────────────────────────────────────
[docs]
def execute(self, input_data: dict[str, Any]) -> AgentResult:
self._last_cost = 0.0
t0 = time.time()
warnings: list[str] = []
api = input_data.get("api")
now = input_data.get("now")
# ── resolve a FieldSession from whatever the caller supplied ──────────
session, src = self._resolve_session(input_data, warnings)
if session is None:
return AgentResult.failed(
"No acquisition source: provide 'session', 'packets', or an "
"EDI 'path'/'edis'/'sites'.",
hint="e.g. execute({'packets': [...]}) or "
"execute({'path': '/data/EDIs'}).",
elapsed=time.time() - t0,
)
n_packets = session.n_packets
n_stations = session.n_stations
n_devices = session.n_devices
data: dict[str, Any] = {
"session": session,
"source": src,
"n_packets": n_packets,
"n_stations": n_stations,
"n_devices": n_devices,
}
# ── monitoring status ─────────────────────────────────────────────────
status = None
try:
status = session.assess(now=now)
data["status"] = status
data["level"] = (
status.level.value
if hasattr(status.level, "value")
else str(status.level)
)
data["issues"] = list(status.issues)
except Exception as exc: # noqa: BLE001
warnings.append(f"assess: {exc}")
data["level"] = "unknown"
data["issues"] = []
# ── telemetry / station / pipeline tables ─────────────────────────────
from ..iot import (
monitoring_status_table,
packet_table,
telemetry_summary,
)
if status is not None:
self._try(
warnings,
"monitoring_status_table",
lambda: data.__setitem__(
"status_table", monitoring_status_table(status, api=api)
),
)
if n_packets:
self._try(
warnings,
"telemetry_summary",
lambda: data.__setitem__(
"telemetry_summary",
telemetry_summary(session.packets, api=api),
),
)
self._try(
warnings,
"packet_table",
lambda: data.__setitem__(
"packet_table", packet_table(session.packets, api=api)
),
)
self._try(
warnings,
"station_table",
lambda: data.__setitem__(
"station_table", session.station_table(api=api)
),
)
self._try(
warnings,
"to_pipeline_input",
lambda: data.__setitem__(
"pipeline_input", session.to_pipeline_input()
),
)
# ── optional deployment capability table ──────────────────────────────
deployment = input_data.get("deployment")
if deployment is not None:
from ..iot import deployment_report
self._try(
warnings,
"deployment_report",
lambda: data.__setitem__(
"deployment_table", deployment_report(deployment, api=api)
),
)
# ── optional clock-sync summary ───────────────────────────────────────
sync_refs = input_data.get("sync_references")
if sync_refs:
from ..iot import batch_assess_sync
self._try(
warnings,
"batch_assess_sync",
lambda: data.__setitem__(
"sync_table",
batch_assess_sync(
sync_refs, config=session.sync_config, api=api
),
),
)
# ── optional power-budget summary ─────────────────────────────────────
energy_cfgs = input_data.get("energy_configs")
if energy_cfgs:
from ..iot import estimate_deployment_energy
self._try(
warnings,
"estimate_deployment_energy",
lambda: data.__setitem__(
"power_table",
estimate_deployment_energy(energy_cfgs, api=api),
),
)
# ── optional acquisition manifest (provenance) ────────────────────────
self._maybe_manifest(session, input_data, data, warnings)
# ── figures ───────────────────────────────────────────────────────────
if input_data.get("figures", True):
self._render_figures(
session,
input_data.get("output_dir"),
data,
warnings,
now=now,
)
# ── LLM field-ops interpretation ──────────────────────────────────────
interp = self._interpret(status, data, warnings)
# ── assemble result ───────────────────────────────────────────────────
level = data.get("level", "unknown")
if level == "ok":
result_status = "success"
elif level == "unknown":
result_status = "needs_review"
else: # warn / critical
result_status = "needs_review"
n_fig = len(data.get("figures", {}))
n_issue = len(data.get("issues", []))
summary = (
f"IoT field assessment: level={level}, "
f"{n_packets} packet(s) across {n_stations} station(s)/"
f"{n_devices} device(s), {n_issue} issue(s), "
f"{n_fig} figure(s)."
)
return AgentResult(
status=result_status,
summary=summary,
data=data,
warnings=warnings,
llm_interpretation=interp,
elapsed_seconds=time.time() - t0,
cost_estimate_usd=self._last_cost,
)
# ── source resolution ─────────────────────────────────────────────────────
def _resolve_session(
self,
input_data: dict[str, Any],
warnings: list[str],
) -> tuple[Any, str]:
"""Return ``(FieldSession | None, source_label)`` from *input_data*."""
from ..iot import FieldSession
survey_id = str(input_data.get("survey_id") or "iot_survey")
# 1. an explicit session (object or serialised mapping)
session = input_data.get("session")
if session is not None:
if isinstance(session, FieldSession):
return session, "session"
if isinstance(session, dict):
try:
return FieldSession.from_dict(session), "session_dict"
except Exception as exc: # noqa: BLE001
warnings.append(f"FieldSession.from_dict: {exc}")
# 2. a bag of telemetry packets → fresh session
packets = input_data.get("packets")
if packets:
try:
sess = FieldSession(survey_id, method=self.method)
sess.add_packets(packets)
return sess, "packets"
except Exception as exc: # noqa: BLE001
warnings.append(f"build session from packets: {exc}")
# 3. an EDI survey to re-occupy → seeded session (no live packets)
edis = (
input_data.get("edis")
or input_data.get("path")
or input_data.get("sites")
)
if edis is not None:
from ..iot import field_session_from_edis
try:
sess = field_session_from_edis(
edis,
survey_id=survey_id,
operator=input_data.get("operator"),
method=self.method,
)
warnings.append(
"Seeded a re-occupation session from EDIs; no live "
"telemetry, so monitoring reflects planned stations only."
)
return sess, "edis"
except Exception as exc: # noqa: BLE001
warnings.append(f"field_session_from_edis: {exc}")
return None, "none"
# ── manifest ──────────────────────────────────────────────────────────────
def _maybe_manifest(
self,
session: Any,
input_data: dict[str, Any],
data: dict[str, Any],
warnings: list[str],
) -> None:
want = (
self.write_manifest
or bool(input_data.get("manifest"))
or bool(input_data.get("manifest_path"))
or bool(input_data.get("sign_key"))
)
if not want:
return
manifest = None
try:
manifest = session.to_manifest()
data["manifest"] = manifest
except Exception as exc: # noqa: BLE001
warnings.append(f"to_manifest: {exc}")
return
# resolve a write path (explicit, else inside output_dir)
path = input_data.get("manifest_path")
if not path and input_data.get("output_dir"):
import os
path = os.path.join(
input_data["output_dir"],
f"{session.survey_id}_manifest.json",
)
if not path:
return
sign_key = input_data.get("sign_key")
try:
if sign_key:
data["signature"] = manifest.sign(sign_key)
data["manifest_path"] = manifest.write_signed(path)
else:
data["manifest_path"] = manifest.write(path)
except Exception as exc: # noqa: BLE001
warnings.append(f"write manifest: {exc}")
# ── figures ───────────────────────────────────────────────────────────────
def _render_figures(
self,
session: Any,
output_dir: str | None,
data: dict[str, Any],
warnings: list[str],
*,
now: float | None,
) -> None:
from ..iot import (
plot_edge_qc_summary,
plot_field_dashboard,
plot_power_budget,
plot_sync_quality,
)
figures: dict[str, Any] = {}
fig_paths: dict[str, str] = {}
specs = (
("dashboard", lambda: plot_field_dashboard(session, now=now)),
("edge_qc", lambda: plot_edge_qc_summary(session)),
("power_budget", lambda: plot_power_budget(session)),
("sync_quality", lambda: plot_sync_quality(session)),
)
for name, fn in specs:
try:
fig = self._as_fig(fn())
if fig is None:
continue
figures[name] = fig
p = self._save_figure(
fig, output_dir, f"iot_{name}", warnings_list=warnings
)
if p:
fig_paths[name] = p
except Exception as exc: # noqa: BLE001
warnings.append(f"plot {name}: {exc}")
data["figures"] = figures
data["figure_paths"] = fig_paths
# ── LLM interpretation ─────────────────────────────────────────────────────
def _interpret(
self,
status: Any,
data: dict[str, Any],
warnings: list[str],
) -> str | None:
if not self.api_key or status is None:
return None
prompt = (
"IoT field acquisition monitoring summary:\n"
f" Level: {data.get('level')}\n"
f" Packets: {data.get('n_packets')} across "
f"{data.get('n_stations')} station(s) / "
f"{data.get('n_devices')} device(s)\n"
f" Packet success rate: {getattr(status, 'packet_success_rate', '?')}\n"
f" Edge acceptance rate: {getattr(status, 'edge_acceptance_rate', '?')}\n"
f" Mean latency (s): {getattr(status, 'mean_latency_s', '?')}\n"
f" Max gap (s): {getattr(status, 'max_gap_s', '?')}\n"
f" Min battery (V): {getattr(status, 'battery_min_v', '?')}\n"
f" Max clock offset (ms): {getattr(status, 'clock_offset_max_ms', '?')}\n"
f" Methods: {getattr(status, 'methods', [])}\n"
f" Issues: {list(getattr(status, 'issues', []))[:6]}\n\n"
"Assess the acquisition health and recommend the single most "
"important field action."
)
try:
return self.query_llm(prompt, max_tokens=260)
except Exception as exc: # noqa: BLE001
warnings.append(f"llm interpretation: {exc}")
return None
# ── small helpers ──────────────────────────────────────────────────────────
@staticmethod
def _try(warnings: list[str], label: str, fn: Any) -> None:
"""Run *fn*, appending ``"<label>: <err>"`` to *warnings* on failure."""
try:
fn()
except Exception as exc: # noqa: BLE001
warnings.append(f"{label}: {exc}")
@staticmethod
def _as_fig(obj: Any) -> Any:
"""Normalise a plot return (Figure or Axes) to a Figure."""
if obj is None:
return None
if hasattr(obj, "get_figure"):
fig = obj.get_figure()
if fig is not None:
return fig
return obj
__all__ = ["IoTFieldAgent"]