LoRa and BLE Emitter Algorithms

IoT waveform synthesis for LoRa CSS and BLE GFSK families.

Note

Layer 3 shipped, Pass-1. The Layer 3 implementation (emitters, device-fingerprint, tx-impairments, propagation, rx-frontend) landed on branch rfgen-impl-2026-06-25-105955 (PR #94). The class names, Pydantic schemas, and Transformation enum members referenced below match the shipped surface; Pass-1 stubs (GNU Radio OOT emitters, cellular emitters, Sionna propagation backends) construct cleanly and raise an EmitterError or ChannelError tagged with stage="pass1_stub" until backend wiring lands. See Reference / rfgen.emitters and Reference / rfgen.channels for the shipped class roster.

3. LoRa CSS (Semtech AN1200.22)

Family. iot.lora. Implementation: custom NumPy + cross-checked against gr-lora_sdr (GPL, reference only).

Modulation. Chirp-Spread-Spectrum. A symbol is one of 2^SF cyclic shifts of a base up-chirp.

def generate_lora(*, class_label, sample_rate, duration_s, params, rng):
    """
    class_label: e.g. "iot.lora.css.sf7.bw125.cr-4-5"
    params:
        sf: int                   # 7..12
        bw: int                   # 125000, 250000, 500000 (Hz)
        cr: str                   # "4-5", "4-6", "4-7", "4-8"
        sync_word: int = 0x12     # 1 byte
        preamble_symbols: int = 8
        payload_bytes: bytes | None
        explicit_header: bool = True
    """
    sf, bw, cr = params["sf"], params["bw"], params["cr"]
    n_per_symbol = (2 ** sf) * (sample_rate / bw)  # at emitter native rate R_emitter

    # Base up-chirp at LoRa rate
    t = torch.arange(int(round(n_per_symbol))) / sample_rate
    f_inst = -bw/2 + bw * t * (sample_rate / n_per_symbol) / sample_rate
    base_chirp = torch.exp(1j * 2 * torch.pi * torch.cumsum(f_inst, dim=0) / sample_rate)

    # Whitening + interleaving + Hamming + Gray + cyclic shift per LoRa frame structure
    # See AN1200.22 §4 for the full encoding chain; this is a sketch.
    payload = params.get("payload_bytes") or _draw_random_payload(20, rng)
    symbols = _lora_encode_symbols(payload, sf, cr, params["sync_word"],
                                    params["explicit_header"])

    # Concatenate: preamble (up-chirps) + sync (downchirps) + 2.25 down-chirps + symbols
    chirps = []
    for _ in range(params["preamble_symbols"]):
        chirps.append(base_chirp)  # 8 up-chirps
    chirps.extend(_sync_word_chirps(params["sync_word"], base_chirp))
    chirps.append(_downchirp(base_chirp))
    chirps.append(_downchirp(base_chirp))
    chirps.append(_downchirp(base_chirp)[:int(0.25 * n_per_symbol)])
    for sym in symbols:
        # Cyclic-shift the up-chirp by `sym` samples (out of 2^SF)
        shift = int(round(sym * n_per_symbol / (2 ** sf)))
        chirps.append(torch.roll(base_chirp, shifts=-shift))

    iq = torch.cat(chirps)
    iq = _resample_to(iq, target_len=int(round(sample_rate * duration_s)))
    return Signal(iq=iq, metadata=SignalMetadata(bandwidth_hz=float(bw), ...))

Reference. Semtech AN1200.22 (“LoRa Modulation Basics”); tapparelj/gr-lora_sdr v0.5.8 for cross-checking. The framework’s Python re-implementation is independent (algorithm in the public technical literature).

Verification. Round-trip a known payload through our encoder + a gr-lora_sdr decoder; reverse with gr-lora_sdr encoder + our decoder. Both must recover the payload bit-exact.


4. BLE GFSK

Family. iot.ble. BLE 5.4 PHY: LE 1M, LE 2M, Coded S=2, Coded S=8.

def generate_ble(*, class_label, sample_rate, duration_s, params, rng):
    """
    class_label: "iot.ble.gfsk.{1m,2m,coded_s2,coded_s8}.{adv,data}"
    params:
        channel_index: int       # 0..39 (37/38/39 are advertising)
        access_address: int      # 32 bits, 0x8E89BED6 for advertising
        pdu_bytes: bytes
        bt: float = 0.5          # Gaussian filter BT product
    """
    phy = class_label.split(".")[3]   # "1m" / "2m" / "coded_s2" / "coded_s8"
    if phy == "1m":
        bit_rate = 1e6
    elif phy == "2m":
        bit_rate = 2e6
    elif phy == "coded_s2":
        bit_rate = 1e6  # coded with S=2 (FEC R=1/2)
    elif phy == "coded_s8":
        bit_rate = 1e6  # coded with S=8

    h = 0.5  # modulation index for GFSK
    bt = params["bt"]
    samples_per_bit = sample_rate / bit_rate

    # Build bitstream: preamble + access address + PDU + CRC
    bits = _ble_build_bitstream(
        access_address=params["access_address"],
        pdu=params["pdu_bytes"],
        coded=(phy.startswith("coded")),
        S=2 if phy == "coded_s2" else 8 if phy == "coded_s8" else 1,
    )

    # NRZ-encode: 0 → -1, 1 → +1
    nrz = torch.tensor([2 * b - 1 for b in bits], dtype=torch.float32)

    # Upsample
    upsampled = nrz.repeat_interleave(int(round(samples_per_bit)))

    # Gaussian filter (BT = 0.5)
    gauss = _gaussian_filter(bt=bt, samples_per_bit=samples_per_bit, span_bits=4)
    filtered = torch.nn.functional.conv1d(
        upsampled.view(1, 1, -1), gauss.view(1, 1, -1), padding=len(gauss) // 2
    ).view(-1)

    # Integrate to get phase, modulate
    phase = torch.cumsum(filtered, dim=0) * (2 * torch.pi * h * bit_rate / sample_rate)
    iq = torch.exp(1j * phase)
    iq = _resample_to(iq, target_len=int(round(sample_rate * duration_s)))

    bw = bit_rate * (1 + bt)  # approx 99% occupied bandwidth for GFSK
    return Signal(iq=iq, metadata=SignalMetadata(bandwidth_hz=bw, ...))


def _gaussian_filter(bt: float, samples_per_bit: float, span_bits: int) -> torch.Tensor:
    """Gaussian impulse response, normalized to unit area."""
    sigma = math.sqrt(math.log(2) / 2) / (2 * math.pi * bt)
    n = int(round(span_bits * samples_per_bit))
    t = (torch.arange(n) - n / 2) / samples_per_bit
    h = torch.exp(-t * t / (2 * sigma * sigma))
    return h / h.sum()

Channel index → carrier offset:

Channel

Frequency (MHz)

Use

37

2402

advertising

0…10

2404…2424

data

38

2426

advertising

11…36

2428…2478

data

39

2480

advertising

The framework places the carrier offset relative to scene center_freq_hz.


See Also

References

LoRa CSS encoding is documented in Semtech’s public application note (proprietary header / sync details cross-checked against gr-lora_sdr). BLE GFSK follows the Bluetooth Core specification. Both PHYs are re-implemented from the public technical literature.

  1. Semtech Corporation. AN1200.22 LoRa Modulation Basics, 2015. (Chirp-Spread-Spectrum symbol mapping; SF-to-symbol rate; preamble + sync-word + downchirp frame layout)

  2. Reynders, B. and Pollin, S. Chirp Spread Spectrum as a Modulation Technique for Long Range Communication, Symposium on Communications and Vehicular Technology, 2016. https://doi.org/10.1109/SCVT.2016.7797659. (CSS bit-rate identity, demodulator de-chirp + FFT analysis used by the cross-check decoder)

  3. Tapparel, J. et al. gr-lora_sdr: An end-to-end LoRa transceiver in GNU Radio, EPFL TCL, v0.5.8. https://github.com/tapparelj/gr-lora_sdr. (Whitening, interleaving, Hamming, Gray-coded cyclic-shift; reference encoder used for round-trip cross-check)

  4. Bluetooth SIG. Bluetooth Core Specification 5.4, Vol 6 Part B (Link Layer Specification), 2023. https://www.bluetooth.com/specifications/specs/core-specification-5-4/. (LE 1M / 2M / Coded S=2 / S=8 PHY definitions; channel index 0…39, advertising channels 37/38/39 at 2402 / 2426 / 2480 MHz; access address 0x8E89BED6 for advertising)

  5. Bluetooth SIG. Bluetooth Core Specification 5.4, Vol 6 Part A (Physical Layer Specification), 2023. (GFSK with BT = 0.5 Gaussian filter, h = 0.5 modulation index; preamble + access address + PDU + CRC bitstream)