Scientific validation: TorchSig chirp (comms) emitter

Validated with documented limitations.

1. The component

TorchSigChirpEmitter is a PyTorch-fronted generator that produces clean baseband in-phase/quadrature (IQ) tensors for two comms-regime chirp waveform classes. A chirp is a signal whose instantaneous frequency (the rate of phase change at a given moment, measured in Hz) sweeps linearly over time; the in-phase (I) and quadrature (Q) channels are the real and imaginary parts of the resulting complex-valued baseband signal, forming the two-channel (2, N) tensor consumed by downstream models. This emitter is the comms-side companion to ChirpRadarEmitter; they share the word “chirp” but represent different regimes (continuous comms-symbol-rate chirp vs deterministic single-pulse radar LFM) and belong to different emitter families.

class TorchSigChirpEmitter(BaseEmitter):
    family: ClassVar[EmitterFamily] = EmitterFamily.COMMS
    supported_classes: ClassVar[tuple[str, ...]] = ("chirpss", "lfm_data")

    def __init__(self) -> None: ...

    def schema(self) -> type[BaseModel]: ...

    def generate(
        self,
        *,
        class_label: str,
        sample_rate: float,
        duration_s: float,
        f_offset_hz: float,
        rng: torch.Generator,
        device_id: str | None = None,
        params: BaseModel | None = None,
    ) -> Signal: ...


class TorchSigChirpParams(BaseModel):
    bandwidth_hz: float = Field(default=125e3, gt=0)

Parameter

Type

Units

Default

Purpose

class_label

str

n/a

required

One of "chirpss" or "lfm_data" (see class descriptions below).

sample_rate

float

Hz

required

Samples per second on the output tensor. Must be finite and positive.

duration_s

float

s

required

Output duration. Total sample count is N = round(sample_rate * duration_s).

f_offset_hz

float

Hz

required

Baseband frequency shift in Hz. May be negative, zero, or positive; must be finite.

rng

torch.Generator

n/a

required

Seeds the NumPy random-number generator consumed by the TorchSig builder. State advances once per call.

device_id

str or None

n/a

None

Optional metadata label echoed into Signal.metadata. Never affects IQ.

params.bandwidth_hz

float

Hz

125_000.0

Nominal 3 dB bandwidth passed to the TorchSig builder. Default matches the standard LoRa (Long Range, a low-power wide-area network protocol) channel raster per Semtech SX1276 datasheet §4.1.

The two class labels correspond to different TorchSig builder paths:

Class label

TorchSig builder

Construction

chirpss

chirpss_modulator

CSS (chirp-spread-spectrum, the class of chirp-based spread-spectrum modulation that LoRa belongs to) symbol stream: each symbol is one of 128 cyclic-shifted copies of an upchirp template spanning [-B, +B].

lfm_data

lfm_modulator(lfm_type="data")

Binary LFM (linear frequency modulation) data chirp: each symbol independently selects an upchirp or downchirp with equal probability.

import torch
from rfgen.emitters import TorchSigChirpEmitter

emitter = TorchSigChirpEmitter()
signal = emitter.generate(
    class_label="lfm_data",
    sample_rate=1_000_000.0,   # 1 MHz
    duration_s=0.05,            # 50 ms -> 50,000 samples
    f_offset_hz=0.0,
    rng=torch.Generator().manual_seed(0),
)
# signal.iq has shape (2, 50_000) and dtype torch.float32:
# channel 0 is the in-phase (I) component, channel 1 is the quadrature (Q).
assert signal.iq.shape == (2, 50_000) and signal.iq.dtype == torch.float32

The class taxonomy is ("comms", "chirp", <label>) under EmitterFamily.COMMS. The emitter rejects the radar-side label "lfm_radar" (handled by ChirpRadarEmitter) with EmitterError. Channel-side impairments (noise, multipath, phase noise, IQ imbalance, power-amplifier nonlinearity) are out of scope and assigned to a separate channel layer.

2. What we validated

