Source code for pycsamt.iot.plot

"""Visualisation helpers for IoT-enabled field acquisition.

The plotting layer turns telemetry, station metadata, edge QC, power, and
clock information into compact operational figures. These figures are not
geophysical inversions; they are acquisition dashboards that help explain
what happened before EDI/impedance processing.
"""

from __future__ import annotations

import math
from collections import defaultdict
from collections.abc import Iterable, Mapping
from typing import (
    Any,
)

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.lines import Line2D

from .core import PacketKind, TelemetryPacket
from .edge import EdgeProcessingResult
from .power import (
    EnergyConfig,
    EnergyEstimate,
    estimate_energy_budget,
)
from .session import FieldSession
from .sync import SyncStatus

__all__ = [
    "plot_field_dashboard",
    "plot_edge_qc_summary",
    "plot_power_budget",
    "plot_sync_quality",
]


_LEVEL_COLORS = {
    "ok": "#2ca25f",
    "sustaining": "#2ca25f",
    "excellent": "#2ca25f",
    "good": "#74c476",
    "warning": "#fdae6b",
    "fair": "#fdae6b",
    "critical": "#de2d26",
    "poor": "#de2d26",
    "reject": "#de2d26",
    "no_data": "#9e9e9e",
    "unknown": "#9e9e9e",
}


