Simulation#

The IoT simulator creates deterministic field-like data for documentation, tests, demos, and pipeline development when hardware is not available. It can generate AMT channel windows, station/device inventories, telemetry packets, packet loss, GPS clock drift, and battery decay. Every simulator accepts a seed argument so examples can be reproduced exactly.

These examples are synthetic by design. They are not substitutes for real EDI or logger data; they are controlled inputs for testing the IoT layer around acquisition.

Simulate AMT Channels#

Use pycsamt.iot.simulate_amt_channel() to create one live channel window. The signal includes band-limited AMT-like energy, Gaussian noise, optional powerline harmonics, and optional dropout gaps.

 1import numpy as np
 2
 3from pycsamt.iot import (
 4    EdgeProcessingConfig,
 5    EdgeProcessor,
 6    detect_powerline_harmonics,
 7    estimate_channel_snr,
 8    simulate_amt_channel,
 9)
10
11sample_rate = 256.0
12n_samples = 4096
13
14ex = simulate_amt_channel(
15    n_samples,
16    sample_rate,
17    snr_db=18.0,
18    mains_hz=50.0,
19    powerline_amplitude=0.18,
20    dropout_rate=0.015,
21    seed=42,
22)
23ey = simulate_amt_channel(
24    n_samples,
25    sample_rate,
26    snr_db=12.0,
27    mains_hz=50.0,
28    powerline_amplitude=0.06,
29    dropout_rate=0.04,
30    seed=43,
31)
32
33window = np.column_stack([ex, ey])
34harmonics = detect_powerline_harmonics(
35    ex,
36    sample_rate,
37    mains_hz=50.0,
38    threshold_ratio=0.02,
39)
40snr_ex = estimate_channel_snr(
41    ex,
42    sample_rate,
43    signal_band_hz=(2.0, 40.0),
44)
45processor = EdgeProcessor(
46    EdgeProcessingConfig(
47        decimation=4,
48        finite_threshold=0.90,
49        warn_finite_threshold=0.98,
50        channel_names=["ex", "ey"],
51        spike_threshold=5.0,
52    )
53)
54qc = processor.process(window)
55
56print(f"n_samples: {n_samples}")
57print(f"sample_rate_hz: {sample_rate:.1f}")
58print(f"finite_coverage: {qc.metrics['finite_coverage']:.3f}")
59print(f"qc_decision: {qc.decision.value}")
60print(f"ex_snr_2_40_hz_db: {snr_ex:.2f}")
61print(f"powerline_contaminated: {harmonics.contaminated}")
62print(f"powerline_total_ratio: {harmonics.total_ratio:.3f}")

Output:

n_samples: 4096
sample_rate_hz: 256.0
finite_coverage: 0.975
qc_decision: warning
ex_snr_2_40_hz_db: -6.76
powerline_contaminated: True
powerline_total_ratio: 0.371

Simulate One Station#

Use pycsamt.iot.simulate_amt_station() when you need station metadata, a device config, channel arrays, and basic health/QC packets in one object.

 1from pycsamt.iot import simulate_amt_station
 2
 3station = simulate_amt_station(
 4    "L18-S001",
 5    sample_rate=256.0,
 6    n_samples=1024,
 7    snr_db=16.0,
 8    powerline_amplitude=0.1,
 9    dropout_rate=0.02,
10    survey_id="SIM-L18",
11    profile="L18",
12    position_m=0.0,
13    seed=8,
14)
15
16print(f"station_id: {station['station'].station_id}")
17print(f"device_id: {station['device'].device_id}")
18print(f"channels: {', '.join(station['station'].channels)}")
19print(f"packets: {len(station['packets'])}")
20print(f"qc_decision: {station['packets'][1].payload['decision']}")

Output:

station_id: L18-S001
device_id: node-L18-S001
channels: ex, ey, hx, hy
packets: 2
qc_decision: accept

Simulate A Network#

Use pycsamt.iot.simulate_iot_network() to create many stations across one or more profiles. With detail=True, the return value contains station dictionaries and a flat packet list. Use pycsamt.iot.simulate_packet_loss() to drop a reproducible fraction of packets.

 1from pycsamt.iot import (
 2    packet_table,
 3    simulate_iot_network,
 4    simulate_packet_loss,
 5    telemetry_summary,
 6)
 7
 8network = simulate_iot_network(
 9    n_stations=6,
10    profiles=["L18", "L22"],
11    sample_rate=128.0,
12    n_samples=512,
13    snr_db=14.0,
14    dropout_rate=0.03,
15    survey_id="SIM-WILLY",
16    station_spacing_m=50.0,
17    seed=9,
18    detail=True,
19)
20packets = network["packets"]
21kept = simulate_packet_loss(packets, dropout_rate=0.25, seed=10)
22
23print(f"original_packets: {len(packets)}")
24print(f"after_packet_loss: {len(kept)}")
25print(
26    packet_table(kept)[
27        ["device_id", "kind", "timestamp", "payload_keys"]
28    ].head(6).to_string(index=False)
29)
30print()
31print(
32    telemetry_summary(kept)[
33        ["device_id", "topic", "n_packet"]
34    ].head(6).to_string(index=False)
35)