This validation establishes 8 load-bearing claims. Each is restated and supported by evidence in §3.

  1. Shape, dtype, and metadata contract (§3.1): the output is a (2, N) float32 tensor with the correct family, taxonomy, and sample count for both class labels.

  2. DC-removal contract (§3.2): the emitter subtracts the complex mean so the DC residue is below the rfgen |mean| / |peak| < 1e-4 threshold.

  3. LFM instantaneous-frequency confinement (§3.3): for lfm_data, the instantaneous frequency stays within the [-B/2, +B/2] band (measured: clean 99th-percentile max 62.5 kHz vs 75 kHz threshold at B = 125 kHz).

  4. LFM symbol-map symmetry (§3.4): the equal-probability binary symbol map produces near-zero mean instantaneous frequency over a long record.

  5. Occupied-bandwidth tracking for lfm_data (§3.5): the empirical -3 dB bandwidth tracks the requested bandwidth_hz within 8 % across three operating points.

  6. chirpss occupied bandwidth is approximately 2x nominal (§3.6): the LoRa-style cyclic-shift symbol map causes the -3 dB bandwidth to be 1.7x to 1.9x bandwidth_hz; pinned by a regression test.

  7. Frequency-offset mixing (§3.7): f_offset_hz != 0 shifts the spectral centroid by f_offset_hz within the tolerance max(5 %, 5 kHz).

  8. Determinism and error-path contract (§3.8): same seed produces bit-identical output; the documented error paths surface the correct exception type.

Limits and scope-bounded items appear in §4; full citations are in §5.

3. Evidence per claim

3.1 Shape, dtype, and metadata contract

The emitter produces a (2, N) float32 tensor with N = round(sample_rate * duration_s). Metadata fields family, class_taxonomy, generator_name, sample_rate_hz, bandwidth_hz, realized_carrier_hz, and duration_samples are all populated correctly.

Assertion

Test

Result

sig.iq.shape == (2, N) for 3 (fs, dur) pairs × 2 class labels

test_construct_contract.py::test_shape_and_dtype

Pass (6 parametrized cases)

sig.iq.dtype == torch.float32

same test

Pass

emitter.family is EmitterFamily.COMMS

test_construct_contract.py::test_family_and_classes

Pass

emitter.supported_classes == ("chirpss", "lfm_data")

same test

Pass

sig.metadata.class_taxonomy == ("comms", "chirp", <label>) for both labels

test_construct_contract.py::test_class_taxonomy

Pass (2 parametrized cases)

sig.metadata.family == "comms"

same test

Pass

sig.metadata.generator_name == "TorchSigChirpEmitter"

same test

Pass

3.2 DC-removal contract

The rfgen contract requires |mean(cx)| / max(|cx|) < 1e-4 where cx = I + jQ. The implementation subtracts iq.mean() after the TorchSig builder and any frequency-offset mixing. The measured residues are four to five orders of magnitude below the threshold.

Class

Measured DC/peak ratio

Contract threshold

Test

chirpss

7.09e-09

1e-04

test_construct_contract.py::test_dc_residual_below_contract[chirpss]

lfm_data

3.35e-09

1e-04

test_construct_contract.py::test_dc_residual_below_contract[lfm_data]

3.3 LFM instantaneous-frequency confinement

For lfm_data, each symbol is an upchirp (f0, f1) = (-B/2, +B/2) or a downchirp (+B/2, -B/2) running for samples_per_symbol baseband samples, then polyphase-resampled (a multi-rate filter operation that changes the effective sample rate by a rational ratio) to sample_rate. The LFM phase polynomial is theta(t) = 2*pi*(f0*t + 0.5*(f1-f0)/T_sym*t^2), giving instantaneous frequency f(t) = f0 + (f1-f0)*t/T_sym sweeping linearly within [-B/2, +B/2] (Richards, 2014, Ch. 4). Short transients at symbol boundaries (Fresnel overshoot and phase-derivative estimator artifacts from numpy.unwrap) affect the top 1 % of |f_inst| values; the clean 99th-percentile maximum is the load-bearing measurement.

Measurement

Value

Threshold

Test

Clean 99th-pct max &#124;f_inst&#124; at B = 125 kHz

62.5 kHz

75 kHz (1.2 x B/2)

test_math_fidelity.py::test_lfm_data_inst_freq_stays_within_band

Figure 1 shows the instantaneous-frequency time series for both class labels, with the ±B/2 limits marked.

Figure 1: Instantaneous frequency over time for lfm_data (blue, top) and chirpss (red, bottom) at fs=1 MHz, B=125 kHz. Blue sweeps stay within the dashed ±B/2 = ±62.5 kHz limits; red shows the wider cyclic-shift excursion. Supports the in-band confinement claim (§3.3) for lfm_data and the wider-bandwidth claim (§3.6) for chirpss.