[docs] def plot_field_dashboard( session: FieldSession | Mapping[str, Any], *, now: float | None = None, figsize: tuple[float, float] = (13.0, 8.0), station_axis: str = "auto", title: str | None = None, output_path: str | None = None, close: bool = False, ) -> Any: """Plot a compact IoT acquisition dashboard. Parameters ---------- session : FieldSession or mapping Field session, or a mapping produced by :meth:`pycsamt.iot.FieldSession.to_dict`. now : float, optional Reference timestamp used for live latency/status calculations. figsize : tuple Matplotlib figure size in inches. station_axis : {"auto", "profile", "map"} Station layout. ``"profile"`` uses profile chainage/index; ``"map"`` uses longitude/latitude when all stations have coordinates; ``"auto"`` chooses map only when coordinates exist. title : str, optional Figure title. Defaults to the survey id. output_path : str, optional If given, save the figure to this path. close : bool Close the figure before returning it. Useful for batch report generation after saving. Returns ------- matplotlib.figure.Figure The dashboard figure. The computed data are also attached as ``fig.pycsamt_iot_dashboard`` for reproducible report workflows. """ sess = _as_session(session) dashboard = _dashboard_data(sess, now=now) fig, axes = plt.subplots( 2, 2, figsize=figsize, constrained_layout=True, ) fig.suptitle( title or f"IoT field dashboard: {sess.survey_id}", fontsize=14 ) _plot_station_panel(axes[0, 0], dashboard, station_axis=station_axis) _plot_acceptance_panel(axes[0, 1], dashboard) _plot_operations_panel(axes[1, 0], dashboard) _plot_timeline_panel(axes[1, 1], dashboard) handles = [ Line2D([0], [0], marker="o", linestyle="", color=color, label=label) for label, color in [ ("ok", _LEVEL_COLORS["ok"]), ("warning", _LEVEL_COLORS["warning"]), ("critical/reject", _LEVEL_COLORS["critical"]), ("unknown", _LEVEL_COLORS["unknown"]), ] ] fig.legend(handles=handles, loc="lower center", ncol=4, frameon=False) fig.pycsamt_iot_dashboard = dashboard # type: ignore[attr-defined] if output_path: fig.savefig(output_path, dpi=150, bbox_inches="tight") if close: plt.close(fig) return fig
[docs] def plot_edge_qc_summary( edge: EdgeProcessingResult | TelemetryPacket | FieldSession | Mapping[str, Any] | Iterable[EdgeProcessingResult | TelemetryPacket | Mapping[str, Any]], *, figsize: tuple[float, float] = (12.0, 7.5), title: str = "Edge QC summary", output_path: str | None = None, close: bool = False, ) -> Any: """Plot edge quality-control decisions and channel metrics. Parameters ---------- edge : EdgeProcessingResult, TelemetryPacket, FieldSession, mapping, or iterable Edge-processing result(s), QC telemetry packet(s), a field session, or serialised mappings. Session inputs are filtered to QC packets. figsize : tuple Matplotlib figure size in inches. title : str Figure title. output_path : str, optional If given, save the figure to this path. close : bool Close the figure before returning it. Returns ------- matplotlib.figure.Figure The QC summary figure. The normalised rows are attached as ``fig.pycsamt_iot_edge_qc``. """ rows = _edge_qc_rows(edge) fig, axes = plt.subplots( 2, 2, figsize=figsize, constrained_layout=True, ) fig.suptitle(title, fontsize=14) _plot_qc_decisions(axes[0, 0], rows) _plot_qc_coverage(axes[0, 1], rows) _plot_qc_spikes(axes[1, 0], rows) _plot_qc_reasons(axes[1, 1], rows) fig.pycsamt_iot_edge_qc = rows # type: ignore[attr-defined] if output_path: fig.savefig(output_path, dpi=150, bbox_inches="tight") if close: plt.close(fig) return fig
[docs] def plot_power_budget( power: EnergyConfig | EnergyEstimate | TelemetryPacket | FieldSession | Mapping[str, Any] | Iterable[ EnergyConfig | EnergyEstimate | TelemetryPacket | Mapping[str, Any] ], *, figsize: tuple[float, float] = (12.0, 7.5), title: str = "IoT power budget", output_path: str | None = None, close: bool = False, ) -> Any: """Plot IoT energy budget, runtime, and power-state summaries. Parameters ---------- power : EnergyConfig, EnergyEstimate, TelemetryPacket, FieldSession, mapping, or iterable Power budget input(s). ``EnergyConfig`` objects are estimated before plotting. Session inputs are filtered to ``PacketKind.POWER`` packets. figsize : tuple Matplotlib figure size in inches. title : str Figure title. output_path : str, optional If given, save the figure to this path. close : bool Close the figure before returning it. Returns ------- matplotlib.figure.Figure The power-budget figure. Normalised rows are attached as ``fig.pycsamt_iot_power_budget``. """ rows = _power_rows(power) fig, axes = plt.subplots( 2, 2, figsize=figsize, constrained_layout=True, ) fig.suptitle(title, fontsize=14) _plot_power_load_harvest(axes[0, 0], rows) _plot_power_runtime(axes[0, 1], rows) _plot_power_breakdown(axes[1, 0], rows) _plot_power_states(axes[1, 1], rows) fig.pycsamt_iot_power_budget = rows # type: ignore[attr-defined] if output_path: fig.savefig(output_path, dpi=150, bbox_inches="tight") if close: plt.close(fig) return fig
[docs] def plot_sync_quality( sync: SyncStatus | TelemetryPacket | FieldSession | Mapping[str, Any] | Iterable[SyncStatus | TelemetryPacket | Mapping[str, Any]], *, figsize: tuple[float, float] = (12.0, 7.5), title: str = "Clock synchronisation quality", tolerance_ms: float | None = 1.0, max_drift_ppm: float | None = None, max_jitter_ms: float | None = None, output_path: str | None = None, close: bool = False, ) -> Any: """Plot clock offset, drift, jitter, GPS lock, and quality grades. Parameters ---------- sync : SyncStatus, TelemetryPacket, FieldSession, mapping, or iterable Synchronisation status input(s). Session inputs are filtered to ``PacketKind.SYNC`` packets. figsize : tuple Matplotlib figure size in inches. title : str Figure title. tolerance_ms, max_drift_ppm, max_jitter_ms : float, optional Optional visual threshold lines. output_path : str, optional If given, save the figure to this path. close : bool Close the figure before returning it. Returns ------- matplotlib.figure.Figure The synchronisation figure. Normalised rows are attached as ``fig.pycsamt_iot_sync_quality``. """ rows = _sync_rows(sync) fig, axes = plt.subplots( 2, 2, figsize=figsize, constrained_layout=True, ) fig.suptitle(title, fontsize=14) _plot_sync_offset(axes[0, 0], rows, tolerance_ms=tolerance_ms) _plot_sync_drift_jitter( axes[0, 1], rows, max_drift_ppm=max_drift_ppm, max_jitter_ms=max_jitter_ms, ) _plot_sync_quality_counts(axes[1, 0], rows) _plot_sync_reference_points(axes[1, 1], rows) fig.pycsamt_iot_sync_quality = rows # type: ignore[attr-defined] if output_path: fig.savefig(output_path, dpi=150, bbox_inches="tight") if close: plt.close(fig) return fig
def _as_session(session: FieldSession | Mapping[str, Any]) -> FieldSession: if isinstance(session, FieldSession): return session if isinstance(session, Mapping): return FieldSession.from_dict(session) raise TypeError("session must be a FieldSession or session mapping.") def _sync_rows(sync: Any) -> list[dict[str, Any]]: items = _sync_items(sync) rows: list[dict[str, Any]] = [] for index, item in enumerate(items): rows.append(_sync_row(item, index=index)) return rows def _sync_items(sync: Any) -> list[Any]: if isinstance(sync, FieldSession): return [ packet for packet in sync.packets if packet.kind is PacketKind.SYNC ] if isinstance(sync, (SyncStatus, TelemetryPacket)): return [sync] if isinstance(sync, Mapping): if "packets" in sync and "survey_id" in sync: return _sync_items(FieldSession.from_dict(sync)) if "payload" in sync: return [TelemetryPacket(**dict(sync))] return [SyncStatus(**dict(sync))] if isinstance(sync, Iterable) and not isinstance(sync, (str, bytes)): out: list[Any] = [] for item in sync: out.extend(_sync_items(item)) return out raise TypeError( "sync must be a SyncStatus, sync packet, FieldSession, mapping, " "or iterable of those objects." ) def _sync_row(item: Any, *, index: int) -> dict[str, Any]: if isinstance(item, SyncStatus): row = item.as_dict() elif isinstance(item, TelemetryPacket): payload = dict(item.payload or {}) row = dict(payload) row.setdefault("device_id", item.device_id) else: row = dict(item) row.setdefault("device_id", f"device-{index + 1}") row.setdefault("reference", "gps") row.setdefault("quality", "unknown") row["offset_ms"] = _float_or_nan( row.get("offset_ms", row.get("clock_offset_ms")) ) row["drift_ppm"] = _float_or_nan(row.get("drift_ppm")) row["jitter_ms"] = _float_or_nan(row.get("jitter_ms")) row["n_reference_points"] = int( _finite_or_zero(row.get("n_reference_points")) ) row["gps_lock"] = _optional_bool(row.get("gps_lock")) row["within_tolerance"] = _optional_bool(row.get("within_tolerance")) row["quality"] = str(row.get("quality") or "unknown").lower() row["reference"] = str(row.get("reference") or "gps").lower() return row def _plot_sync_offset( ax: Any, rows: list[Mapping[str, Any]], *, tolerance_ms: float | None, ) -> None: ax.set_title("Clock offset") if not rows: _empty_panel(ax, "No sync data") return labels = _sync_labels(rows) values = [_float_or_nan(row.get("offset_ms")) for row in rows] if not any(math.isfinite(v) for v in values): _empty_panel(ax, "No offset data") return x = np.arange(len(rows), dtype=float) colors = [_sync_quality_color(str(row.get("quality"))) for row in rows] plot_values = [v if math.isfinite(v) else 0.0 for v in values] ax.bar(x, plot_values, color=colors, edgecolor="black", linewidth=0.4) if tolerance_ms is not None: tol = abs(float(tolerance_ms)) ax.axhline(tol, color="#de2d26", lw=1.0, ls="--") ax.axhline(-tol, color="#de2d26", lw=1.0, ls="--") ax.axhline(0.0, color="#333333", lw=0.8) ax.set_ylabel("Offset (ms)") ax.set_xticks(x) ax.set_xticklabels(labels, rotation=45, ha="right") ax.grid(True, axis="y", alpha=0.25) def _plot_sync_drift_jitter( ax: Any, rows: list[Mapping[str, Any]], *, max_drift_ppm: float | None, max_jitter_ms: float | None, ) -> None: ax.set_title("Drift and jitter") if not rows: _empty_panel(ax, "No sync data") return labels = _sync_labels(rows) x = np.arange(len(rows), dtype=float) width = 0.38 drift = [_finite_or_zero(row.get("drift_ppm")) for row in rows] jitter = [_finite_or_zero(row.get("jitter_ms")) for row in rows] if not any(v != 0 for v in drift + jitter): _empty_panel(ax, "No drift/jitter data") return ax.bar(x - width / 2, drift, width, label="drift ppm", color="#756bb1") ax.bar(x + width / 2, jitter, width, label="jitter ms", color="#6baed6") if max_drift_ppm is not None: ax.axhline(float(max_drift_ppm), color="#756bb1", lw=1.0, ls="--") ax.axhline(-float(max_drift_ppm), color="#756bb1", lw=1.0, ls="--") if max_jitter_ms is not None: ax.axhline(float(max_jitter_ms), color="#6baed6", lw=1.0, ls=":") ax.set_xticks(x) ax.set_xticklabels(labels, rotation=45, ha="right") ax.set_ylabel("ppm / ms") ax.legend(frameon=False) ax.grid(True, axis="y", alpha=0.25) def _plot_sync_quality_counts(ax: Any, rows: list[Mapping[str, Any]]) -> None: ax.set_title("Quality grades") if not rows: _empty_panel(ax, "No quality data") return grades = [str(row.get("quality", "unknown")) for row in rows] counts = {grade: grades.count(grade) for grade in sorted(set(grades))} labels = list(counts.keys()) colors = [_sync_quality_color(label) for label in labels] ax.bar(labels, list(counts.values()), color=colors, edgecolor="black") ax.set_ylabel("Device count") ax.tick_params(axis="x", rotation=25) ax.grid(True, axis="y", alpha=0.25) def _plot_sync_reference_points( ax: Any, rows: list[Mapping[str, Any]], ) -> None: ax.set_title("Reference support and GPS lock") if not rows: _empty_panel(ax, "No reference data") return labels = _sync_labels(rows) points = [_finite_or_zero(row.get("n_reference_points")) for row in rows] gps = [row.get("gps_lock") for row in rows] colors = [ _LEVEL_COLORS["ok"] if value is True else _LEVEL_COLORS["critical"] if value is False else _LEVEL_COLORS["unknown"] for value in gps ] x = np.arange(len(rows), dtype=float) ax.bar(x, points, color=colors, edgecolor="black", linewidth=0.4) ax.set_xticks(x) ax.set_xticklabels(labels, rotation=45, ha="right") ax.set_ylabel("Reference points") ax.grid(True, axis="y", alpha=0.25) handles = [ Line2D( [0], [0], marker="s", linestyle="", color=_LEVEL_COLORS["ok"], label="GPS lock", ), Line2D( [0], [0], marker="s", linestyle="", color=_LEVEL_COLORS["critical"], label="GPS lost", ), Line2D( [0], [0], marker="s", linestyle="", color=_LEVEL_COLORS["unknown"], label="unknown", ), ] ax.legend(handles=handles, frameon=False, fontsize=8, loc="best") def _sync_labels(rows: list[Mapping[str, Any]]) -> list[str]: return [ str(row.get("device_id") or f"device-{idx + 1}") for idx, row in enumerate(rows) ] def _sync_quality_color(quality: str) -> str: quality = str(quality or "unknown").lower() return _LEVEL_COLORS.get(quality, "#9e9e9e") def _optional_bool(value: Any) -> bool | None: if value is None or value == "": return None if isinstance(value, str): text = value.strip().lower() if text in {"1", "true", "yes", "y", "ok", "locked"}: return True if text in {"0", "false", "no", "n", "lost", "none"}: return False return bool(value) def _power_rows(power: Any) -> list[dict[str, Any]]: items = _power_items(power) rows: list[dict[str, Any]] = [] for index, item in enumerate(items): rows.append(_power_row(item, index=index)) return rows def _power_items(power: Any) -> list[Any]: if isinstance(power, FieldSession): return [ packet for packet in power.packets if packet.kind is PacketKind.POWER ] if isinstance(power, (EnergyConfig, EnergyEstimate, TelemetryPacket)): return [power] if isinstance(power, Mapping): if "packets" in power and "survey_id" in power: return _power_items(FieldSession.from_dict(power)) if "payload" in power: return [TelemetryPacket(**dict(power))] if "battery_wh" in power and "active_power_w" in power: return [EnergyConfig(**dict(power))] return [EnergyEstimate(**dict(power))] if isinstance(power, Iterable) and not isinstance(power, (str, bytes)): out: list[Any] = [] for item in power: out.extend(_power_items(item)) return out raise TypeError( "power must be an EnergyConfig, EnergyEstimate, power packet, " "FieldSession, mapping, or iterable of those objects." ) def _power_row(item: Any, *, index: int) -> dict[str, Any]: device_id: str | None = None if isinstance(item, EnergyConfig): device_id = item.device_id estimate = estimate_energy_budget(item) row = estimate.as_dict() elif isinstance(item, EnergyEstimate): row = item.as_dict() elif isinstance(item, TelemetryPacket): device_id = item.device_id row = dict(item.payload or {}) else: row = dict(item) row = dict(row) row.setdefault("device_id", device_id or f"device-{index + 1}") row.setdefault("state", "unknown") row.setdefault("issues", "") for key in ( "average_power_w", "net_wh_per_day", "load_wh_per_day", "harvest_wh_per_day", "usable_battery_wh", "telemetry_wh_per_day", "edge_wh_per_day", "auxiliary_wh_per_day", "reserve_wh", "energy_margin_wh_per_day", ): row[key] = _float_or_nan(row.get(key)) for key in ("runtime_hours", "runtime_days", "autonomy_days_no_harvest"): row[key] = _float_allow_inf(row.get(key)) row["state"] = str(row.get("state") or "unknown").lower() row["issues"] = _split_reasons(row.get("issues")) return row def _plot_power_load_harvest(ax: Any, rows: list[Mapping[str, Any]]) -> None: ax.set_title("Daily load and harvest") if not rows: _empty_panel(ax, "No power data") return labels = _power_labels(rows) x = np.arange(len(rows), dtype=float) width = 0.38 load = [_finite_or_zero(row.get("load_wh_per_day")) for row in rows] harvest = [_finite_or_zero(row.get("harvest_wh_per_day")) for row in rows] ax.bar(x - width / 2, load, width, label="load", color="#756bb1") ax.bar(x + width / 2, harvest, width, label="harvest", color="#74c476") ax.set_xticks(x) ax.set_xticklabels(labels, rotation=45, ha="right") ax.set_ylabel("Wh/day") ax.grid(True, axis="y", alpha=0.25) ax.legend(frameon=False) def _plot_power_runtime(ax: Any, rows: list[Mapping[str, Any]]) -> None: ax.set_title("Runtime and no-harvest autonomy") if not rows: _empty_panel(ax, "No runtime data") return labels = _power_labels(rows) x = np.arange(len(rows), dtype=float) width = 0.38 runtime = [_plot_runtime_value(row.get("runtime_days")) for row in rows] autonomy = [ _plot_runtime_value(row.get("autonomy_days_no_harvest")) for row in rows ] colors = [_power_state_color(str(row.get("state"))) for row in rows] ax.bar(x - width / 2, runtime, width, label="runtime", color=colors) ax.bar( x + width / 2, autonomy, width, label="no-harvest autonomy", color="#9ecae1", ) ax.set_xticks(x) ax.set_xticklabels(labels, rotation=45, ha="right") ax.set_ylabel("Days") ax.grid(True, axis="y", alpha=0.25) ax.legend(frameon=False) for idx, row in enumerate(rows): if math.isinf(_float_or_nan(row.get("runtime_days"))): ax.text( idx - width / 2, runtime[idx], "inf", ha="center", fontsize=8 ) def _plot_power_breakdown(ax: Any, rows: list[Mapping[str, Any]]) -> None: ax.set_title("Daily load breakdown") if not rows: _empty_panel(ax, "No load data") return labels = _power_labels(rows) x = np.arange(len(rows), dtype=float) telemetry = np.asarray( [_finite_or_zero(row.get("telemetry_wh_per_day")) for row in rows] ) edge = np.asarray( [_finite_or_zero(row.get("edge_wh_per_day")) for row in rows] ) aux = np.asarray( [_finite_or_zero(row.get("auxiliary_wh_per_day")) for row in rows] ) load = np.asarray( [_finite_or_zero(row.get("load_wh_per_day")) for row in rows] ) base = np.maximum(load - telemetry - edge - aux, 0.0) bottom = np.zeros(len(rows)) for values, label, color in [ (base, "base", "#bcbddc"), (telemetry, "telemetry", "#6baed6"), (edge, "edge", "#fd8d3c"), (aux, "auxiliary", "#969696"), ]: ax.bar(x, values, bottom=bottom, label=label, color=color) bottom = bottom + values ax.set_xticks(x) ax.set_xticklabels(labels, rotation=45, ha="right") ax.set_ylabel("Wh/day") ax.grid(True, axis="y", alpha=0.25) ax.legend(frameon=False, fontsize=8) def _plot_power_states(ax: Any, rows: list[Mapping[str, Any]]) -> None: ax.set_title("Power states and issues") if not rows: _empty_panel(ax, "No states") return states = [str(row.get("state", "unknown")) for row in rows] counts = {state: states.count(state) for state in sorted(set(states))} labels = list(counts.keys()) colors = [_power_state_color(label) for label in labels] ax.bar(labels, list(counts.values()), color=colors, edgecolor="black") ax.set_ylabel("Device count") ax.tick_params(axis="x", rotation=25) issue_counts: dict[str, int] = defaultdict(int) for row in rows: for issue in row.get("issues") or []: issue_counts[str(issue)] += 1 if issue_counts: text = "\n".join( f"{key}: {value}" for key, value in sorted( issue_counts.items(), key=lambda kv: (-kv[1], kv[0]) )[:5] ) ax.text( 0.98, 0.95, text, transform=ax.transAxes, va="top", ha="right", fontsize=8, bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="#cccccc"), ) ax.grid(True, axis="y", alpha=0.25) def _power_labels(rows: list[Mapping[str, Any]]) -> list[str]: return [ str(row.get("device_id") or f"device-{i + 1}") for i, row in enumerate(rows) ] def _finite_or_zero(value: Any) -> float: out = _float_or_nan(value) return out if math.isfinite(out) else 0.0 def _plot_runtime_value(value: Any) -> float: out = _float_allow_inf(value) if math.isinf(out): return 1.1 * max(1.0, out if math.isfinite(out) else 1.0) return out if math.isfinite(out) else 0.0 def _power_state_color(state: str) -> str: return _LEVEL_COLORS.get(str(state or "unknown").lower(), "#9e9e9e") def _float_allow_inf(value: Any) -> float: try: return float(value) except Exception: return float("nan") def _edge_qc_rows(edge: Any) -> list[dict[str, Any]]: items = _edge_items(edge) rows: list[dict[str, Any]] = [] for index, item in enumerate(items): if isinstance(item, EdgeProcessingResult): rows.extend(_rows_from_edge_result(item, result_index=index)) else: rows.extend(_rows_from_qc_packet(item, result_index=index)) return rows def _edge_items(edge: Any) -> list[Any]: if isinstance(edge, FieldSession): return [ packet for packet in edge.packets if packet.kind is PacketKind.QC ] if isinstance(edge, EdgeProcessingResult): return [edge] if isinstance(edge, TelemetryPacket): return [edge] if isinstance(edge, Mapping): if "packets" in edge and "survey_id" in edge: return _edge_items(FieldSession.from_dict(edge)) if "payload" in edge: return [TelemetryPacket(**dict(edge))] if "metrics" in edge: return [EdgeProcessingResult(**dict(edge))] raise TypeError( "edge mapping must describe a session, packet, or result." ) if isinstance(edge, Iterable) and not isinstance(edge, (str, bytes)): out: list[Any] = [] for item in edge: out.extend(_edge_items(item)) return out raise TypeError( "edge must be an EdgeProcessingResult, QC packet, FieldSession, " "mapping, or iterable of those objects." ) def _rows_from_edge_result( result: EdgeProcessingResult, *, result_index: int, ) -> list[dict[str, Any]]: metrics = dict(result.metrics or {}) base = dict( result_index=result_index, station=None, decision=result.decision.value, accepted=bool(result.accepted), finite_coverage=_float_or_nan(metrics.get("finite_coverage")), spike_fraction=_float_or_nan(metrics.get("spike_fraction_max")), rms=_float_or_nan(metrics.get("rms")), reasons=list(result.reasons or []), warnings=_split_reasons(metrics.get("warnings")), ) rows: list[dict[str, Any]] = [] for channel in result.channels: row = dict(base) row.update( channel=channel.channel, finite_coverage=channel.finite_coverage, spike_fraction=channel.spike_fraction, rms=channel.rms, channel_accepted=channel.accepted, channel_reasons=list(channel.reasons or []), ) rows.append(row) if not rows: row = dict(base) row.update( channel="window", channel_accepted=bool(result.accepted), channel_reasons=list(result.reasons or []), ) rows.append(row) return rows def _rows_from_qc_packet( packet: TelemetryPacket | Mapping[str, Any], *, result_index: int, ) -> list[dict[str, Any]]: pkt = ( packet if isinstance(packet, TelemetryPacket) else TelemetryPacket(**dict(packet)) ) payload = dict(pkt.payload or {}) metrics = dict(payload.get("metrics") or {}) decision = str(payload.get("decision", "") or "").lower() if not decision: accepted = _accepted_from_payload(payload) decision = ( "accept" if accepted is True else "reject" if accepted is False else "unknown" ) accepted = _accepted_from_payload(payload) if accepted is None: accepted = decision in {"accept", "ok", "pass", "warning"} station = _payload_first(payload, "station", "site", "station_id") reasons = _split_reasons(payload.get("reasons")) warnings = _split_reasons( metrics.get("warnings") or payload.get("warnings") ) base = dict( result_index=result_index, station=station, decision=decision, accepted=bool(accepted), finite_coverage=_float_or_nan( metrics.get("finite_coverage", payload.get("finite_coverage")) ), spike_fraction=_float_or_nan( metrics.get("spike_fraction_max", payload.get("spike_fraction")) ), rms=_float_or_nan(metrics.get("rms", payload.get("rms"))), reasons=reasons, warnings=warnings, ) channels = payload.get("channels") rows: list[dict[str, Any]] = [] if ( isinstance(channels, list) and channels and isinstance(channels[0], Mapping) ): for channel in channels: row = dict(base) row.update( channel=str(channel.get("channel", "channel")).lower(), finite_coverage=_float_or_nan( channel.get("finite_coverage", base["finite_coverage"]) ), spike_fraction=_float_or_nan( channel.get("spike_fraction", base["spike_fraction"]) ), rms=_float_or_nan(channel.get("rms", base["rms"])), channel_accepted=bool(channel.get("accepted", accepted)), channel_reasons=_split_reasons(channel.get("reasons")), ) rows.append(row) else: channel_names = ( channels if isinstance(channels, list) else ([channels] if isinstance(channels, str) else ["window"]) ) for channel in channel_names: row = dict(base) row.update( channel=str(channel).lower(), channel_accepted=bool(accepted), channel_reasons=list(reasons), ) rows.append(row) return rows def _split_reasons(value: Any) -> list[str]: if value is None: return [] if isinstance(value, str): return [part for part in value.split(";") if part] if isinstance(value, Iterable) and not isinstance(value, (bytes, str)): return [str(part) for part in value if str(part)] return [str(value)] def _plot_qc_decisions(ax: Any, rows: list[Mapping[str, Any]]) -> None: ax.set_title("QC decisions") if not rows: _empty_panel(ax, "No QC rows") return decisions = [str(row.get("decision", "unknown")) for row in rows] counts = { decision: decisions.count(decision) for decision in sorted(set(decisions)) } labels = list(counts.keys()) colors = [_decision_color(label) for label in labels] ax.bar( labels, list(counts.values()), color=colors, edgecolor="black", linewidth=0.5, ) ax.set_ylabel("Channel/window count") ax.tick_params(axis="x", rotation=25) for idx, value in enumerate(counts.values()): ax.text(idx, value + 0.05, str(value), ha="center", fontsize=8) def _plot_qc_coverage(ax: Any, rows: list[Mapping[str, Any]]) -> None: ax.set_title("Finite coverage by channel") _plot_qc_metric( ax, rows, key="finite_coverage", ylabel="Finite coverage", ylim=(0, 1.05), thresholds=(0.85, 0.95), ) def _plot_qc_spikes(ax: Any, rows: list[Mapping[str, Any]]) -> None: ax.set_title("Spike fraction by channel") _plot_qc_metric( ax, rows, key="spike_fraction", ylabel="Spike fraction", ylim=(0, None), thresholds=(0.05,), high_bad=True, ) def _plot_qc_metric( ax: Any, rows: list[Mapping[str, Any]], *, key: str, ylabel: str, ylim: tuple[float, float | None], thresholds: tuple[float, ...] = (), high_bad: bool = False, ) -> None: values = [_float_or_nan(row.get(key)) for row in rows] if not any(math.isfinite(v) for v in values): _empty_panel(ax, f"No {ylabel.lower()} data") return labels = _qc_labels(rows) plot_values = [v if math.isfinite(v) else 0.0 for v in values] colors = [ _decision_color(str(row.get("decision", "unknown"))) for row in rows ] x = np.arange(len(rows), dtype=float) ax.bar(x, plot_values, color=colors, edgecolor="black", linewidth=0.4) for threshold in thresholds: color = "#de2d26" if high_bad else "#2ca25f" ax.axhline(threshold, color=color, lw=1.0, ls="--") ax.set_ylabel(ylabel) ax.set_xticks(x) ax.set_xticklabels(labels, rotation=45, ha="right") if ylim[1] is None: top = max( max(plot_values) * 1.25, max(thresholds or (0.05,)) * 1.2, 0.1 ) ax.set_ylim(ylim[0], top) else: ax.set_ylim(*ylim) ax.grid(True, axis="y", alpha=0.25) def _plot_qc_reasons(ax: Any, rows: list[Mapping[str, Any]]) -> None: ax.set_title("Reasons and warnings") reason_counts: dict[str, int] = defaultdict(int) for row in rows: for reason in list(row.get("reasons") or []) + list( row.get("channel_reasons") or [] ): reason_counts[str(reason)] += 1 for warning in row.get("warnings") or []: reason_counts[f"warn:{warning}"] += 1 if not reason_counts: _empty_panel(ax, "No rejection reasons") return labels = sorted(reason_counts, key=lambda k: (-reason_counts[k], k))[:10] values = [reason_counts[label] for label in labels] y = np.arange(len(labels), dtype=float) ax.barh(y, values, color="#9ecae1", edgecolor="black", linewidth=0.4) ax.set_yticks(y) ax.set_yticklabels(labels) ax.invert_yaxis() ax.set_xlabel("Count") ax.grid(True, axis="x", alpha=0.25) def _qc_labels(rows: list[Mapping[str, Any]]) -> list[str]: labels = [] for row in rows: station = row.get("station") channel = row.get("channel", "window") idx = row.get("result_index") prefix = f"{station}:" if station else f"{idx}:" labels.append(f"{prefix}{channel}") return labels def _decision_color(decision: str) -> str: decision = str(decision or "unknown").lower() if decision in {"accept", "ok", "pass"}: return _LEVEL_COLORS["ok"] if decision in {"warning", "repeat"}: return _LEVEL_COLORS["warning"] if decision in {"reject", "critical", "fail"}: return _LEVEL_COLORS["critical"] return _LEVEL_COLORS["unknown"] def _dashboard_data( session: FieldSession, *, now: float | None, ) -> dict[str, Any]: pipeline = session.to_pipeline_input() stations = [dict(row) for row in pipeline.get("stations", [])] packets = [_packet_row(packet) for packet in session.packets] status = session.assess(now=now) latest = _latest_station_metrics(packets, session) for row in stations: sid = row.get("station_id") row.update(latest.get(sid, {})) row["health_level"] = _station_level(row) return dict( survey_id=session.survey_id, method=session.method or pipeline.get("method"), n_devices=session.n_devices, n_stations=session.n_stations, n_packets=session.n_packets, stations=stations, packets=packets, monitoring=status.as_dict(), issues=list(status.issues), ) def _packet_row(packet: TelemetryPacket) -> dict[str, Any]: row = packet.as_dict() payload = dict(row.get("payload") or {}) row["kind"] = ( row["kind"].value if isinstance(row.get("kind"), PacketKind) else str(row.get("kind")) ) row["station"] = _payload_first( payload, "station", "site", "station_id", "station_name", ) row["accepted"] = _accepted_from_payload(payload) row["battery_v"] = _float_or_nan( _payload_first(payload, "battery_v", "battery_voltage_v") ) row["clock_offset_ms"] = _float_or_nan( _payload_first(payload, "clock_offset_ms", "offset_ms") ) row["runtime_days"] = _float_or_nan(payload.get("runtime_days")) row["power_state"] = str(payload.get("state", "") or "").lower() row["sync_quality"] = str(payload.get("quality", "") or "").lower() return row def _payload_first(payload: Mapping[str, Any], *keys: str) -> Any: for key in keys: if key in payload and payload[key] is not None: return payload[key] return None def _accepted_from_payload(payload: Mapping[str, Any]) -> bool | None: value = _payload_first( payload, "accepted", "edge_accepted", "qc_accepted" ) if value is not None: return _as_bool(value) decision = _payload_first(payload, "decision", "edge_decision") if decision is None: return None return str(decision).strip().lower() in { "accept", "ok", "pass", "warning", } def _as_bool(value: Any) -> bool: if isinstance(value, str): return value.strip().lower() in {"1", "true", "yes", "y", "ok"} return bool(value) def _float_or_nan(value: Any) -> float: try: out = float(value) except Exception: return float("nan") return out if math.isfinite(out) else float("nan") def _latest_station_metrics( packets: Iterable[Mapping[str, Any]], session: FieldSession, ) -> dict[str, dict[str, Any]]: latest: dict[str, dict[str, Any]] = defaultdict(dict) by_device = { device_id: device.station for device_id, device in session.devices.items() if device.station } ordered = sorted(packets, key=lambda r: float(r.get("timestamp", 0.0))) for row in ordered: sid = row.get("station") or by_device.get(str(row.get("device_id"))) if sid is None: continue bucket = latest[str(sid)] for key in ( "battery_v", "clock_offset_ms", "runtime_days", "power_state", "sync_quality", ): value = row.get(key) if isinstance(value, float) and not math.isfinite(value): continue if value not in (None, ""): bucket[key] = value return dict(latest) def _station_level(row: Mapping[str, Any]) -> str: acceptance = row.get("acceptance_rate") try: rate = float(acceptance) except Exception: rate = float("nan") power = str(row.get("power_state", "") or "").lower() sync = str(row.get("sync_quality", "") or "").lower() if power in {"critical"} or sync in {"poor"}: return "critical" if math.isfinite(rate): if rate < 0.85: return "critical" if rate < 0.95: return "warning" if power in {"warning"} or sync in {"fair"}: return "warning" if math.isfinite(rate) or power or sync: return "ok" return "unknown" def _plot_station_panel( ax: Any, data: Mapping[str, Any], *, station_axis: str ) -> None: stations = list(data.get("stations", [])) ax.set_title("Station health") if not stations: _empty_panel(ax, "No stations") return use_map = _use_map_axis(stations, station_axis) xs, ys, labels, colors, sizes = [], [], [], [], [] profiles = {row.get("profile") for row in stations if row.get("profile")} profile_index = {p: i for i, p in enumerate(sorted(profiles))} for idx, row in enumerate(stations): if use_map: lat, lon = _lat_lon(row.get("coords")) x = lon y = lat else: x = _float_or_nan(row.get("position_m")) if not math.isfinite(x): x = float(idx) profile = row.get("profile") y = float(profile_index.get(profile, 0)) labels.append(str(row.get("station_id"))) colors.append(_LEVEL_COLORS.get(row.get("health_level"), "#9e9e9e")) rate = _float_or_nan(row.get("acceptance_rate")) sizes.append(80.0 + 220.0 * (rate if math.isfinite(rate) else 0.25)) xs.append(x) ys.append(y) ax.scatter(xs, ys, s=sizes, c=colors, edgecolor="black", linewidth=0.8) for x, y, label in zip(xs, ys, labels): ax.text(x, y, f" {label}", va="center", fontsize=8) ax.set_xlabel( "Longitude" if use_map else "Profile position / station index" ) ax.set_ylabel("Latitude" if use_map else "Profile") if not use_map and profile_index: ax.set_yticks(list(profile_index.values())) ax.set_yticklabels(list(profile_index.keys())) ax.grid(True, alpha=0.25) def _use_map_axis( stations: list[Mapping[str, Any]], station_axis: str ) -> bool: mode = station_axis.lower() if mode not in {"auto", "profile", "map"}: raise ValueError("station_axis must be 'auto', 'profile', or 'map'.") if mode == "profile": return False has_coords = [] for row in stations: coords = row.get("coords") lat, lon = _lat_lon(coords) has_coords.append(math.isfinite(lat) and math.isfinite(lon)) return all(has_coords) if mode == "auto" else True def _lat_lon(coords: Any) -> tuple[float, float]: try: seq = list(coords) return _float_or_nan(seq[0]), _float_or_nan(seq[1]) except Exception: return float("nan"), float("nan") def _plot_acceptance_panel(ax: Any, data: Mapping[str, Any]) -> None: stations = list(data.get("stations", [])) ax.set_title("Edge QC acceptance") if not stations: _empty_panel(ax, "No QC data") return labels = [str(row.get("station_id")) for row in stations] values = [_float_or_nan(row.get("acceptance_rate")) for row in stations] plot_values = [v if math.isfinite(v) else 0.0 for v in values] colors = [ _LEVEL_COLORS.get(row.get("health_level"), "#9e9e9e") for row in stations ] ax.bar( labels, plot_values, color=colors, edgecolor="black", linewidth=0.5 ) ax.axhline(0.85, color="#de2d26", lw=1.0, ls="--", label="0.85") ax.axhline(0.95, color="#2ca25f", lw=1.0, ls=":", label="0.95") ax.set_ylim(0, 1.05) ax.set_ylabel("Acceptance rate") ax.tick_params(axis="x", rotation=45) ax.legend(frameon=False, loc="lower right") for idx, value in enumerate(values): text = "n/a" if not math.isfinite(value) else f"{value:.0%}" ax.text(idx, plot_values[idx] + 0.03, text, ha="center", fontsize=8) def _plot_operations_panel(ax: Any, data: Mapping[str, Any]) -> None: stations = list(data.get("stations", [])) ax.set_title("Power and synchronisation") if not stations: _empty_panel(ax, "No operations data") return labels = [str(row.get("station_id")) for row in stations] battery = [_float_or_nan(row.get("battery_v")) for row in stations] runtime = [_float_or_nan(row.get("runtime_days")) for row in stations] x = np.arange(len(labels), dtype=float) if any(math.isfinite(v) for v in battery): ax.plot(x, battery, marker="o", color="#3182bd", label="battery V") ax.set_ylabel("Battery voltage") elif any(math.isfinite(v) for v in runtime): ax.plot(x, runtime, marker="o", color="#756bb1", label="runtime d") ax.set_ylabel("Runtime days") else: _empty_panel(ax, "No battery/runtime packets") return for idx, row in enumerate(stations): sync = str(row.get("sync_quality", "") or "") power = str(row.get("power_state", "") or "") notes = " / ".join(v for v in (power, sync) if v) if notes: ax.text( idx, ax.get_ylim()[0], notes, rotation=90, va="bottom", ha="center", fontsize=7, ) ax.set_xticks(x) ax.set_xticklabels(labels, rotation=45) ax.grid(True, axis="y", alpha=0.25) ax.legend(frameon=False, loc="best") def _plot_timeline_panel(ax: Any, data: Mapping[str, Any]) -> None: packets = list(data.get("packets", [])) ax.set_title("Telemetry timeline") if not packets: _empty_panel(ax, "No packets") return kinds = sorted({str(row.get("kind")) for row in packets}) kind_index = {kind: i for i, kind in enumerate(kinds)} times = np.asarray( [_float_or_nan(row.get("timestamp")) for row in packets] ) finite_times = times[np.isfinite(times)] if finite_times.size and finite_times.max() > finite_times.min(): xvals = (times - finite_times.min()) / 60.0 xlabel = "Minutes since first packet" else: xvals = np.arange(len(packets), dtype=float) xlabel = "Packet index" yvals = [kind_index[str(row.get("kind"))] for row in packets] colors = [ _LEVEL_COLORS["critical"] if row.get("accepted") is False else _LEVEL_COLORS["ok"] if row.get("accepted") is True else "#6baed6" for row in packets ] ax.scatter(xvals, yvals, c=colors, s=45, edgecolor="black", linewidth=0.4) ax.set_yticks(list(kind_index.values())) ax.set_yticklabels(kinds) ax.set_xlabel(xlabel) ax.set_ylabel("Packet kind") ax.grid(True, alpha=0.25) status = data.get("monitoring", {}) level = status.get("level", "unknown") issues = data.get("issues", []) issue_text = ", ".join(issues[:3]) if issues else "no issues" ax.text( 0.01, 0.98, f"status: {level}\n{issue_text}", transform=ax.transAxes, va="top", ha="left", fontsize=8, bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="#cccccc"), ) def _empty_panel(ax: Any, text: str) -> None: ax.text(0.5, 0.5, text, ha="center", va="center", transform=ax.transAxes) ax.set_xticks([]) ax.set_yticks([]) for spine in ax.spines.values(): spine.set_alpha(0.2)