Source code for pycsamt.iot.monitoring

from __future__ import annotations

from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from enum import Enum
from typing import (
    Any,
)

import numpy as np
import pandas as pd

from ..api.property import MetadataMixin, PyCSAMTObject
from ..api.view import maybe_wrap_frame
from .core import PacketKind, TelemetryPacket


[docs] class EMMethod(str, Enum): """Electromagnetic survey methods recognised by IoT monitoring.""" AMT = "amt" MT = "mt" CSAMT = "csamt" CSEM = "csem" TDEM = "tdem" TEM = "tem" UNKNOWN = "unknown"
[docs] class MonitoringLevel(str, Enum): """Overall status level for a monitored telemetry stream.""" OK = "ok" WARNING = "warning" CRITICAL = "critical" NO_DATA = "no_data"
def _normalise_enum(value: Any, enum_cls: type[Enum], name: str) -> Enum: if isinstance(value, enum_cls): return value text = str(value).strip().lower() for item in enum_cls: if text in {item.value, item.name.lower()}: return item allowed = ", ".join(item.value for item in enum_cls) raise ValueError(f"{name} must be one of: {allowed}.") def _as_probability(value: Any, name: str) -> float: out = float(value) if not 0.0 <= out <= 1.0: raise ValueError(f"{name} must be between 0 and 1.") return out def _as_optional_positive(value: Any, name: str) -> float | None: if value is None: return None out = float(value) if out <= 0: raise ValueError(f"{name} must be positive.") return out def _payload(packet: TelemetryPacket | Mapping[str, Any]) -> dict[str, Any]: if isinstance(packet, TelemetryPacket): return dict(packet.payload or {}) payload = dict(packet).get("payload", {}) return dict(payload or {}) def _packet_dict( packet: TelemetryPacket | Mapping[str, Any], ) -> dict[str, Any]: if isinstance(packet, TelemetryPacket): return packet.as_dict() out = dict(packet) out.setdefault("payload", dict(out.get("payload") or {})) out.setdefault("kind", str(out.get("kind", "")) or PacketKind.DATA.value) out.setdefault("qos", int(out.get("qos", 0))) out.setdefault("retained", bool(out.get("retained", False))) return out def _payload_value( payload: Mapping[str, Any], keys: Iterable[str], default: Any = None, ) -> Any: for key in keys: if key in payload and payload[key] is not None: return payload[key] return default def _as_float_or_nan(value: Any) -> float: try: return float(value) except Exception: return float("nan") def _safe_mean(values: Iterable[Any]) -> float: arr = np.asarray([_as_float_or_nan(v) for v in values], dtype=float) arr = arr[np.isfinite(arr)] return float(np.mean(arr)) if arr.size else float("nan") def _safe_max(values: Iterable[Any]) -> float: arr = np.asarray([_as_float_or_nan(v) for v in values], dtype=float) arr = arr[np.isfinite(arr)] return float(np.max(arr)) if arr.size else float("nan") def _safe_min(values: Iterable[Any]) -> float: arr = np.asarray([_as_float_or_nan(v) for v in values], dtype=float) arr = arr[np.isfinite(arr)] return float(np.min(arr)) if arr.size else float("nan") def _method_from_payload(payload: Mapping[str, Any]) -> EMMethod: value = _payload_value(payload, ("method", "survey_method", "em_method")) if value is None: return EMMethod.UNKNOWN try: return _normalise_enum(value, EMMethod, "method") # type: ignore[return-value] except ValueError: return EMMethod.UNKNOWN
[docs] @dataclass class MonitoringConfig(PyCSAMTObject, MetadataMixin): """Thresholds used to monitor AMT/MT/CSAMT telemetry streams.""" method: EMMethod | str = EMMethod.UNKNOWN expected_interval_s: float | None = None max_latency_s: float | None = 30.0 max_gap_s: float | None = None min_packet_success_rate: float = 0.95 min_edge_acceptance_rate: float = 0.85 min_battery_v: float | None = None max_clock_offset_ms: float | None = None frequency_band_hz: tuple[float, float] | None = None required_channels: list[str] = field(default_factory=list) metadata: dict[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: self.validate()
[docs] @classmethod def for_method( cls, method: EMMethod | str, **overrides: Any ) -> MonitoringConfig: """Build a config seeded with a method's canonical defaults. The expected frequency band and required channels are taken from the method's :class:`~pycsamt.iot.methods.MethodProfile`, so the method-mismatch, missing-channel, and out-of-band checks in :class:`TelemetryMonitor` become method-aware without any manual threshold tuning. Any keyword *overrides* win over the defaults. Example ------- >>> cfg = MonitoringConfig.for_method("csamt", min_battery_v=11.5) >>> cfg.required_channels ['ex', 'ey', 'hx', 'hy'] """ from .methods import method_profile profile = method_profile(method) params: dict[str, Any] = dict( method=profile.method, frequency_band_hz=profile.frequency_band_hz, required_channels=list(profile.required_channels), ) params.update(overrides) return cls(**params)
[docs] def validate(self) -> None: """Validate and normalise monitoring thresholds.""" self.method = _normalise_enum(self.method, EMMethod, "method") self.expected_interval_s = _as_optional_positive( self.expected_interval_s, "expected_interval_s", ) self.max_latency_s = _as_optional_positive( self.max_latency_s, "max_latency_s", ) self.max_gap_s = _as_optional_positive(self.max_gap_s, "max_gap_s") self.min_packet_success_rate = _as_probability( self.min_packet_success_rate, "min_packet_success_rate", ) self.min_edge_acceptance_rate = _as_probability( self.min_edge_acceptance_rate, "min_edge_acceptance_rate", ) self.min_battery_v = _as_optional_positive( self.min_battery_v, "min_battery_v", ) self.max_clock_offset_ms = _as_optional_positive( self.max_clock_offset_ms, "max_clock_offset_ms", ) if self.frequency_band_hz is not None: lo, hi = [float(v) for v in self.frequency_band_hz] if lo <= 0 or hi <= 0 or lo > hi: raise ValueError( "frequency_band_hz must be positive and ordered." ) self.frequency_band_hz = (lo, hi) self.required_channels = [ str(ch).strip().lower() for ch in list(self.required_channels or []) ] if any(not ch for ch in self.required_channels): raise ValueError("required_channels cannot contain empty labels.") if not isinstance(self.metadata, dict): self.metadata = dict(self.metadata or {})
[docs] @dataclass class MonitoringStatus(PyCSAMTObject): """Status returned by :class:`TelemetryMonitor`.""" level: MonitoringLevel | str n_packet: int packet_success_rate: float edge_acceptance_rate: float mean_latency_s: float max_gap_s: float battery_min_v: float clock_offset_max_ms: float methods: list[str] = field(default_factory=list) stations: list[str] = field(default_factory=list) channels: list[str] = field(default_factory=list) frequency_min_hz: float = float("nan") frequency_max_hz: float = float("nan") issues: list[str] = field(default_factory=list) def __post_init__(self) -> None: self.validate()
[docs] def validate(self) -> None: """Validate and normalise status fields.""" self.level = _normalise_enum(self.level, MonitoringLevel, "level") self.n_packet = int(self.n_packet) if self.n_packet < 0: raise ValueError("n_packet must be >= 0.") self.packet_success_rate = _as_probability( self.packet_success_rate, "packet_success_rate", ) self.edge_acceptance_rate = _as_probability( self.edge_acceptance_rate, "edge_acceptance_rate", ) self.mean_latency_s = float(self.mean_latency_s) self.max_gap_s = float(self.max_gap_s) self.battery_min_v = float(self.battery_min_v) self.clock_offset_max_ms = float(self.clock_offset_max_ms) self.methods = sorted({str(v).lower() for v in self.methods if v}) self.stations = sorted({str(v) for v in self.stations if v}) self.channels = sorted({str(v).lower() for v in self.channels if v}) self.frequency_min_hz = float(self.frequency_min_hz) self.frequency_max_hz = float(self.frequency_max_hz) self.issues = [str(issue) for issue in list(self.issues or [])]
[docs] @property def ok(self) -> bool: """Return whether the monitored stream is acceptable.""" return self.level == MonitoringLevel.OK
[docs] def as_dict(self) -> dict[str, Any]: """Return a flat serialisable status dictionary.""" return dict( level=self.level.value if isinstance(self.level, MonitoringLevel) else str(self.level), n_packet=self.n_packet, packet_success_rate=self.packet_success_rate, edge_acceptance_rate=self.edge_acceptance_rate, mean_latency_s=self.mean_latency_s, max_gap_s=self.max_gap_s, battery_min_v=self.battery_min_v, clock_offset_max_ms=self.clock_offset_max_ms, methods=";".join(self.methods), stations=";".join(self.stations), channels=";".join(self.channels), frequency_min_hz=self.frequency_min_hz, frequency_max_hz=self.frequency_max_hz, issues=";".join(self.issues), )
[docs] class TelemetryMonitor(PyCSAMTObject): """Monitor field telemetry from AMT, MT, CSAMT, and related surveys.""" def __init__(self, config: MonitoringConfig | None = None) -> None: self.config = config or MonitoringConfig() self.config.validate()
[docs] def assess( self, packets: Iterable[TelemetryPacket | Mapping[str, Any]], *, now: float | None = None, ) -> MonitoringStatus: """Assess a telemetry packet stream against configured thresholds.""" self.config.validate() rows = [_packet_dict(packet) for packet in packets] if not rows: return MonitoringStatus( level=MonitoringLevel.NO_DATA, n_packet=0, packet_success_rate=0.0, edge_acceptance_rate=0.0, mean_latency_s=float("nan"), max_gap_s=float("nan"), battery_min_v=float("nan"), clock_offset_max_ms=float("nan"), issues=["no_packets"], ) enriched = [self._enrich_row(row, now=now) for row in rows] issues = self._issues(enriched) level = self._level(issues) return MonitoringStatus( level=level, n_packet=len(enriched), packet_success_rate=self._packet_success_rate(enriched), edge_acceptance_rate=self._edge_acceptance_rate(enriched), mean_latency_s=_safe_mean( row.get("latency_s") for row in enriched ), max_gap_s=self._max_gap(enriched), battery_min_v=_safe_min(row.get("battery_v") for row in enriched), clock_offset_max_ms=_safe_max( abs(_as_float_or_nan(row.get("clock_offset_ms"))) for row in enriched ), methods=[ str(row.get("method", EMMethod.UNKNOWN.value)) for row in enriched ], stations=[ str(row.get("station")) for row in enriched if row.get("station") is not None ], channels=[ ch for row in enriched for ch in list(row.get("channels") or []) ], frequency_min_hz=_safe_min( row.get("frequency_min_hz") for row in enriched ), frequency_max_hz=_safe_max( row.get("frequency_max_hz") for row in enriched ), issues=issues, )
[docs] def table( self, packets: Iterable[TelemetryPacket | Mapping[str, Any]], *, now: float | None = None, api: bool | None = None, ) -> Any: """Return enriched packet rows used by the monitor.""" rows = [ self._enrich_row(_packet_dict(packet), now=now) for packet in packets ] df = pd.DataFrame.from_records(rows) return maybe_wrap_frame( df, api=api, name="iot_monitoring_packets", kind="iot.monitoring.packets", source=packets, description="Telemetry packets enriched with EM survey metadata.", )
def _enrich_row( self, row: Mapping[str, Any], *, now: float | None, ) -> dict[str, Any]: out = dict(row) payload = dict(out.get("payload") or {}) method = _method_from_payload(payload) station = _payload_value(payload, ("station", "site", "station_id")) channels = _payload_value(payload, ("channels", "channel"), []) if isinstance(channels, str): channels = [channels] channels = [str(ch).strip().lower() for ch in list(channels or [])] freq = _payload_value( payload, ("frequency_hz", "freq_hz", "frequency"), ) band = _payload_value( payload, ("frequency_band_hz", "band_hz", "freq_band_hz"), ) fmin = fmax = _as_float_or_nan(freq) if band is not None: try: f0, f1 = list(band)[:2] fmin, fmax = float(f0), float(f1) except Exception: pass latency = _payload_value(payload, ("latency_s", "latency")) if latency is None and now is not None: latency = float(now) - float(out.get("timestamp")) out.update( method=method.value, station=station, channels=channels, frequency_min_hz=fmin, frequency_max_hz=fmax, latency_s=_as_float_or_nan(latency), battery_v=_as_float_or_nan( _payload_value(payload, ("battery_v", "battery_voltage_v")) ), clock_offset_ms=_as_float_or_nan( _payload_value(payload, ("clock_offset_ms", "offset_ms")) ), ack_ok=bool(_payload_value(payload, ("ack_ok", "ok"), True)), edge_accepted=self._edge_accepted(payload), ) return out def _edge_accepted(self, payload: Mapping[str, Any]) -> bool | None: value = _payload_value( payload, ("accepted", "edge_accepted", "qc_accepted"), ) decision = _payload_value(payload, ("decision", "edge_decision")) if value is not None: return bool(value) if decision is not None: return str(decision).strip().lower() in {"accept", "ok", "pass"} return None def _issues(self, rows: list[Mapping[str, Any]]) -> list[str]: issues: list[str] = [] success = self._packet_success_rate(rows) edge = self._edge_acceptance_rate(rows) max_gap = self._max_gap(rows) mean_latency = _safe_mean(row.get("latency_s") for row in rows) battery_min = _safe_min(row.get("battery_v") for row in rows) clock_max = _safe_max( abs(_as_float_or_nan(row.get("clock_offset_ms"))) for row in rows ) if success < self.config.min_packet_success_rate: issues.append("packet_success_rate_below_threshold") if edge < self.config.min_edge_acceptance_rate: issues.append("edge_acceptance_rate_below_threshold") if self.config.max_gap_s is not None and np.isfinite(max_gap): if max_gap > self.config.max_gap_s: issues.append("packet_gap_above_threshold") if self.config.expected_interval_s is not None and np.isfinite( max_gap ): if max_gap > 2.5 * self.config.expected_interval_s: issues.append("packet_gap_exceeds_expected_interval") if self.config.max_latency_s is not None and np.isfinite( mean_latency ): if mean_latency > self.config.max_latency_s: issues.append("latency_above_threshold") if self.config.min_battery_v is not None and np.isfinite(battery_min): if battery_min < self.config.min_battery_v: issues.append("battery_below_threshold") if ( self.config.max_clock_offset_ms is not None and np.isfinite(clock_max) and clock_max > self.config.max_clock_offset_ms ): issues.append("clock_offset_above_threshold") if self.config.method != EMMethod.UNKNOWN: methods = {str(row.get("method")) for row in rows} if self.config.method.value not in methods: issues.append("method_mismatch") channels = { ch for row in rows for ch in list(row.get("channels") or []) } missing = set(self.config.required_channels) - channels if missing: issues.append("required_channels_missing") if self.config.frequency_band_hz is not None: lo, hi = self.config.frequency_band_hz seen = [ ( _as_float_or_nan(row.get("frequency_min_hz")), _as_float_or_nan(row.get("frequency_max_hz")), ) for row in rows ] in_band = [ f0 >= lo and f1 <= hi for f0, f1 in seen if np.isfinite(f0) and np.isfinite(f1) ] if in_band and not all(in_band): issues.append("frequency_outside_configured_band") return sorted(dict.fromkeys(issues)) def _level(self, issues: list[str]) -> MonitoringLevel: if not issues: return MonitoringLevel.OK critical = { "packet_success_rate_below_threshold", "edge_acceptance_rate_below_threshold", "battery_below_threshold", "clock_offset_above_threshold", "method_mismatch", "required_channels_missing", } if any(issue in critical for issue in issues): return MonitoringLevel.CRITICAL return MonitoringLevel.WARNING def _packet_success_rate(self, rows: list[Mapping[str, Any]]) -> float: if not rows: return 0.0 return float(np.mean([bool(row.get("ack_ok", True)) for row in rows])) def _edge_acceptance_rate(self, rows: list[Mapping[str, Any]]) -> float: values = [ row.get("edge_accepted") for row in rows if row.get("edge_accepted") is not None ] if not values: return 1.0 return float(np.mean([bool(value) for value in values])) def _max_gap(self, rows: list[Mapping[str, Any]]) -> float: times = sorted( _as_float_or_nan(row.get("timestamp")) for row in rows if np.isfinite(_as_float_or_nan(row.get("timestamp"))) ) if len(times) < 2: return 0.0 return float(np.max(np.diff(times)))
[docs] def packet_table( packets: Iterable[TelemetryPacket | Mapping[str, Any]], *, api: bool | None = None, ) -> Any: """Return telemetry packets as a pyCSAMT table.""" rows: list[dict] = [] for packet in packets: row = _packet_dict(packet) row["payload_keys"] = ";".join(sorted(row["payload"].keys())) rows.append(row) df = pd.DataFrame.from_records(rows) return maybe_wrap_frame( df, api=api, name="iot_packet_table", kind="iot.telemetry.packets", source=packets, description="Recorded IoT telemetry packets.", )
[docs] def telemetry_summary( packets: Iterable[TelemetryPacket | Mapping[str, Any]], *, api: bool | None = None, ) -> Any: """Summarise telemetry packet counts by device and topic.""" rows = [_packet_dict(p) for p in packets] if not rows: df = pd.DataFrame(columns=["device_id", "topic", "n_packet"]) else: df0 = pd.DataFrame.from_records(rows) df = ( df0.groupby(["device_id", "topic"], dropna=False) .size() .reset_index(name="n_packet") ) return maybe_wrap_frame( df, api=api, name="iot_telemetry_summary", kind="iot.telemetry.summary", source=packets, description="Telemetry packet counts by device and topic.", )
[docs] def monitoring_status_table( statuses: MonitoringStatus | Iterable[MonitoringStatus], *, api: bool | None = None, ) -> Any: """Return monitoring statuses as a pyCSAMT table.""" rows = ( [statuses] if isinstance(statuses, MonitoringStatus) else list(statuses) ) df = pd.DataFrame.from_records([status.as_dict() for status in rows]) return maybe_wrap_frame( df, api=api, name="iot_monitoring_status", kind="iot.monitoring.status", source=statuses, description="IoT monitoring status for EM field telemetry.", )
[docs] def assess_telemetry( packets: Iterable[TelemetryPacket | Mapping[str, Any]], *, config: MonitoringConfig | None = None, now: float | None = None, ) -> MonitoringStatus: """Convenience wrapper around :class:`TelemetryMonitor`.""" return TelemetryMonitor(config).assess(packets, now=now)
__all__ = [ "EMMethod", "MonitoringConfig", "MonitoringLevel", "MonitoringStatus", "TelemetryMonitor", "assess_telemetry", "monitoring_status_table", "packet_table", "telemetry_summary", ]