3.4 LFM symbol-map symmetry

The TorchSig lfm_data builder selects the upchirp or downchirp symbol with equal probability (np.array([-1.0, 1.0]) symbol map). Over many symbols, upchirp and downchirp frequency excursions cancel, so the mean instantaneous frequency over a long record must be near zero. The test trims the 1st and 99th percentiles to remove numpy.unwrap artifacts before computing the mean.

Measurement

Value

Threshold

Test

Mean trimmed f_inst over 0.1 s

-61 Hz

max 0.1 × B = 12.5 kHz

test_math_fidelity.py::test_lfm_data_inst_freq_is_symmetric

3.5 Occupied-bandwidth tracking for lfm_data

The claim is that the TorchSig lfm_modulator builder honors its documented “3 dB bandwidth” parameter for the lfm_data path: the empirical -3 dB occupied bandwidth (the width of the contiguous spectral band whose PSD exceeds peak minus 3 dB) tracks bandwidth_hz within 15 %. Vangelista (2017) derives the ideal occupied bandwidth of a CSS symbol stream as approximately BW at the -3 dB point (reference value ≈ bandwidth_hz); these measurements are compared against that reference.

Measurement setup: sample_rate = 2 MHz, duration_s = 0.2 s, scipy.signal.welch (Welch’s method: PSD, or power spectral density, is how signal energy is distributed across frequency; it is estimated by averaging overlapping windowed Fourier transforms) with nperseg = 4096 (~488 Hz bin resolution, ~195 averaged segments), 8 seeds per cell.

Nominal bandwidth_hz

Empirical -3 dB BW mean ± std

Ratio to nominal

Test

50 kHz

47.4 ± 1.0 kHz

0.95

test_psd_bandwidth.py::test_lfm_data_3db_bw_tracks_nominal[50000.0]

100 kHz

95.0 ± 2.1 kHz

0.95

test_psd_bandwidth.py::test_lfm_data_3db_bw_tracks_nominal[100000.0]

200 kHz

184.9 ± 19.0 kHz

0.92

test_psd_bandwidth.py::test_lfm_data_3db_bw_tracks_nominal[200000.0]

The 8 % shortfall (ratio 0.92) at 200 kHz is within the 15 % tolerance. The increased standard deviation at 200 kHz reflects the random samples_per_symbol draw inside the TorchSig builder. The proportional-scaling test test_psd_bandwidth.py::test_bandwidth_scaling_doubles confirms that doubling bandwidth_hz doubles the empirical -3 dB BW within 30 % for both class labels.

Figure 2 shows the Welch-averaged PSD for both class labels at three nominal bandwidths.

Figure 2: Welch-averaged PSD (8-seed mean) for lfm_data (left panel, blues) and chirpss (right panel, reds) at nominal bandwidths 50 kHz, 100 kHz, and 200 kHz (light to dark shading). The dashed horizontal line marks -3 dB. lfm_data -3 dB edges track the nominal bandwidth within 8 %; chirpss -3 dB edges are approximately 2x the nominal. Supports §3.5 (lfm_data tracking) and §3.6 (chirpss wider occupancy).

3.6 chirpss occupied bandwidth is approximately 2x nominal

The chirpss path uses a 128-shift cyclic-permutation symbol map, the LoRa-style SF (spreading factor, SF=7 here, meaning 2^7=128 distinct symbols) map. Each symbol shifts the frequency excursion of an upchirp template that spans [-B, +B] by k × B/128 for shift index k in {0, …, 127}. Because the template spans [-B, +B] rather than [-B/2, +B/2], the occupied bandwidth at the -3 dB point is approximately twice bandwidth_hz. Additionally, the TorchSig chirpss_modulator_baseband function applies a 50 % random LPF (low-pass filter) on each call, which is the dominant source of bandwidth variance at larger nominal bandwidths.

Nominal bandwidth_hz

Empirical -3 dB BW mean ± std

Ratio to nominal

Test

50 kHz

96.2 ± 1.6 kHz

1.92

test_psd_bandwidth.py::test_chirpss_3db_bw_is_wider_than_nominal[50000.0]

100 kHz

192.6 ± 3.2 kHz

1.93

test_psd_bandwidth.py::test_chirpss_3db_bw_is_wider_than_nominal[100000.0]

200 kHz

345.2 ± 80.3 kHz

1.73