Output:

original_packets: 12
after_packet_loss: 9
    device_id   kind    timestamp                                                                payload_keys
node-L18-S001 health 1700000000.0                                    battery_v;firmware;station;temperature_c
node-L22-S001 health 1700000005.0                                    battery_v;firmware;station;temperature_c
node-L18-S002 health 1700000010.0                                    battery_v;firmware;station;temperature_c
node-L22-S002 health 1700000015.0                                    battery_v;firmware;station;temperature_c
node-L22-S002     qc 1700000019.0 accepted;channels;decision;finite_coverage;frequency_band_hz;method;station
node-L18-S003 health 1700000020.0                                    battery_v;firmware;station;temperature_c

    device_id                                           topic  n_packet
node-L18-S001 pycsamt/SIM-WILLY/L18-S001/node-L18-S001/health         1
node-L18-S002 pycsamt/SIM-WILLY/L18-S002/node-L18-S002/health         1
node-L18-S003 pycsamt/SIM-WILLY/L18-S003/node-L18-S003/health         1
node-L18-S003     pycsamt/SIM-WILLY/L18-S003/node-L18-S003/qc         1
node-L22-S001 pycsamt/SIM-WILLY/L22-S001/node-L22-S001/health         1
node-L22-S002 pycsamt/SIM-WILLY/L22-S002/node-L22-S002/health         1

Monitor Simulated Packets#

Simulated packets can be fed directly into the monitoring layer. This is useful when testing dashboard behavior or packet-loss handling before hardware exists.

 1from pycsamt.iot import MonitoringConfig, TelemetryMonitor
 2
 3monitor = TelemetryMonitor(
 4    MonitoringConfig(
 5        method="amt",
 6        expected_interval_s=5.0,
 7        max_gap_s=20.0,
 8        min_packet_success_rate=0.90,
 9        min_edge_acceptance_rate=0.80,
10        required_channels=["ex", "ey", "hx", "hy"],
11    )
12)
13status = monitor.assess(kept, now=1_700_000_080.0)
14
15print(f"level: {status.level.value}")
16print(f"n_packet: {status.n_packet}")
17print(f"edge_acceptance_rate: {status.edge_acceptance_rate:.3f}")
18print(f"max_gap_s: {status.max_gap_s:.1f}")
19print(f"issues: {', '.join(status.issues) or '-'}")

Output:

level: warning
n_packet: 9
edge_acceptance_rate: 1.000
max_gap_s: 5.0
issues: latency_above_threshold

Simulate GPS Drift And Battery Decay#

Use pycsamt.iot.simulate_gps_drift() to generate paired local and reference timestamps for synchronisation tests. Use pycsamt.iot.simulate_battery_decay() for power and monitoring demos.

 1from pycsamt.iot import (
 2    ClockSynchronizer,
 3    simulate_battery_decay,
 4    simulate_gps_drift,
 5)
 6import numpy as np
 7
 8gps = simulate_gps_drift(
 9    120,
10    sample_interval_s=1.0,
11    drift_ppm=8.0,
12    jitter_ms=0.25,
13    offset_ms=0.7,
14    dropout_rate=0.1,
15    seed=11,
16)
17sync = ClockSynchronizer().assess(
18    "l18-node-01",
19    gps["local"],
20    gps["reference"],
21    gps_lock=bool(np.mean(gps["gps_lock"]) > 0.9),
22)
23battery = simulate_battery_decay(
24    120,
25    initial_v=13.1,
26    final_v=10.8,
27    seed=12,
28)
29
30print(f"gps_lock_fraction: {np.mean(gps['gps_lock']):.3f}")
31print(f"offset_ms: {sync.offset_ms:.3f}")
32print(f"drift_ppm: {sync.drift_ppm:.3f}")
33print(f"jitter_ms: {sync.jitter_ms:.3f}")
34print(f"sync_quality: {sync.quality.value}")
35print(f"battery_start_v: {battery[0]:.2f}")
36print(f"battery_end_v: {battery[-1]:.2f}")

Output:

gps_lock_fraction: 0.892
offset_ms: 1.214
drift_ppm: 9.354
jitter_ms: 0.254
sync_quality: fair
battery_start_v: 13.10
battery_end_v: 11.06

Plot Simulation Outputs#

