9.14. 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 random seed argument so examples can be reproduced exactly. Every synthetic example elsewhere in this guide — the noisy half-space soundings, the multi-station sessions, the flaky uplinks — was built by hand from raw arrays and dictionaries; this page is the one place those same field-like inputs come from a single generator function instead.
These examples are synthetic data by design. They are not substitutes for real EDI or logger data; they are controlled inputs for testing the IoT layer around acquisition. Reproducibility comes from treating each simulator as a deterministic function of its arguments and seed: if the arguments and seed are unchanged, the generated samples, packets, and figures are unchanged.
9.14.1. Simulate AMT Channels#
Use 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. For a sample rate \(f_s\) and
\(N\) samples, the time vector is \(t_i=i/f_s\). The simulator
first builds a normalised signal from five random sinusoids,
where \(a_j\), \(f_j\), and \(\phi_j\) are drawn from the
seeded random generator, with \(f_j\) uniform over
\([0.05,\ f_s/4]\) — broadband by construction, not concentrated in
any one target sub-band. A requested SNR of \(S_\mathrm{dB}\)
is converted to \(S_\mathrm{lin}=10^{S_\mathrm{dB}/10}\), then white
noise \(\epsilon_i \sim \mathcal{N}(0, 1/S_\mathrm{lin})\) is added.
If a mains component is requested, the simulator adds
\(\sum_{k=1}^{K} (A/k)\sin(2\pi k f_m t_i+\psi_k)\). Finally, the
configured dropout rate replaces a reproducible number of contiguous
samples with NaN values.
>>> import numpy as np
>>> from pycsamt.iot import (
... EdgeProcessingConfig, EdgeProcessor, detect_powerline_harmonics,
... estimate_channel_snr, simulate_amt_channel,
... )
>>> sample_rate = 256.0
>>> n_samples = 4096
>>> ex = simulate_amt_channel(
... n_samples, sample_rate, snr_db=18.0, mains_hz=50.0,
... powerline_amplitude=0.18, dropout_rate=0.015, seed=42,
... )
>>> ey = simulate_amt_channel(
... n_samples, sample_rate, snr_db=12.0, mains_hz=50.0,
... powerline_amplitude=0.06, dropout_rate=0.04, seed=43,
... )
>>> window = np.column_stack([ex, ey])
>>> harmonics = detect_powerline_harmonics(ex, sample_rate, mains_hz=50.0, threshold_ratio=0.02)
>>> snr_ex = estimate_channel_snr(ex, sample_rate, signal_band_hz=(2.0, 40.0))
>>> processor = EdgeProcessor(
... EdgeProcessingConfig(
... decimation=4, finite_threshold=0.90, warn_finite_threshold=0.98,
... channel_names=["ex", "ey"], spike_threshold=5.0,
... )
... )
>>> qc = processor.process(window)
>>> print(f"n_samples: {n_samples}")
n_samples: 4096
>>> print(f"sample_rate_hz: {sample_rate:.1f}")
sample_rate_hz: 256.0
>>> print(f"finite_coverage: {qc.metrics['finite_coverage']:.3f}")
finite_coverage: 0.975
>>> print(f"qc_decision: {qc.decision.value}")
qc_decision: warning
>>> print(f"ex_snr_2_40_hz_db: {snr_ex:.2f}")
ex_snr_2_40_hz_db: -6.76
>>> print(f"powerline_contaminated: {harmonics.contaminated}")
powerline_contaminated: True
>>> print(f"powerline_total_ratio: {harmonics.total_ratio:.3f}")
powerline_total_ratio: 0.371
ex_snr_2_40_hz_db reads as a poor -6.76 dB even though ex was
built with a broadband snr_db=18.0 — the two numbers are not
measuring the same thing. The requested 18 dB is the ratio of total
signal power to total noise power across the whole spectrum, but the
five random sinusoids that make up the signal are drawn uniformly up to
\(f_s/4 = 64\) Hz, so most of that signal energy can easily land
outside the narrow 2-40 Hz band the SNR estimator actually integrates
over. A high requested SNR does not guarantee a high in-band
estimate unless the signal energy happens to fall inside the band being
checked.
9.14.2. Simulate One Station#
Use simulate_amt_station() when you need station
metadata, a device config, channel arrays, and basic health/QC packets
in one object. Each requested channel is generated with its own child
seed from the station generator. The station-level finite
coverage is the mean of per-channel finite fractions,
where \(M\) is the number of channels and \(N\) is the samples per channel. The synthetic QC packet is accepted when \(C \ge 0.95\) and the configured channel SNR is at least 6 dB; otherwise it is rejected. That makes station simulation useful for testing both clean and failed edge cases.
>>> from pycsamt.iot import simulate_amt_station
>>> station = simulate_amt_station(
... "L18-S001", sample_rate=256.0, n_samples=1024, snr_db=16.0,
... powerline_amplitude=0.1, dropout_rate=0.02, survey_id="SIM-L18",
... profile="L18", position_m=0.0, seed=8,
... )
>>> print(f"station_id: {station['station'].station_id}")
station_id: L18-S001
>>> print(f"device_id: {station['device'].device_id}")
device_id: node-L18-S001
>>> print(f"channels: {', '.join(station['station'].channels)}")
channels: ex, ey, hx, hy
>>> print(f"packets: {len(station['packets'])}")
packets: 2
>>> print(f"qc_decision: {station['packets'][1].payload['decision']}")
qc_decision: accept
Unlike ex_snr_2_40_hz_db above, simulate_amt_station’s own
accept/reject rule checks the configured snr_db directly rather
than re-measuring an in-band estimate, so this station accepts on the
strength of snr_db=16.0 \ge 6.0 and a coverage comfortably above
0.95 — the same broadband-versus-in-band gap noted above simply never
enters this particular decision.
9.14.3. Simulate A Network#
Use 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
simulate_packet_loss() to drop a reproducible
fraction of packets. Stations are assigned to profiles in round-robin
order, and their profile positions advance by the configured spacing.
Packet loss is a seeded Bernoulli keep/drop experiment: for packet
\(p_i\), draw \(u_i \sim U(0,1)\) and keep the packet when
\(u_i \ge q\), where \(q\) is dropout_rate. This means the
exact kept packet inventory is reproducible, while still behaving like
a lossy field link.
>>> from pycsamt.iot import (
... packet_table, simulate_iot_network, simulate_packet_loss, telemetry_summary,
... )
>>> network = simulate_iot_network(
... n_stations=6, profiles=["L18", "L22"], sample_rate=128.0, n_samples=512,
... snr_db=14.0, dropout_rate=0.03, survey_id="SIM-WILLY",
... station_spacing_m=50.0, seed=9, detail=True,
... )
>>> packets = network["packets"]
>>> kept = simulate_packet_loss(packets, dropout_rate=0.25, seed=10)
>>> print(f"original_packets: {len(packets)}")
original_packets: 12
>>> print(f"after_packet_loss: {len(kept)}")
after_packet_loss: 9
>>> print(
... packet_table(kept)[
... ["device_id", "kind", "timestamp", "payload_keys"]
... ].head(6).to_string(index=False)
... )
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
>>> print(
... telemetry_summary(kept)[
... ["device_id", "topic", "n_packet"]
... ].head(6).to_string(index=False)
... )
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
Six stations produce 12 packets (one health, one qc, each), and the
seeded loss keeps 9 — but unevenly across kinds: all 6 health
packets survive while only 3 of the 6 qc packets do. That split is
an artefact of this particular seed and packet ordering, not a rule that
favours one kind; a different seed would drop a different, still
reproducible, subset.
9.14.4. 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. The same monitoring definitions used for real telemetry apply here: the monitoring status counts packets, estimates the edge acceptance rate, checks the maximum packet gap, and compares latency with the configured threshold. Because the packet stream is synthetic, a warning can be reproduced and debugged without waiting for a field outage.
>>> from pycsamt.iot import MonitoringConfig, TelemetryMonitor
>>> monitor = TelemetryMonitor(
... MonitoringConfig(
... method="amt", expected_interval_s=5.0, max_gap_s=20.0,
... min_packet_success_rate=0.90, min_edge_acceptance_rate=0.80,
... required_channels=["ex", "ey", "hx", "hy"],
... )
... )
>>> status = monitor.assess(kept, now=1_700_000_080.0)
>>> print(f"level: {status.level.value}")
level: warning
>>> print(f"n_packet: {status.n_packet}")
n_packet: 9
>>> print(f"edge_acceptance_rate: {status.edge_acceptance_rate:.3f}")
edge_acceptance_rate: 1.000
>>> print(f"max_gap_s: {status.max_gap_s:.1f}")
max_gap_s: 5.0
>>> print(f"issues: {', '.join(status.issues) or '-'}")
issues: latency_above_threshold
Every one of the three surviving qc packets accepted, so
edge_acceptance_rate is a perfect 1.000 and max_gap_s never
approaches the 20-second limit — the only issue is
latency_above_threshold, which traces back to now=1_700_000_080.0
being set well past the packets’ own timestamps. This is a status
shaped entirely by how the monitor was asked to look at the stream,
not by anything wrong with the simulated stations themselves.
9.14.5. Simulating GPS Drift#
Use simulate_gps_drift() to generate paired local
and reference timestamps for synchronisation tests. The clock simulator
starts from a reference clock time \(r_i=t_0+i\Delta t\) and
creates a local timestamp
where \(o_\mathrm{ms}\) is the initial offset, \(d_\mathrm{ppm}\) is the clock drift in PPM, and \(\eta_i \sim \mathcal{N}(0, \sigma_\mathrm{jitter}^2)\) is timing jitter in seconds. When simulated GPS lock is lost, the local clock receives an additional drift term to mimic free-running behavior.
>>> from pycsamt.iot import ClockSynchronizer, simulate_gps_drift
>>> gps = simulate_gps_drift(
... 120, sample_interval_s=1.0, drift_ppm=8.0, jitter_ms=0.25,
... offset_ms=0.7, dropout_rate=0.1, seed=11,
... )
>>> sync = ClockSynchronizer().assess(
... "l18-node-01", gps["local"], gps["reference"],
... gps_lock=bool(np.mean(gps["gps_lock"]) > 0.9),
... )
>>> print(f"gps_lock_fraction: {np.mean(gps['gps_lock']):.3f}")
gps_lock_fraction: 0.892
>>> print(f"offset_ms: {sync.offset_ms:.3f}")
offset_ms: 1.214
>>> print(f"drift_ppm: {sync.drift_ppm:.3f}")
drift_ppm: 9.354
>>> print(f"jitter_ms: {sync.jitter_ms:.3f}")
jitter_ms: 0.254
>>> print(f"sync_quality: {sync.quality.value}")
sync_quality: fair
The recovered drift_ppm of 9.354 is close to but not exactly the
requested drift_ppm=8.0 — the extra drift term samples where GPS
lock is lost (10% of samples here) pulls the fitted slope up a little,
which is the simulator’s own free-running-clock behaviour working as
intended, not an estimation error. sync_quality lands on fair
for a reason that has nothing to do with the lock fraction, though: the
offset itself, 1.214 ms, already exceeds the default 1 ms tolerance, so
neither excellent nor good was reachable regardless of GPS lock
— both require offset within tolerance. With a lock fraction of 0.892,
just under the 0.9 threshold this example checks, gps_lock is passed
to assess as False too, which reaches the same fair grade
through the free-running-clock branch instead. Either path lands on the
same grade here, but for genuinely different reasons.
9.14.6. Simulating Battery Decay#
Use simulate_battery_decay() for power and
monitoring demos. Battery decay uses an exponential sag from initial
voltage \(V_0\) toward final voltage \(V_f\),
with small seeded voltage noise \(\nu_i\).
>>> from pycsamt.iot import simulate_battery_decay
>>> battery = simulate_battery_decay(120, initial_v=13.1, final_v=10.8, seed=12)
>>> print(f"battery_start_v: {battery[0]:.2f}")
battery_start_v: 13.10
>>> print(f"battery_end_v: {battery[-1]:.2f}")
battery_end_v: 11.06
battery_end_v lands at 11.06 V, not the requested final_v=10.8,
because the exponential sag in the equation above only reaches
\(V_f\) in the limit as \(\alpha_i \to \infty\); at
\(\alpha_i = 1\) (the last sample) the curve has decayed to within
\((V_0-V_f)e^{-2} \approx 0.31\) V of \(V_f\), and the small
seeded noise term accounts for the rest of the gap.
9.14.7. The Simulation Figures#
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. Both figures are generated from the same variables used in the examples above, so the visual output is tied to the captured text output rather than to a separate hidden dataset.
>>> from pathlib import Path
>>> import matplotlib.pyplot as plt
>>> out_dir = Path("docs/source/images/user_guide/iot")
>>> out_dir.mkdir(parents=True, exist_ok=True)
>>> t = np.arange(n_samples) / sample_rate
>>> freq = np.fft.rfftfreq(n_samples, d=1.0 / sample_rate)
>>> spec = np.abs(np.fft.rfft(np.nan_to_num(ex))) ** 2
>>> fig, axes = plt.subplots(2, 1, figsize=(9.0, 6.2), constrained_layout=True)
>>> _ = axes[0].plot(t[:1200], ex[:1200], lw=0.9, label="Ex")
>>> _ = axes[0].plot(t[:1200], ey[:1200], lw=0.9, alpha=0.75, label="Ey")
>>> _ = axes[0].set_title("Simulated AMT channels")
>>> _ = axes[0].set_xlabel("Time (s)")
>>> _ = axes[0].set_ylabel("Amplitude")
>>> _ = axes[0].legend(loc="upper right")
>>> axes[0].grid(alpha=0.25)
>>> _ = axes[1].semilogy(freq[1:], spec[1:], color="tab:blue")
>>> for peak in harmonics.peaks:
... if peak.flagged:
... _ = axes[1].axvline(peak.frequency_hz, color="tab:red", ls="--", lw=1.0)
>>> _ = axes[1].set_xlim(1.0, 128.0)
>>> _ = axes[1].set_title("Ex spectrum with simulated mains harmonics")
>>> _ = axes[1].set_xlabel("Frequency (Hz)")
>>> _ = axes[1].set_ylabel("Power")
>>> axes[1].grid(alpha=0.25, which="both")
>>> fig.savefig(out_dir / "user-guide-iot-simulation-01.png", dpi=180)
>>> plt.close(fig)
>>> fig, axes = plt.subplots(2, 2, figsize=(10.5, 7.0), constrained_layout=True)
>>> profiles = [item["station"].profile for item in network["stations"]]
>>> positions = [item["station"].position_m for item in network["stations"]]
>>> accepted = [item["packets"][1].payload["accepted"] for item in network["stations"]]
>>> colors = ["#2ca25f" if ok else "#de2d26" for ok in accepted]
>>> _ = axes[0, 0].scatter(positions, profiles, c=colors, s=90, edgecolor="black")
>>> _ = axes[0, 0].set_title("Simulated station network")
>>> _ = axes[0, 0].set_xlabel("Profile position (m)")
>>> _ = axes[0, 0].set_ylabel("Profile")
>>> axes[0, 0].grid(alpha=0.25)
>>> kinds = {}
>>> for packet in kept:
... kinds[packet.kind.value] = kinds.get(packet.kind.value, 0) + 1
>>> _ = axes[0, 1].bar(kinds.keys(), kinds.values(), color="#756bb1")
>>> _ = axes[0, 1].set_title("Packets after simulated loss")
>>> _ = axes[0, 1].set_ylabel("Count")
>>> axes[0, 1].grid(axis="y", alpha=0.25)
>>> elapsed = gps["reference"] - gps["reference"][0]
>>> offset_ms = (gps["local"] - gps["reference"]) * 1000.0
>>> _ = axes[1, 0].plot(elapsed, offset_ms)
>>> _ = axes[1, 0].scatter(
... elapsed[~gps["gps_lock"]], offset_ms[~gps["gps_lock"]],
... color="tab:red", s=18, label="GPS lost",
... )
>>> _ = axes[1, 0].set_title("Simulated clock offset")
>>> _ = axes[1, 0].set_xlabel("Elapsed time (s)")
>>> _ = axes[1, 0].set_ylabel("Offset (ms)")
>>> _ = axes[1, 0].legend(loc="upper left")
>>> axes[1, 0].grid(alpha=0.25)
>>> _ = axes[1, 1].plot(np.arange(battery.size), battery, color="#2ca25f")
>>> _ = axes[1, 1].axhline(11.0, ls="--", color="#de2d26", label="low battery")
>>> _ = axes[1, 1].set_title("Simulated battery decay")
>>> _ = axes[1, 1].set_xlabel("Sample")
>>> _ = axes[1, 1].set_ylabel("Voltage")
>>> _ = axes[1, 1].legend(loc="upper right")
>>> axes[1, 1].grid(alpha=0.25)
>>> fig.savefig(out_dir / "user-guide-iot-simulation-02.png", dpi=180)
>>> plt.close(fig)
The top time series already looks noisy rather than obviously periodic,
because Ex sums five broadband sinusoids plus noise plus a mains
term, not one clean tone. The spectrum below it makes the mains
contamination legible: a sharp peak sits exactly at the red dashed 50 Hz
line — the one harmonic that actually cleared the 0.02 threshold — while
a visible but unflagged second spike near 100 Hz shows the mains
contamination extends further than the single flagged line reports; the
remaining unlabelled peaks between roughly 25 and 50 Hz are simply
where the five random signal sinusoids happened to land this seed, not
mains-related at all. The station-network panel shows all six markers
green, consistent with every station’s QC packet accepting above. The
packet-count bar confirms the 6-versus-3 health/QC split from the
network example — visibly uneven, not the roughly-even split a flat 25%
loss rate might suggest. The clock-offset panel shows the offset
trending upward over the two-minute window, with the red “GPS lost”
points scattered throughout rather than clustered at one end — this
simulator drops lock on a per-sample basis, not as one contiguous
outage. The battery panel decays smoothly from just above 13 V toward
the dashed 11 V line, with the small noise band visible throughout
rather than a perfectly smooth exponential.
Simulation is useful because it makes edge cases reproducible with a single seed argument rather than a field outage. For production work, clearly label simulated data in reports and examples. Use simulation to test the IoT workflow shown throughout this guide, then replace simulated packets with real telemetry once field hardware is available.