test_psd_bandwidth.py::test_chirpss_3db_bw_is_wider_than_nominal[200000.0]

The regression test pins the ratio in [1.4, 2.4] so any future upstream change to chirpss_modulator realized-occupancy semantics is detected automatically. The metadata.bandwidth_hz field records the user’s input value, not the realized occupancy; a scheduler using metadata.bandwidth_hz as a guard band will under-reserve by approximately 2x for chirpss signals.

Figure 3 shows the time-frequency structure of both class labels.

Figure 3: Spectrogram (STFT, short-time Fourier transform: a time-frequency representation obtained by applying the Fourier transform to short overlapping windows) for lfm_data (left) and chirpss (right) at fs=2 MHz, B=125 kHz. Color encodes normalised power in dB (yellow = high, purple = low). White dashed lines mark ±B/2 = ±62.5 kHz. lfm_data sweeps are confined within ±B/2; chirpss symbols extend beyond. Supports the bandwidth contrast between §3.5 and §3.6.

3.7 Frequency-offset mixing

When f_offset_hz != 0, the implementation multiplies the baseband signal by exp(j * 2*pi * f_offset_hz * t) where t = arange(N) / sample_rate. This shifts the spectral centroid of the signal by f_offset_hz. The test verifies the shift using the power-weighted spectral centroid (band-energy center) computed via scipy.signal.welch, which is more robust to per-bin noise than a single PSD-peak bin.

Parameter

Measurement

Expected

Tolerance

Test

Spectral centroid shift at f_offset_hz = 200 kHz

200 kHz

200 kHz

max(5 %, 5 kHz) = 10 kHz

test_math_fidelity.py::test_f_offset_shifts_spectrum

3.8 Determinism and error-path contract

Determinism. The implementation seeds a NumPy RNG (random-number generator) from a single torch.randint draw on the supplied torch.Generator. Same seed gives bit-identical output; different seeds give different output; consecutive calls on the same generator advance the state.

Assertion

Tests

Result

Same seed → bit-identical IQ (both class labels)

test_robustness.py::test_determinism_same_seed[chirpss], test_determinism_same_seed[lfm_data]

Pass

Different seeds → different IQ (both class labels)

test_robustness.py::test_determinism_different_seeds_differ[chirpss], test_determinism_different_seeds_differ[lfm_data]

Pass

Consecutive calls on same generator differ

test_robustness.py::test_consecutive_calls_advance_generator

Pass

Error paths. Every validated error path surfaces the correct exception type.

Input condition

Exception

Test

bandwidth_hz <= 0

pydantic.ValidationError

test_robustness.py::test_bandwidth_must_be_positive (3 values)

Extra schema field

pydantic.ValidationError

test_robustness.py::test_extra_fields_forbidden

bandwidth_hz + 2*&#124;f_offset_hz&#124; >= sample_rate

EmitterError with context fields

test_robustness.py::test_shifted_extent_guard_at_boundary

bandwidth_hz + 2*&#124;f_offset_hz&#124; < sample_rate (just below threshold)

No exception, correct shape

test_robustness.py::test_shifted_extent_guard_passes_just_below

bandwidth_hz > sample_rate / 2

EmitterError (wraps TorchSig ValueError)

test_robustness.py::test_bandwidth_above_nyquist_raises_emitter_error

round(sample_rate * duration_s) == 0

EmitterError

test_robustness.py::test_zero_samples_raises

f_offset_hz = NaN or f_offset_hz = inf

EmitterError (message contains “finite”)

test_robustness.py::test_non_finite_f_offset_raises_emitter_error (2 values)

Unsupported class label (e.g. "lfm_radar")

EmitterError

test_construct_contract.py::test_unsupported_class_raises

4. Limits and what is not validated

chirpss is CSS-family, not LoRa-compliant. The spreading factor SF is effectively fixed at 7 (the 128-shift symbol map is hard-coded in the upstream TorchSig builder); symbol duration is randomized per call rather than determined by T_sym = 2^SF / BW; there is no preamble, sync word, payload, CRC, or whitening; the 50 % random LPF is not part of the LoRa specification. The LoRaPHYEmitter and LoRaSdrEmitter classes in this repository are the LoRa-compliant paths. Exposing spreading factor and symbol time as user-controlled parameters would require switching to a lower-level TorchSig API or patching TorchSig; both are architectural changes deferred if downstream consumers require deterministic LoRa-style symbol timing.