The first figure shows channel time series and a spectrum with simulated mains contamination. The second shows the simulated network, packet counts after loss, clock drift, and battery decay.

  1from pathlib import Path
  2
  3import matplotlib.pyplot as plt
  4import numpy as np
  5
  6out_dir = Path("docs/source/images/user_guide/iot")
  7out_dir.mkdir(parents=True, exist_ok=True)
  8
  9t = np.arange(n_samples) / sample_rate
 10freq = np.fft.rfftfreq(n_samples, d=1.0 / sample_rate)
 11spec = np.abs(np.fft.rfft(np.nan_to_num(ex))) ** 2
 12
 13fig, axes = plt.subplots(
 14    2,
 15    1,
 16    figsize=(9.0, 6.2),
 17    constrained_layout=True,
 18)
 19axes[0].plot(t[:1200], ex[:1200], lw=0.9, label="Ex")
 20axes[0].plot(t[:1200], ey[:1200], lw=0.9, alpha=0.75, label="Ey")
 21axes[0].set_title("Simulated AMT channels")
 22axes[0].set_xlabel("Time (s)")
 23axes[0].set_ylabel("Amplitude")
 24axes[0].legend(loc="upper right")
 25axes[0].grid(alpha=0.25)
 26
 27axes[1].semilogy(freq[1:], spec[1:], color="tab:blue")
 28for peak in harmonics.peaks:
 29    if peak.flagged:
 30        axes[1].axvline(
 31            peak.frequency_hz,
 32            color="tab:red",
 33            ls="--",
 34            lw=1.0,
 35        )
 36axes[1].set_xlim(1.0, 128.0)
 37axes[1].set_title("Ex spectrum with simulated mains harmonics")
 38axes[1].set_xlabel("Frequency (Hz)")
 39axes[1].set_ylabel("Power")
 40axes[1].grid(alpha=0.25, which="both")
 41fig.savefig(out_dir / "user-guide-iot-simulation-01.png", dpi=180)
 42plt.close(fig)
 43
 44fig, axes = plt.subplots(
 45    2,
 46    2,
 47    figsize=(10.5, 7.0),
 48    constrained_layout=True,
 49)
 50profiles = [item["station"].profile for item in network["stations"]]
 51positions = [item["station"].position_m for item in network["stations"]]
 52accepted = [
 53    item["packets"][1].payload["accepted"]
 54    for item in network["stations"]
 55]
 56colors = ["#2ca25f" if ok else "#de2d26" for ok in accepted]
 57axes[0, 0].scatter(
 58    positions,
 59    profiles,
 60    c=colors,
 61    s=90,
 62    edgecolor="black",
 63)
 64axes[0, 0].set_title("Simulated station network")
 65axes[0, 0].set_xlabel("Profile position (m)")
 66axes[0, 0].set_ylabel("Profile")
 67axes[0, 0].grid(alpha=0.25)
 68
 69kinds = {}
 70for packet in kept:
 71    kinds[packet.kind.value] = kinds.get(packet.kind.value, 0) + 1
 72axes[0, 1].bar(kinds.keys(), kinds.values(), color="#756bb1")
 73axes[0, 1].set_title("Packets after simulated loss")
 74axes[0, 1].set_ylabel("Count")
 75axes[0, 1].grid(axis="y", alpha=0.25)
 76
 77elapsed = gps["reference"] - gps["reference"][0]
 78offset_ms = (gps["local"] - gps["reference"]) * 1000.0
 79axes[1, 0].plot(elapsed, offset_ms)
 80axes[1, 0].scatter(
 81    elapsed[~gps["gps_lock"]],
 82    offset_ms[~gps["gps_lock"]],
 83    color="tab:red",
 84    s=18,
 85    label="GPS lost",
 86)
 87axes[1, 0].set_title("Simulated clock offset")
 88axes[1, 0].set_xlabel("Elapsed time (s)")
 89axes[1, 0].set_ylabel("Offset (ms)")
 90axes[1, 0].legend(loc="upper left")
 91axes[1, 0].grid(alpha=0.25)
 92
 93axes[1, 1].plot(np.arange(battery.size), battery, color="#2ca25f")
 94axes[1, 1].axhline(
 95    11.0,
 96    ls="--",
 97    color="#de2d26",
 98    label="low battery",
 99)
100axes[1, 1].set_title("Simulated battery decay")
101axes[1, 1].set_xlabel("Sample")
102axes[1, 1].set_ylabel("Voltage")
103axes[1, 1].legend(loc="upper right")
104axes[1, 1].grid(alpha=0.25)
105fig.savefig(out_dir / "user-guide-iot-simulation-02.png", dpi=180)
106plt.close(fig)

Field Interpretation#

Simulation is useful because it makes edge cases reproducible. Here the channel window has enough missing samples to trigger a warning, a strong powerline signature, and a controlled SNR. The network example shows how packet loss changes packet inventories while still leaving enough QC packets for monitoring. The GPS and battery curves provide deterministic inputs for synchronisation and power-management documentation.

For production work, clearly label simulated data in reports and examples. Use simulation to test the IoT workflow, then replace simulated packets with real telemetry when field hardware is available.