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-QC decisions, power budget, and clock synchronisation before the data are converted into impedance products or inversion inputs.

The plotting functions accept high-level objects such as pycsamt.iot.FieldSession, telemetry packets, energy estimates, sync status objects, or serialised mappings. Each returned Matplotlib figure also carries the normalised data used to draw the panels in a fig.pycsamt_iot_* attribute. This makes report generation auditable: the image and the plotted rows stay together.

Build A Visualisation Session#

This example is synthetic and deterministic. It creates three stations on profile L18: one healthy station, one station with poor finite coverage, and one station with spike and timing problems. The goal is to exercise all visual panels, not to represent a real field deployment.

  1import numpy as np
  2
  3from pycsamt.iot import (
  4    DeviceConfig,
  5    EdgeProcessingConfig,
  6    EdgeProcessor,
  7    EnergyConfig,
  8    FieldSession,
  9    MonitoringConfig,
 10    PacketKind,
 11    StationConfig,
 12    TelemetryPacket,
 13    estimate_energy_budget,
 14)
 15
 16survey_id = "WILLY-L18-VISUAL"
 17devices = [
 18    DeviceConfig(
 19        "l18-node-01",
 20        station="L18-001",
 21        channels=["ex", "ey", "hx", "hy"],
 22        sample_rate_hz=256.0,
 23    ),
 24    DeviceConfig(
 25        "l18-node-02",
 26        station="L18-002",
 27        channels=["ex", "ey", "hx", "hy"],
 28        sample_rate_hz=256.0,
 29    ),
 30    DeviceConfig(
 31        "l18-node-03",
 32        station="L18-003",
 33        channels=["ex", "ey", "hx", "hy"],
 34        sample_rate_hz=256.0,
 35    ),
 36]
 37stations = [
 38    StationConfig(
 39        "L18-001",
 40        profile="L18",
 41        position_m=0.0,
 42        lat=7.501,
 43        lon=-5.201,
 44        channels=["ex", "ey", "hx", "hy"],
 45    ),
 46    StationConfig(
 47        "L18-002",
 48        profile="L18",
 49        position_m=50.0,
 50        lat=7.502,
 51        lon=-5.198,
 52        channels=["ex", "ey", "hx", "hy"],
 53    ),
 54    StationConfig(
 55        "L18-003",
 56        profile="L18",
 57        position_m=100.0,
 58        lat=7.503,
 59        lon=-5.195,
 60        channels=["ex", "ey", "hx", "hy"],
 61    ),
 62]
 63
 64session = FieldSession(
 65    survey_id,
 66    devices=devices,
 67    stations=stations,
 68    monitoring_config=MonitoringConfig(
 69        method="amt",
 70        expected_interval_s=6.0,
 71        max_gap_s=18.0,
 72        min_edge_acceptance_rate=0.70,
 73        min_battery_v=11.2,
 74        required_channels=["ex", "ey", "hx", "hy"],
 75    ),
 76)
 77
 78processor = EdgeProcessor(
 79    EdgeProcessingConfig(
 80        decimation=1,
 81        finite_threshold=0.85,
 82        warn_finite_threshold=0.95,
 83        channel_names=["ex", "ey", "hx", "hy"],
 84        spike_threshold=4.0,
 85        max_spike_fraction=0.12,
 86    )
 87)
 88
 89rng = np.random.default_rng(42)
 90for idx, device in enumerate(devices):
 91    data = rng.normal(size=(256, 4))
 92    if idx == 1:
 93        data[20:230, 1] = np.nan
 94    if idx == 2:
 95        data[80:125, 0] = 15.0
 96
 97    edge = processor.process(data)
 98    qc_packet = edge.to_packet(
 99        device,
100        timestamp=1_700_000_000.0 + idx * 6.0,
101        survey_id=survey_id,
102        qos=1,
103    )
104    qc_packet.payload["station"] = device.station
105    qc_packet.payload["method"] = "amt"
106    session.add_packet(qc_packet)
107
108    health = TelemetryPacket.from_device(
109        device,
110        timestamp=1_700_000_002.0 + idx * 6.0,
111        survey_id=survey_id,
112        kind=PacketKind.HEALTH,
113        payload={
114            "station": device.station,
115            "battery_v": [12.55, 11.84, 10.92][idx],
116            "temperature_c": [30.1, 31.4, 33.2][idx],
117        },
118    )
119    session.add_packet(health)
120
121    power_config = EnergyConfig(
122        battery_wh=[180.0, 120.0, 70.0][idx],
123        active_power_w=[1.2, 1.6, 2.1][idx],
124        sleep_power_w=0.18,
125        duty_cycle=[0.25, 0.35, 0.55][idx],
126        solar_wh_per_day=[8.0, 3.0, 0.0][idx],
127        telemetry_power_w=2.0,
128        telemetry_seconds_per_day=600.0,
129        edge_power_w=0.35,
130        edge_duty_cycle=0.20,
131        min_runtime_days=5.0,
132        device_id=device.device_id,
133    )
134    power_packet = estimate_energy_budget(power_config).to_packet(
135        device,
136        timestamp=1_700_000_004.0 + idx * 6.0,
137        survey_id=survey_id,
138    )
139    power_packet.payload["station"] = device.station
140    session.add_packet(power_packet)
141
142sync_payloads = [
143    {
144        "station": "L18-001",
145        "offset_ms": 0.32,
146        "drift_ppm": 1.4,
147        "jitter_ms": 0.16,
148        "gps_lock": True,
149        "n_reference_points": 120,
150        "quality": "excellent",
151    },
152    {
153        "station": "L18-002",
154        "offset_ms": 1.84,
155        "drift_ppm": 11.2,
156        "jitter_ms": 0.72,
157        "gps_lock": True,
158        "n_reference_points": 118,
159        "quality": "fair",
160    },
161    {
162        "station": "L18-003",
163        "offset_ms": 7.62,
164        "drift_ppm": 69.5,
165        "jitter_ms": 2.15,
166        "gps_lock": False,
167        "n_reference_points": 61,
168        "quality": "poor",
169    },
170]
171for idx, payload in enumerate(sync_payloads):
172    session.add_packet(
173        TelemetryPacket.from_device(
174            devices[idx],
175            timestamp=1_700_000_006.0 + idx * 6.0,
176            survey_id=survey_id,
177            kind=PacketKind.SYNC,
178            payload=payload,
179        )
180    )
181
182print(f"survey_id: {session.survey_id}")
183print(f"n_devices: {session.n_devices}")
184print(f"n_stations: {session.n_stations}")
185print(f"n_packets: {session.n_packets}")

