9.9. Visualization#
The IoT plotting layer lives in pycsamt.iot.plot and is re-exported
from pycsamt.iot. These figures are operational acquisition
plots: they show telemetry health, edge decisions,
power budget, and clock synchronisation before the data are
converted into impedance products or inversion inputs. Every function
covered on this page has already appeared once elsewhere in this
guide — plot_field_dashboard() in
Basic Session and Telemetry Transports,
plot_edge_qc_summary() in Generic Edge QC — but
always attached to the topic that page was teaching. Here they are the
subject: what each panel actually plots, where its numbers come from, and
which function to reach for first.
The plotting functions accept high-level objects such as
FieldSession, telemetry packets,
energy estimates, synchronisation status objects, or
serialised mappings. Each returned Matplotlib figure also carries
the normalised plot data used to draw the panels in a
fig.pycsamt_iot_* attribute. That makes report generation auditable:
the image and the plotted rows stay together, so a reviewer can
reconstruct exactly what a bar or point represents without re-deriving it
from the raw packets.
9.9.1. Building A Visualisation Session#
This example uses synthetic data built deterministically from a
fixed seed. It creates three stations on profile L18: one healthy,
one with poor finite coverage on one channel, and one with a
spike on another. Each station gets a QC packet (run through the same
EdgeProcessor introduced in Generic Edge QC), a
health packet, a power packet, and a sync packet, so every one of the
four plotting functions below has real data to draw. With \(N_s=3\)
stations and four packets each, the total is \(4N_s=12\).
>>> import numpy as np
>>> from pycsamt.iot import (
... DeviceConfig, EdgeProcessingConfig, EdgeProcessor, EnergyConfig,
... FieldSession, MonitoringConfig, PacketKind, StationConfig,
... TelemetryPacket, estimate_energy_budget,
... )
>>> survey_id = "WILLY-L18-VISUAL"
>>> station_ids = ["L18-001", "L18-002", "L18-003"]
>>> positions_m = [0.0, 50.0, 100.0]
>>> coords = [(7.501, -5.201), (7.502, -5.198), (7.503, -5.195)]
>>> devices = [
... DeviceConfig(
... f"l18-node-{i + 1:02d}", station=sid,
... channels=["ex", "ey", "hx", "hy"], sample_rate_hz=256.0,
... )
... for i, sid in enumerate(station_ids)
... ]
>>> stations = [
... StationConfig(
... sid, profile="L18", position_m=pos, lat=lat, lon=lon,
... channels=["ex", "ey", "hx", "hy"],
... )
... for sid, pos, (lat, lon) in zip(station_ids, positions_m, coords)
... ]
>>> session = FieldSession(
... survey_id, devices=devices, stations=stations,
... monitoring_config=MonitoringConfig(
... method="amt", expected_interval_s=6.0, max_gap_s=18.0,
... min_edge_acceptance_rate=0.70, min_battery_v=11.2,
... required_channels=["ex", "ey", "hx", "hy"],
... ),
... )
>>> processor = EdgeProcessor(
... EdgeProcessingConfig(
... decimation=1, finite_threshold=0.85, warn_finite_threshold=0.95,
... channel_names=["ex", "ey", "hx", "hy"], spike_threshold=4.0,
... max_spike_fraction=0.12,
... )
... )
>>> rng = np.random.default_rng(42)
>>> battery_v = [12.55, 11.84, 10.92]
>>> temperature_c = [30.1, 31.4, 33.2]
>>> battery_wh = [180.0, 120.0, 70.0]
>>> active_power_w = [1.2, 1.6, 2.1]
>>> duty_cycle = [0.25, 0.35, 0.55]
>>> solar_wh_per_day = [8.0, 3.0, 0.0]
>>> sync_specs = [
... {"offset_ms": 0.32, "drift_ppm": 1.4, "jitter_ms": 0.16,
... "gps_lock": True, "n_reference_points": 120, "quality": "excellent"},
... {"offset_ms": 1.84, "drift_ppm": 11.2, "jitter_ms": 0.72,
... "gps_lock": True, "n_reference_points": 118, "quality": "fair"},
... {"offset_ms": 7.62, "drift_ppm": 69.5, "jitter_ms": 2.15,
... "gps_lock": False, "n_reference_points": 61, "quality": "poor"},
... ]
>>> for idx, device in enumerate(devices):
... data = rng.normal(size=(256, 4))
... if idx == 1:
... data[20:230, 1] = np.nan
... if idx == 2:
... data[80:125, 0] = 15.0
... edge = processor.process(data)
... qc_packet = edge.to_packet(
... device, timestamp=1_700_000_000.0 + idx * 6.0,
... survey_id=survey_id, qos=1,
... )
... qc_packet.payload["station"] = device.station
... qc_packet.payload["method"] = "amt"
... _ = session.add_packet(qc_packet)
... health = TelemetryPacket.from_device(
... device, timestamp=1_700_000_002.0 + idx * 6.0,
... survey_id=survey_id, kind=PacketKind.HEALTH,
... payload={
... "station": device.station, "battery_v": battery_v[idx],
... "temperature_c": temperature_c[idx],
... },
... )
... _ = session.add_packet(health)
... power_config = EnergyConfig(
... battery_wh=battery_wh[idx], active_power_w=active_power_w[idx],
... sleep_power_w=0.18, duty_cycle=duty_cycle[idx],
... solar_wh_per_day=solar_wh_per_day[idx], telemetry_power_w=2.0,
... telemetry_seconds_per_day=600.0, edge_power_w=0.35,
... edge_duty_cycle=0.20, min_runtime_days=5.0,
... device_id=device.device_id,
... )
... power_packet = estimate_energy_budget(power_config).to_packet(
... device, timestamp=1_700_000_004.0 + idx * 6.0, survey_id=survey_id,
... )
... power_packet.payload["station"] = device.station
... _ = session.add_packet(power_packet)
... sync_payload = dict(sync_specs[idx], station=device.station)
... _ = session.add_packet(
... TelemetryPacket.from_device(
... device, timestamp=1_700_000_006.0 + idx * 6.0,
... survey_id=survey_id, kind=PacketKind.SYNC, payload=sync_payload,
... )
... )
>>> print((session.survey_id, session.n_devices, session.n_stations, session.n_packets))
('WILLY-L18-VISUAL', 3, 3, 12)
9.9.2. The Field Dashboard#
plot_field_dashboard() is the at-a-glance
field dashboard: station health, edge-QC acceptance, power or
synchronisation state, and packet timing in four panels. Internally it
starts from session.to_pipeline_input() and the packet stream, then
joins the latest battery voltage, runtime, power state, clock offset, and
sync quality onto each station row. A station is marked critical when its
power state is critical, its sync quality is poor, or its acceptance
rate is below 0.85; it is marked warning when the acceptance rate is
below 0.95 or when power/sync is warning/fair. The acceptance panel
displays \(A_i=n_{\mathrm{accepted},i}/n_{\mathrm{qc},i}\) for each
station, and the timeline uses minutes since the first finite packet
timestamp.
>>> from pycsamt.iot import plot_field_dashboard
>>> dashboard = plot_field_dashboard(
... session, now=1_700_000_030.0, station_axis="profile",
... title="IoT field dashboard: WILLY L18",
... )
>>> dashboard_data = dashboard.pycsamt_iot_dashboard
>>> print(f"dashboard_stations: {len(dashboard_data['stations'])}")
dashboard_stations: 3
>>> print(f"dashboard_packets: {len(dashboard_data['packets'])}")
dashboard_packets: 12
>>> print(f"monitoring_level: {dashboard_data['monitoring']['level']}")
monitoring_level: critical
>>> print(f"issues: {', '.join(dashboard_data['issues']) or '-'}")
issues: battery_below_threshold, edge_acceptance_rate_below_threshold, required_channels_missing
station_axis only changes one of the four panels: "profile" lays
stations out by chainage, while "map" uses longitude/latitude — and
since every station here carries real coordinates, both are worth seeing
side by side rather than picking one arbitrarily. The script below
rebuilds the session and saves the "profile" dashboard shown as the
first figure further down:
View profile-view dashboard source codeClick to inspect and copy the complete code
1def make_field_dashboard_profile() -> None:
2 session = _build_session()
3 fig = plot_field_dashboard(
4 session, now=1_700_000_030.0, station_axis="profile",
5 title="IoT field dashboard: WILLY L18",
6 )
7 fig.savefig(IMAGE_DIR / "user-guide-iot-visualization-01.png", dpi=180)
The map variant changes nothing but that one keyword argument — same
session, same thresholds, just station_axis="map" — and saves the
second figure:
View map-view dashboard source codeClick to inspect and copy the complete code
1def make_field_dashboard_map() -> None:
2 session = _build_session()
3 fig = plot_field_dashboard(
4 session, now=1_700_000_030.0, station_axis="map",
5 title="IoT field dashboard (map view): WILLY L18",
6 )
7 fig.savefig(IMAGE_DIR / "user-guide-iot-visualization-05.png", dpi=180)
The station-health panel is the only thing that changes between the two:
chainage 0/50/100 m becomes longitude/latitude, and L18-001 stays the
one large green marker in both. The other three panels — acceptance,
power/sync, timeline — read from the same station and packet rows
regardless of axis choice, so they are pixel-identical between the two
figures. Reach for "map" once a survey is no longer a single straight
line and chainage stops being a meaningful x-axis; use "profile" for
ordinary line work, which is the more common case and the default this
guide otherwise uses.
9.9.3. Edge QC Detail#
Use plot_edge_qc_summary() when the dashboard
indicates that edge quality is the problem — the same function
Generic Edge QC introduced on synthetic windows, now reading straight from
a session. It accepts a FieldSession, one
or more QC telemetry packets, or raw
EdgeProcessingResult objects, and normalises
whichever it receives into one row per station/channel pair. From that
row set it counts decisions, plots finite coverage against the
configured warning/rejection thresholds, plots robust spike
fraction, and tallies rejection or warning reasons — each window-level
reason once per window, each channel-level reason once per channel,
exactly as derived in Generic Edge QC.
>>> from pycsamt.iot import plot_edge_qc_summary
>>> qc_fig = plot_edge_qc_summary(session, title="Edge QC visual summary: WILLY L18")
>>> qc_rows = qc_fig.pycsamt_iot_edge_qc
>>> print(f"qc_rows: {len(qc_rows)}")
qc_rows: 12
>>> print(f"qc_decisions: {sorted({row['decision'] for row in qc_rows})}")
qc_decisions: ['accept', 'reject']
>>> print(f"qc_channels: {sorted({row['channel'] for row in qc_rows})}")
qc_channels: ['ex', 'ey', 'hx', 'hy']
View edge-QC summary source codeClick to inspect and copy the complete code
1def make_edge_qc_summary() -> None:
2 session = _build_session()
3 fig = plot_edge_qc_summary(session, title="Edge QC visual summary: WILLY L18")
4 fig.savefig(IMAGE_DIR / "user-guide-iot-visualization-02.png", dpi=180)
All four of L18-001’s channels are green and accepted; all eight
channels across the other two stations are red and rejected, matching
the dashboard’s stream-wide acceptance already shown above. The coverage
panel isolates exactly where: L18-002:ey alone drops to about 0.18,
the injected NaN block from the session-building step, while every other
channel — including L18-002’s other three — sits at a clean 1.0. The
spike panel is the mirror image, flagging only L18-003:ex, where the
15.0 outlier was injected. The reasons panel totals two occurrences of
each failure kind, not four or eight: one window-level reason per
rejected station plus one matching channel-level reason from the specific
channel that triggered it, counted the way Generic Edge QC derived rather
than repeated once per channel in the row set.
9.9.4. Power Budgets#
Use plot_power_budget() to compare daily load,
harvest, runtime, and power states. The input can be a session containing
power packets, a list of EnergyConfig
objects, power telemetry packets, or energy estimates. The first panel
compares daily load \(L_i\) with harvested energy \(H_i\); the
runtime panel compares estimated runtime
\(T_i=E_{\mathrm{usable},i}/\max(L_i-H_i,0)\) with no-harvest
autonomy; the breakdown panel stacks base, telemetry, edge-processing,
and auxiliary load components so the sum returns \(L_i\). The final
panel counts power state values and lists the most common issues.
>>> from pycsamt.iot import plot_power_budget
>>> power_fig = plot_power_budget(session, title="Power budget visual summary: WILLY L18")
>>> power_rows = power_fig.pycsamt_iot_power_budget
>>> print(f"power_rows: {len(power_rows)}")
power_rows: 3
>>> print(f"power_states: {sorted({row['state'] for row in power_rows})}")
power_states: ['critical', 'ok']
>>> print(
... "runtime_days: "
... + ", ".join(
... "inf" if np.isinf(row["runtime_days"]) else f"{row['runtime_days']:.2f}"
... for row in power_rows
... )
... )
runtime_days: 40.42, 7.86, 2.21
View power-budget source codeClick to inspect and copy the complete code
1def make_power_budget() -> None:
2 session = _build_session()
3 fig = plot_power_budget(session, title="Power budget visual summary: WILLY L18")
4 fig.savefig(IMAGE_DIR / "user-guide-iot-visualization-03.png", dpi=180)
l18-node-03 is the node to worry about: its load/harvest panel shows
a full purple load bar and no green harvest bar at all — zero solar input
was deliberately configured for that node — and its runtime bar in the
second panel barely clears the x-axis at 2.21 days, coloured red for
critical while the other two stay green. The breakdown panel shows
why the load itself is so much higher than the other two nodes: base load
dominates every bar, but l18-node-03’s base load alone (about 29.7
Wh/day) is nearly triple l18-node-01’s (about 10.4 Wh/day), consistent
with its higher configured
active_power_w and duty cycle. The states panel’s issue list —
daily_energy_deficit on three devices, runtime_below_minimum on
one — makes explicit that a negative harvest margin and an unacceptably
short runtime are two different thresholds, not the same check reported
twice.
9.9.5. Clock Synchronisation#
Use plot_sync_quality() to inspect offset, drift,
jitter, reference support, GPS lock, and quality grades. Threshold
arguments draw reference lines but do not mutate the data. Each sync row
carries the same quantities used by the clock-sync audit: clock
offset \(o_i\), clock drift \(d_i\), timing
jitter \(j_i\), GPS lock, and the number of reference
points. The visual thresholds are overlays only: a tolerance line at
\(|o|=o_{\max}\), a drift line at \(|d|=d_{\max}\), and a jitter
line at \(j=j_{\max}\).
>>> from pycsamt.iot import plot_sync_quality
>>> sync_fig = plot_sync_quality(
... session, title="Clock sync visual summary: WILLY L18",
... tolerance_ms=1.0, max_drift_ppm=10.0, max_jitter_ms=1.0,
... )
>>> sync_rows = sync_fig.pycsamt_iot_sync_quality
>>> print(f"sync_rows: {len(sync_rows)}")
sync_rows: 3
>>> print(f"sync_quality: {sorted({row['quality'] for row in sync_rows})}")
sync_quality: ['excellent', 'fair', 'poor']
>>> print(f"gps_lock_values: {[row['gps_lock'] for row in sync_rows]}")
gps_lock_values: [True, True, False]
View clock-sync source codeClick to inspect and copy the complete code
1def make_sync_quality() -> None:
2 session = _build_session()
3 fig = plot_sync_quality(
4 session, title="Clock sync visual summary: WILLY L18",
5 tolerance_ms=1.0, max_drift_ppm=10.0, max_jitter_ms=1.0,
6 )
7 fig.savefig(IMAGE_DIR / "user-guide-iot-visualization-04.png", dpi=180)
l18-node-03 again stands out, and for a related but distinct reason:
its clock offset clears the 1 ms tolerance line by more than 7×, and its
drift bar is the only one anywhere near the 10 ppm line — nearly 70 ppm,
almost seven times the threshold — while its jitter stays comparatively
modest.
The reference-support panel explains part of why: that node has only 61
reference points against 118-120 for the other two, and is the only one
that lost GPS lock, so its clock has both worse raw timing and less data
backing the estimate. Quality grades run one node per bucket here purely
because the synthetic data was built that way; a real deployment would
usually show several nodes sharing each grade.
9.9.6. Choosing The Right Plot#
Function |
Best input |
Use it for |
|---|---|---|
|
Survey status, station health, packet timing, acceptance rates. |
|
|
Session, QC packets, or edge results |
Rejection reasons, finite coverage, spike fractions. |
|
Session, power packets, energy configs, or estimates |
Runtime, harvest/load balance, power states. |
|
Session, sync packets, or sync status rows |
Offset, drift, jitter, GPS lock, synchronisation grades. |
Use the dashboard first when reviewing a live or replayed acquisition
session. If it reports a telemetry issue, inspect the packet tables and
monitoring summary from Monitoring. If it reports edge-acceptance
problems, move to the QC summary above. If a station is losing voltage or
runtime margin, use the power plot; if packet timing or GPS lock looks
suspicious, use the sync plot — in this example’s session, that chain of
reasoning leads to l18-node-03 from three independent directions at
once. These figures do not replace MT/AMT/CSAMT response plots. They
explain the condition of the acquisition system that produced the data,
which is exactly the context needed before deciding whether a station
should enter the geophysical processing workflow.