chirpss realized -3 dB bandwidth is approximately 2x bandwidth_hz. The metadata.bandwidth_hz field records the user’s input, not the realized occupancy. A scheduler using metadata.bandwidth_hz as a guard band will under-reserve by approximately 2x for chirpss signals. The class docstring carries a forward reference to this report.

Per-sample envelope is not unit. The TorchSig polyphase resampler introduces amplitude ripple; the DC-subtract step further perturbs per-sample magnitude. Mean power is within 0.15 of unity (test_math_fidelity.py::test_envelope_power_close_to_unity); per-sample |cx|^2 = 1 is not satisfied. The ChirpRadarEmitter is the path for a constant-envelope LFM.

Realized symbol rate is not in metadata. The per-call random samples_per_symbol draw inside both TorchSig builders produces different effective symbol rates across calls with identical parameters and different seeds. The realized symbol rate is neither a user-facing parameter nor a metadata field; exposing it would require patching TorchSig or moving to a lower-level API.

Realized chirpss LPF is invisible. The 50 % random LPF impulse response inside chirpss_modulator_baseband is not exposed. The realized -3 dB bandwidth distribution at fixed bandwidth_hz is bimodal-ish (with vs without the LPF), which accounts for the large standard deviation (80.3 kHz) at B = 200 kHz in §3.6.

Degenerate B / fs corner. When bandwidth_hz / sample_rate is small enough that the baseband sample count rounds to zero (for example bandwidth_hz = 1e-3 at fs = 1 MHz), the TorchSig builder raises a bare ValueError rather than an EmitterError. This regime is outside any realistic operating point.

No transmitter impairments. No power-amplifier compression, no IQ imbalance, no oscillator phase noise, no carrier-frequency drift. All deferred to the channel layer per the rfgen architecture.

No multi-burst or duty-cycle structure. The entire duration_s window is filled with consecutive symbols; there is no PTT (push-to-talk) on/off envelope, no preamble, and no inter-burst silence. Out of scope for a clean-baseband emitter.

Sample-rate range tested. The validation tests cover 250 kHz to 2 MHz. Behavior at extreme sample rates (below 250 kHz or above 2 MHz) is not validated.

5. References

Published works

Reference

Role

L. Vangelista, “Frequency Shift Chirp Modulation: The LoRa Modulation,” IEEE Signal Processing Letters 24(12), Dec. 2017, pp. 1818–1821, doi:10.1109/LSP.2017.2762960

Closed-form CSS analysis; derives symbol-orthogonality and ideal occupied-bandwidth ≈ BW at -3 dB for a single-SF symbol stream; used as reference value in §3.5

Semtech AN1200.22, “LoRa Modulation Basics,” Semtech Corp., 2015

Canonical CSS / LoRa physical-layer reference; defines T_sym = 2^SF / BW, chirp slope BW / T_sym, and the 128-symbol map at spreading factor 7

Semtech SX1276/77/78/79 Datasheet, rev. 7, Semtech Corp., 2020, §4.1

LoRa modulator block diagram; defines the supported-bandwidth set (7.8 kHz to 500 kHz); 125 kHz is the standard channel and the bandwidth_hz default

M. A. Richards, Fundamentals of Radar Signal Processing, 2nd ed., McGraw-Hill, 2014, Ch. 4, ISBN 978-0071798327

LFM phase polynomial and instantaneous-frequency derivation; contrasted with the comms-regime LFM in §3.3

Libraries

PyPI distribution

Installed version

Documentation

Role

torchsig

2.1.1

https://torchsig.readthedocs.io/

Source of chirpss_modulator and lfm_modulator; implements the CSS cyclic-shift symbol map, LFM binary symbol stream, iterative LPF design, and polyphase resampler

scipy

1.18.0

https://docs.scipy.org/doc/scipy/

scipy.signal.welch used in all PSD bandwidth tests; scipy.signal.spectrogram used in Figure 3 generator

numpy

2.4.6

https://numpy.org/doc/stable/

numpy.exp for heterodyne mixing; numpy.unwrap and numpy.diff for the instantaneous-frequency estimator

torch

2.12.1

https://pytorch.org/docs/stable/

torch.Generator for seeded randomness; torch.from_numpy for output tensor construction

pydantic

2.13.4

https://docs.pydantic.dev/latest/

TorchSigChirpParams schema validation (frozen=True, extra="forbid", gt=0 constraint)