Output:

survey_id: WILLY-L18-VISUAL
n_devices: 3
n_stations: 3
n_packets: 12

Plot The Field Dashboard#

Use pycsamt.iot.plot_field_dashboard() for an at-a-glance field status. The four panels show station health, edge-QC acceptance, power or synchronisation state, and packet timing. Set station_axis="profile" for line work and station_axis="map" when all stations have valid coordinates.

 1from pathlib import Path
 2
 3from pycsamt.iot import plot_field_dashboard
 4
 5out_dir = Path("docs/source/images/user_guide/iot")
 6out_dir.mkdir(parents=True, exist_ok=True)
 7
 8dashboard = plot_field_dashboard(
 9    session,
10    now=1_700_000_030.0,
11    station_axis="profile",
12    title="IoT field dashboard: WILLY L18",
13)
14dashboard.savefig(
15    out_dir / "user-guide-iot-visualization-01.png",
16    dpi=180,
17)
18
19dashboard_data = dashboard.pycsamt_iot_dashboard
20print(f"dashboard_stations: {len(dashboard_data['stations'])}")
21print(f"dashboard_packets: {len(dashboard_data['packets'])}")
22print(f"monitoring_level: {dashboard_data['monitoring']['level']}")
23print(f"issues: {', '.join(dashboard_data['issues']) or '-'}")

Output:

dashboard_stations: 3
dashboard_packets: 12
monitoring_level: critical
issues: battery_below_threshold, edge_acceptance_rate_below_threshold, required_channels_missing

Plot Edge-QC Detail#

