Source code for pycsamt.iot.sim

"""Synthetic IoT/AMT field-network simulator.

These generators make the IoT subpackage easy to demo, document, and
test without hardware. They produce realistic-looking channel time
series (with configurable SNR, powerline contamination, and dropouts),
station/device configs, telemetry packets, GPS-drift clock pairs, and
battery-decay curves.

Randomness is controlled through a ``seed`` argument for reproducibility.
"""

from __future__ import annotations

from collections.abc import Iterable, Sequence
from typing import (
    Any,
)

import numpy as np

from .core import DeviceConfig, PacketKind, TelemetryPacket
from .station import StationConfig

__all__ = [
    "simulate_powerline_noise",
    "simulate_amt_channel",
    "simulate_amt_station",
    "simulate_iot_network",
    "simulate_packet_loss",
    "simulate_gps_drift",
    "simulate_battery_decay",
]

_EPOCH_BASE = 1_700_000_000.0  # a fixed, plausible POSIX base time (2023-11)


def _rng(seed: int | None) -> np.random.Generator:
    return np.random.default_rng(seed)


[docs] def simulate_powerline_noise( n_samples: int, sample_rate: float, *, mains_hz: float = 50.0, amplitude: float = 1.0, n_harmonics: int = 3, seed: int | None = None, ) -> np.ndarray: """Return a powerline-noise time series (fundamental + harmonics).""" n_samples = int(n_samples) if n_samples <= 0: return np.empty(0) rng = _rng(seed) t = np.arange(n_samples) / float(sample_rate) noise = np.zeros(n_samples) for k in range(1, int(n_harmonics) + 1): amp = amplitude / k phase = rng.uniform(0.0, 2.0 * np.pi) noise += amp * np.sin(2.0 * np.pi * k * mains_hz * t + phase) return noise
[docs] def simulate_amt_channel( n_samples: int, sample_rate: float, *, snr_db: float = 20.0, mains_hz: float | None = 50.0, powerline_amplitude: float = 0.0, dropout_rate: float = 0.0, seed: int | None = None, ) -> np.ndarray: """Simulate one AMT channel: band-limited signal + noise + artefacts.""" n_samples = int(n_samples) if n_samples <= 0: return np.empty(0) rng = _rng(seed) fs = float(sample_rate) t = np.arange(n_samples) / fs signal = np.zeros(n_samples) for _ in range(5): freq = rng.uniform(0.05, fs / 4.0) signal += rng.uniform(0.5, 1.5) * np.sin( 2.0 * np.pi * freq * t + rng.uniform(0.0, 2.0 * np.pi) ) sig_std = float(np.std(signal)) or 1.0 signal /= sig_std snr_lin = 10.0 ** (float(snr_db) / 10.0) noise = rng.standard_normal(n_samples) / np.sqrt(max(snr_lin, 1e-9)) x = signal + noise if mains_hz and powerline_amplitude > 0: x = x + simulate_powerline_noise( n_samples, fs, mains_hz=mains_hz, amplitude=powerline_amplitude, seed=int(rng.integers(0, 2**31)), ) if dropout_rate and dropout_rate > 0: x = _inject_dropouts(x, dropout_rate, rng) return x
def _inject_dropouts( x: np.ndarray, dropout_rate: float, rng: np.random.Generator, ) -> np.ndarray: n = x.size n_drop = int(round(float(dropout_rate) * n)) if n_drop <= 0: return x x = x.copy() # A few contiguous gaps rather than scattered single NaNs. n_gaps = max(1, n_drop // 16) for _ in range(n_gaps): length = max(1, n_drop // n_gaps) start = int(rng.integers(0, max(1, n - length))) x[start : start + length] = np.nan return x
[docs] def simulate_amt_station( station_id: str, *, channels: Sequence[str] = ("ex", "ey", "hx", "hy"), sample_rate: float = 128.0, n_samples: int = 1024, mains_hz: float = 50.0, snr_db: float = 20.0, powerline_amplitude: float = 0.2, dropout_rate: float = 0.0, survey_id: str | None = None, profile: str | None = None, position_m: float | None = None, lat: float | None = None, lon: float | None = None, timestamp: float | None = None, seed: int | None = None, ) -> dict[str, Any]: """Simulate a full AMT station: config, channel data, and packets. Returns ------- dict Keys: ``station`` (:class:`StationConfig`), ``device`` (:class:`DeviceConfig`), ``data`` (``{channel: ndarray}``), and ``packets`` (list of :class:`TelemetryPacket`: one health, one QC). """ rng = _rng(seed) channels = [str(c).lower() for c in channels] device_id = f"node-{station_id}" ts = float(_EPOCH_BASE if timestamp is None else timestamp) device = DeviceConfig( device_id=device_id, station=station_id, protocol="mqtt", sample_rate_hz=sample_rate, channels=channels, ) station = StationConfig( station_id=station_id, lat=lat, lon=lon, profile=profile, position_m=position_m, channels=channels, device_ids=[device_id], ) data: dict[str, np.ndarray] = {} for channel in channels: data[channel] = simulate_amt_channel( n_samples, sample_rate, snr_db=snr_db, mains_hz=mains_hz, powerline_amplitude=powerline_amplitude, dropout_rate=dropout_rate, seed=int(rng.integers(0, 2**31)), ) # Aggregate QC proxies from the synthetic data. coverage = ( float(np.mean([np.mean(np.isfinite(v)) for v in data.values()])) if data else 1.0 ) accepted = bool(coverage >= 0.95 and snr_db >= 6.0) battery_v = float(11.0 + rng.uniform(0.0, 2.0)) health = TelemetryPacket.from_device( device, timestamp=ts, payload=dict( station=station_id, battery_v=round(battery_v, 3), temperature_c=round(float(20.0 + rng.uniform(-5, 10)), 2), firmware="sim-1.0", ), kind=PacketKind.HEALTH, survey_id=survey_id, ) qc = TelemetryPacket.from_device( device, timestamp=ts + n_samples / float(sample_rate), payload=dict( method="amt", station=station_id, channels=channels, frequency_band_hz=[1.0, sample_rate / 2.0], finite_coverage=round(coverage, 4), accepted=accepted, decision="accept" if accepted else "reject", ), kind=PacketKind.QC, survey_id=survey_id, ) return dict( station=station, device=device, data=data, packets=[health, qc], )
[docs] def simulate_iot_network( n_stations: int = 10, *, profiles: Sequence[str] = ("L1",), channels: Sequence[str] = ("ex", "ey", "hx", "hy"), sample_rate: float = 128.0, n_samples: int = 512, mains_hz: float = 50.0, snr_db: float = 20.0, dropout_rate: float = 0.05, survey_id: str = "SIM", station_spacing_m: float = 50.0, seed: int | None = None, detail: bool = False, ) -> Any: """Simulate a network of AMT stations across one or more profiles. Parameters ---------- n_stations : int Total number of stations to generate. profiles : sequence of str Profile/line labels; stations are round-robin assigned to them. detail : bool When ``True`` return a dict with ``stations`` and ``packets``. When ``False`` (default) return just the flat list of packets, as in the documented example. Returns ------- list of TelemetryPacket, or dict when ``detail=True``. """ rng = _rng(seed) profiles = list(profiles) or ["L1"] n_stations = max(0, int(n_stations)) per_profile_counts: dict[str, int] = {p: 0 for p in profiles} stations: list[dict[str, Any]] = [] packets: list[TelemetryPacket] = [] for i in range(n_stations): profile = profiles[i % len(profiles)] idx = per_profile_counts[profile] per_profile_counts[profile] = idx + 1 station_id = f"{profile}-S{idx + 1:03d}" result = simulate_amt_station( station_id, channels=channels, sample_rate=sample_rate, n_samples=n_samples, mains_hz=mains_hz, snr_db=float(snr_db + rng.uniform(-4.0, 4.0)), dropout_rate=dropout_rate, survey_id=survey_id, profile=profile, position_m=idx * float(station_spacing_m), timestamp=_EPOCH_BASE + i * (n_samples / float(sample_rate) + 1.0), seed=int(rng.integers(0, 2**31)), ) stations.append(result) packets.extend(result["packets"]) if detail: return dict(stations=stations, packets=packets) return packets
[docs] def simulate_packet_loss( packets: Iterable[TelemetryPacket], dropout_rate: float = 0.05, *, seed: int | None = None, ) -> list[TelemetryPacket]: """Return *packets* with a fraction randomly dropped.""" rng = _rng(seed) items = list(packets) if not 0.0 <= float(dropout_rate) <= 1.0: raise ValueError("dropout_rate must be between 0 and 1.") keep_mask = rng.random(len(items)) >= float(dropout_rate) return [pkt for pkt, keep in zip(items, keep_mask) if keep]
[docs] def simulate_gps_drift( n_samples: int, *, sample_interval_s: float = 1.0, drift_ppm: float = 5.0, jitter_ms: float = 0.2, offset_ms: float = 0.0, dropout_rate: float = 0.0, start_time: float = _EPOCH_BASE, seed: int | None = None, ) -> dict[str, np.ndarray]: """Simulate paired reference/local clocks with drift, jitter, dropout. Returns ------- dict Keys ``reference`` and ``local`` (POSIX-second arrays) and ``gps_lock`` (bool array). Feed ``local``/``reference`` straight into :func:`~pycsamt.iot.sync.estimate_clock_drift_ppm` etc. """ n_samples = int(n_samples) rng = _rng(seed) reference = start_time + np.arange(max(n_samples, 0)) * float( sample_interval_s ) elapsed = reference - start_time drift_s = (float(drift_ppm) * 1e-6) * elapsed jitter_s = rng.normal(0.0, float(jitter_ms) / 1000.0, size=reference.size) local = reference + float(offset_ms) / 1000.0 + drift_s + jitter_s gps_lock = np.ones(reference.size, dtype=bool) if dropout_rate and dropout_rate > 0: gps_lock = rng.random(reference.size) >= float(dropout_rate) # Where GPS is lost, the local clock free-runs (no correction): # exaggerate drift on those samples for realism. local = np.where(gps_lock, local, local + drift_s * 0.5) return {"reference": reference, "local": local, "gps_lock": gps_lock}
[docs] def simulate_battery_decay( n_samples: int, *, initial_v: float = 13.2, final_v: float = 10.5, noise_v: float = 0.05, seed: int | None = None, ) -> np.ndarray: """Simulate a monotonic-ish battery discharge curve with noise.""" n_samples = int(n_samples) if n_samples <= 0: return np.empty(0) rng = _rng(seed) frac = np.linspace(0.0, 1.0, n_samples) # Gentle exponential-ish sag from initial to final voltage. curve = final_v + (initial_v - final_v) * np.exp(-2.0 * frac) curve = curve + rng.normal(0.0, float(noise_v), size=n_samples) return curve