Use pycsamt.iot.plot_edge_qc_summary() when the dashboard indicates that edge quality is the problem. The function accepts a pycsamt.iot.FieldSession, one or more QC telemetry packets, or raw pycsamt.iot.EdgeProcessingResult objects.

 1from pycsamt.iot import plot_edge_qc_summary
 2
 3qc_fig = plot_edge_qc_summary(
 4    session,
 5    title="Edge QC visual summary: WILLY L18",
 6)
 7qc_fig.savefig(
 8    out_dir / "user-guide-iot-visualization-02.png",
 9    dpi=180,
10)
11
12qc_rows = qc_fig.pycsamt_iot_edge_qc
13print(f"qc_rows: {len(qc_rows)}")
14print(f"qc_decisions: {sorted({row['decision'] for row in qc_rows})}")
15print(f"qc_channels: {sorted({row['channel'] for row in qc_rows})}")

Output:

qc_rows: 12
qc_decisions: ['accept', 'reject']
qc_channels: ['ex', 'ey', 'hx', 'hy']

Plot Power Budgets#

Use pycsamt.iot.plot_power_budget() to compare daily load, harvest, runtime, and power states. The input can be a session containing power packets, a list of pycsamt.iot.EnergyConfig objects, power telemetry packets, or energy estimates.

 1import numpy as np
 2
 3from pycsamt.iot import plot_power_budget
 4
 5power_fig = plot_power_budget(
 6    session,
 7    title="Power budget visual summary: WILLY L18",
 8)
 9power_fig.savefig(
10    out_dir / "user-guide-iot-visualization-03.png",
11    dpi=180,
12)
13
14power_rows = power_fig.pycsamt_iot_power_budget
15print(f"power_rows: {len(power_rows)}")
16print(f"power_states: {sorted({row['state'] for row in power_rows})}")
17print(
18    "runtime_days: "
19    + ", ".join(
20        "inf" if np.isinf(row["runtime_days"])
21        else f"{row['runtime_days']:.2f}"
22        for row in power_rows
23    )
24)

Output:

power_rows: 3
power_states: ['critical', 'ok']
runtime_days: 40.42, 7.86, 2.21

Plot Clock Synchronisation#

Use pycsamt.iot.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.

 1from pycsamt.iot import plot_sync_quality
 2
 3sync_fig = plot_sync_quality(
 4    session,
 5    title="Clock sync visual summary: WILLY L18",
 6    tolerance_ms=1.0,
 7    max_drift_ppm=10.0,
 8    max_jitter_ms=1.0,
 9)
10sync_fig.savefig(
11    out_dir / "user-guide-iot-visualization-04.png",
12    dpi=180,
13)
14
15sync_rows = sync_fig.pycsamt_iot_sync_quality
16print(f"sync_rows: {len(sync_rows)}")
17print(f"sync_quality: {sorted({row['quality'] for row in sync_rows})}")
18print(f"gps_lock_values: {[row['gps_lock'] for row in sync_rows]}")

Output:

sync_rows: 3
sync_quality: ['excellent', 'fair', 'poor']
gps_lock_values: [True, True, False]

Generated Figures#

The figures are displayed in a two-column grid so the page remains compact while still showing each diagnostic family clearly.

Choosing The Right Plot#

Function

Best input

Use it for

pycsamt.iot.plot_field_dashboard()

pycsamt.iot.FieldSession

Survey status, station health, packet timing, acceptance rates.

pycsamt.iot.plot_edge_qc_summary()

Session, QC packets, or edge results

Rejection reasons, finite coverage, spike fractions.

pycsamt.iot.plot_power_budget()

Session, power packets, energy configs, or estimates

Runtime, harvest/load balance, power states.

pycsamt.iot.plot_sync_quality()

Session, sync packets, or sync status rows

Offset, drift, jitter, GPS lock, synchronisation grades.

Field Interpretation#

Use the dashboard first when reviewing a live or replayed acquisition session. If the dashboard reports a telemetry issue, inspect packet tables and monitoring summaries. If it reports edge acceptance problems, move to the QC summary. If a station is losing voltage or runtime margin, use the power plot. If packet timing or GPS lock is suspicious, use the sync plot.

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.