Patrick Lidstone
Self-hosted

LoRa chirp IQ modem + CFO/STO synchroniser

A native complex-baseband LoRa modulator/demodulator: chirp-spread-spectrum symbols ⇄ IQ, plus a full carrier- and timing-offset synchroniser robust to crystal-error-scale frequency offsets.

Rafe project · app/radio/lorasdr/modem.py, sdr.py · SDR LoRa receive/transmit


Abstract

This is the physical-waveform layer of Rafe's native LoRa stack: the piece that turns the integer symbol stream produced by the LoRa PHY codec into transmittable complex baseband, and — the hard direction — recovers those symbols from off-air IQ that arrives with an unknown carrier-frequency offset (CFO), an unknown sample-timing offset (STO), and noise. Modulation is textbook chirp spread spectrum (CSS): a base up-chirp, one cyclic shift per symbol, wrapped in the standard LoRa frame (preamble up-chirps, a two-symbol sync word, a 2.25-symbol down-chirp start-of-frame delimiter, then data). Demodulation is the subtle part. The synchroniser detects the preamble by a run of consistent dechirp bins (a test that is immune to CFO because a carrier offset only translates the bin), pulls the fractional CFO out of the phase drift between periodic preamble windows, splits the integer CFO and STO from the up-chirp preamble bin versus the down-chirp SFD bin (b_up = c − s, b_dn = c + s), refines both to sub-bin precision by parabolic interpolation, resolves the inherent N/2 timing ambiguity by which grid hypothesis makes the known sync word ring loudest, and cancels any residual integer CFO implicitly by referencing every data symbol against the preamble bin. Everything is pure NumPy. The estimator is validated against ground truth (injected CFO/τ are recovered exactly) and by full baseband loopback across every SF7–12 × CR4/5–4/8 combination, plus a randomized CFO/STO/noise torture test, with carrier offsets to roughly a quarter of the bandwidth — beyond the ±30 ppm error of the crystals in real 868/915 MHz LoRa nodes.

The novelty for this project is that a complete, interoperable LoRa RX/TX — the kind normally bought as a Semtech transceiver chip or lifted from a GNU Radio out-of-tree module — runs here as ~300 lines of NumPy alongside the SoapySDR capture, decoding real Meshtastic traffic.


1. Scope and context

modem.py sits between two neighbours and is deliberately ignorant of both:

   SoapySDR capture ─▶ resample to 2·BW ─▶ [ modem.py ] ─▶ [ phy.py ] ─▶ bytes
     (sdr.py, out of scope)   (sdr.py)      symbols ⇄ IQ    CSS codec    payload

Downward, it consumes/produces a plain list of integer symbols in [0, 2^SF); the mapping of those symbols to/from payload bytes (Gray coding, whitening, interleaving, Hamming FEC, header, CRC) is the LoRa PHY codec and is not described here. Upward, the SoapySDR/LimeSDR RF capture is out of scope; sdr.py's only in-scope roles — the resampler and the streaming framer glue — are summarised in §8.

This document specifies the waveform and the synchroniser completely enough to reimplement them from scratch. Every constant is quoted from source with a file:line citation; nothing is invented.


2. Background: chirp spread spectrum

2.1 The up-chirp

A LoRa up-chirp is a constant-amplitude complex signal whose instantaneous frequency sweeps linearly across the channel bandwidth BW, from −BW/2 to +BW/2, over one symbol period T_sym = 2^SF / BW. Writing N = 2^SF for the number of frequency steps and normalising time to chips t ∈ [0, N), the instantaneous frequency (in units of BW) is f(t) = t/N − ½ and the phase is its integral. modem.py builds exactly this, oversampled by an integer factor OS:

def _base_upchirp(n, os):                       # modem.py
    m = np.arange(n * os)                        #   n·os samples, one per output sample
    chip = m / os                                #   chip index t ∈ [0, n)
    return np.exp(1j * 2 * np.pi * np.cumsum((chip / n - 0.5) / os))

The cumsum is a discrete integral of the instantaneous frequency (t/N − ½), with the /os accounting for the sub-chip sample spacing. The result is N·OS unit-magnitude samples: the reference up-chirp. The down-chirp is simply its complex conjugate (down = np.conj(up), modem.py), sweeping +BW/2 → −BW/2.

2.2 Dechirp-to-tone

Multiplying any up-chirp by the reference down-chirp (its conjugate) cancels the quadratic phase and leaves a pure complex tone whose frequency encodes the symbol. This is "dechirping". Concretely, up · conj(up) = 1 (DC, bin 0) for the unshifted chirp; a shifted chirp dechirps to a nonzero bin. Symmetrically, a down-chirp is dechirped to a tone by multiplying it by the reference up-chirp. This asymmetry — up-chirps need the down reference, down-chirps need the up reference — is what lets the receiver tell preamble from SFD, and is the lever the whole CFO/STO split pivots on (§6.3).

2.3 A symbol is a cyclic shift

LoRa carries SF bits per symbol by cyclically rotating the base up-chirp. A symbol value s ∈ [0, N) is transmitted as the up-chirp rolled left by s chips (s·OS samples), modem.py:

def _sym(up, s, os):                            # modem.py
    return np.roll(up, -s * os)

Because the chirp folds cyclically through the band, this shift is equivalent to a starting-frequency offset; after dechirping, symbol s produces a tone at FFT bin s. Demodulation is therefore "dechirp, FFT, argmax". The elegance of CSS is that this one operation delivers both the symbol value and enormous processing gain: the matched dechirp collapses a full-bandwidth chirp into a single bin, concentrating N samples of signal energy while noise stays spread — a spreading gain of ≈ 10·log₁₀(N) dB (e.g. ≈ 24 dB at SF8), which is why LoRa (and this sync) work far below 0 dB in-band SNR.


3. Signal structure

3.1 Rates and sizes

quantity definition source
N — bins/symbol 1 << SF (2^SF) modem.py
OS — oversampling round(fs / bw) modem.py
fs — sample rate OS · BW (receiver uses fs = 2·BW, i.e. OS = 2) sdr.py
sps — samples/symbol N · OS modem.py
one FFT bin BW / N Hz (both the N-point folded grid and the N·OS-point raw FFT resolve to this after folding)
T_sym N / BW s (e.g. SF7/BW125k → 1.024 ms, sps = 256 at OS2)

The streaming receiver fixes OS = 2 by resampling the SDR capture to exactly 2·BW (sdr.py, LoRaFramer.rx_rate = 2 * bw). OS = 2 is the minimum that keeps the full ±BW/2 chirp inside Nyquist while leaving alias headroom for the folded FFT; the modulator and demodulator accept any integer OS.

3.2 Frame layout

modulate() (modem.py) concatenates, in order:

┌──────────────────────┬───────────────┬────────────────────┬───────────────────────┐
│  preamble             │  sync word    │  SFD               │  payload              │
│  N_pre × up-chirp     │  up^sw0 up^sw1│  dn dn dn[:sps/4]   │  up^d0 up^d1 …        │
│  (default 8)          │  (2 symbols)  │  (2.25 down-chirps) │  (from phy.encode)    │
└──────────────────────┴───────────────┴────────────────────┴───────────────────────┘
  each block = sps = N·OS samples,  except the SFD tail = sps/4 samples (¼ symbol)
  • Preamblepreamble_len unshifted up-chirps (default PREAMBLE_LEN = 8, modem.py; Meshtastic uses 16, meshtastic.py). A periodic run of identical symbols: the anchor for detection and fractional-CFO estimation.
  • Sync word — two up-chirps shifted by the two nibbles of the sync-word byte (§3.3). A known, per-network code that both gates false frames and calibrates the bin origin.
  • SFD (start-of-frame delimiter) — two full down-chirps plus a quarter down-chirp = 2.25 symbols (modem.py: [down, down, down[:sps//4]]). The polarity flip from up to down is the timing fiducial: it is the only place the receiver can measure b_dn, and hence separate CFO from STO.
  • Payload — one shifted up-chirp per data symbol, up^(s mod N) (modem.py). These symbols come from phy.encode(payload); this layer treats them as opaque integers.
def modulate(symbols, sf, bw, fs, preamble_len=PREAMBLE_LEN,      # modem.py
             sync_word=SYNC_WORD, amp=0.7):
    n = 1 << sf; os = int(round(fs / bw))
    up = _base_upchirp(n, os); down = np.conj(up); sps = n * os
    sw0, sw1 = _sync_symbols(sync_word)
    parts  = [up.copy() for _ in range(preamble_len)]
    parts += [_sym(up, sw0, os), _sym(up, sw1, os)]
    parts += [down.copy(), down.copy(), down[:sps // 4].copy()]   # 2.25 SFD
    parts += [_sym(up, int(s) % n, os) for s in symbols]
    return (np.concatenate(parts) * amp).astype(np.complex64)     # amp default 0.7

The whole frame is scaled by amp = 0.7 (modem.py) to leave DAC/ADC headroom. Total sync overhead in samples is sync_len = (preamble_len + 2)·sps + round(2.25·sps) (modem.py).

3.3 Sync-word encoding

The LoRa sync word is one byte, transmitted as two shifted up-chirps whose symbol values are the two nibbles scaled by 8 (_sync_symbols, modem.py):

def _sync_symbols(sync_word):                   # modem.py
    return ((sync_word >> 4) * 8, (sync_word & 0x0F) * 8)
sync-word byte network high nibble·8 low nibble·8 symbols
0x12 private / default (modem.py) 1·8 2·8 (8, 16)
0x2B Meshtastic (meshtastic.py) 2·8 11·8 (16, 88)
0x34 LoRaWAN public (modem.py) 3·8 4·8 (24, 32)

The default is SYNC_WORD = 0x12 (modem.py). The ×8 spacing keeps the two sync tones well separated on the N-bin grid so a wrong sync word lands its energy in the wrong bins and is rejected (the receiver requires both sync bins to fall within ±1 of their expected positions, §6.5). A frame sent with sync 0x12 does not decode under sync 0x2b (test_lorasdr.py).


4. Modulate — step by step

Given a symbol list and (SF, BW, fs):

  1. Derive sizes. N = 1<<SF, OS = round(fs/bw), sps = N·OS.
  2. Build references. up = _base_upchirp(N, OS); down = conj(up).
  3. Preamble. Append preamble_len copies of up.
  4. Sync word. sw0, sw1 = _sync_symbols(sync_word); append roll(up,−sw0·OS) and roll(up,−sw1·OS).
  5. SFD. Append down, down, and the first sps//4 samples of down (2.25 down-chirps total).
  6. Payload. For each data symbol s, append roll(up, −(s mod N)·OS).
  7. Assemble. Concatenate, multiply by amp = 0.7, cast to complex64.

The output is contiguous complex baseband at fs, ready to hand to a TX resampler (§8) or to the loopback test harness.


5. The demod primitives

Four small helpers underpin the synchroniser. Getting these exactly right is the whole game.

5.1 Dechirp + alias-folded FFT (_fold_fft, modem.py)

def _fold_fft(x, ref, n, os):                   # modem.py
    S = np.fft.fft(x * ref)                       # dechirp, then N·OS-point FFT
    folded = np.abs(S).reshape(os, n).sum(axis=0) # non-coherent fold of OS aliases
    return int(np.argmax(folded)), folded, S

x is one sps-sample window; ref is the down-chirp (to dechirp up-chirps) or the up-chirp (to dechirp down-chirps). The N·OS-point FFT of the dechirped window places the symbol tone in one of N fundamental bins, but oversampling replicates that tone at OS alias positions spaced N bins apart. Reshaping the magnitude spectrum to (OS, N) and summing down the OS axis folds those aliases back onto the N-bin symbol grid non-coherently, maximising the peak before argmax. The function returns the peak bin, the folded N-length magnitude vector, and the raw complex FFT (the latter is needed for the phase-based fractional-CFO steps).

_dechirp(sig, a, up_ref) (modem.py) is the windowed wrapper: it slices sig[a:a+sps], returns None if a<0 or the window is short, and otherwise calls _fold_fft with up when up_ref=True, else down.

5.2 Sub-bin parabolic peak (_interp_peak, modem.py)

Quadratic interpolation over the peak bin b and its two folded neighbours refines the peak to sub-bin resolution:

def _interp_peak(folded, b, n):                 # modem.py
    fm, f0, fp = folded[(b-1) % n], folded[b], folded[(b+1) % n]
    den = 2*f0 - fm - fp
    if den <= 0:
        return float(b)
    return b + 0.5 * (fp - fm) / den

Sub-bin accuracy is not cosmetic: the timing estimate s is a difference of two bins, and a bin-quantised s leaves a sub-OS-sample residual that puts every data-symbol argmax on a rounding boundary (modem.py). Interpolating b_up and b_dn before the split keeps the realigned grid off those boundaries.

5.3 Signed and circular modular arithmetic

def _smod(x, n):                                # modem.py  -> nearest-signed residue in (−n/2, n/2]
    x = x % n
    return x - n if x >= n / 2 else x
def _bdist(a, b, n):                            # modem.py  -> circular bin distance
    d = (a - b) % n
    return min(d, n - d)

_smod maps a bin difference to its shortest signed representative (used to unwrap CFO/STO around zero and around the N/2 alias). _bdist is the wrap-aware distance used to test "is this window's peak the same bin as the run?".


6. Demodulate + synchronise — step by step

The receiver models the captured baseband as the transmitted frame subjected to an unknown carrier offset and an unknown delay:

   r[m] ≈ s[m − τ] · exp( j·2π · f_c · m / fs )   +  noise

with CFO f_c (Hz) and STO τ (samples). Expressed in FFT bins:

  • CFO in symbol-bins: λ = f_c · N / BW = c + δ, integer part c, fraction δ ∈ (−½, ½].
  • STO in bins: σ = τ / OS = s + ε (one bin of dechirp shift = OS samples of delay).

The synchroniser recovers (δ, c, s, ε) in stages and never needs f_c or τ in physical units. demodulate() (modem.py) loops: detect a frame, estimate and correct offsets, demodulate its symbols, advance past it, repeat.

6.1 Preamble detection — and why it is CFO-immune (_detect, modem.py)

Slide one symbol (sps samples) at a time, dechirping each window with the down-chirp. A window is "strong" if its folded peak exceeds 3.0 × the mean folded magnitude (modem.py). A run of consecutive strong windows whose peak bins all agree to within _bdist ≤ 1 (modem.py) is the preamble; once the run reaches need = preamble_len − 2 windows (modem.py), detection returns the run's start sample g0 and its peak bin pbin (modem.py).

strong = folded[b] > 3.0 * folded.mean()                        # modem.py
if strong and run_len and _bdist(b, run_bin, n) <= 1:           # modem.py
    run_len += 1
    if run_len >= need:                                          # need = preamble_len - 2
        return run_start, run_bin

Why detection precedes CFO estimation: a carrier offset shifts every dechirped preamble window by the same λ bins. It changes which bin the run sits on, but not the fact that consecutive preamble windows land on the same bin. So the "consistent-bin run" test fires regardless of CFO — detection is CFO-immune by construction, which is exactly why it can run first, before any offset is known. The −2 slack in need tolerates the run's first and last windows straddling the preamble edges.

6.2 Fractional CFO from preamble phase drift (_frac_cfo, modem.py)

The preamble is periodic with period sps. Dechirp windows k = 1…5 (skipping window 0, which may straddle the preamble start, modem.py), pick the raw-FFT alias bin qsel carrying the most energy for pbin (modem.py), and collect the complex phasors z_k = S[qsel]. The fractional CFO in bins is the average inter-window phase advance:

acc = sum(np.conj(zs[k]) * zs[k+1] for k in range(len(zs)-1))    # modem.py
return float(np.angle(acc) / (2 * np.pi))                         # modem.py  -> δ (bins)

The key subtlety. Because the preamble repeats identical samples, the phase step from one window to the next is driven only by the CFO, which rotates continuously across a whole symbol: exp(j·2π·f_c·sps/fs) = exp(j·2π·λ) = exp(j·2π·δ), since the integer part c contributes an exact multiple of and vanishes. Therefore:

  • the measured inter-window phase is 2π·δ — the fractional CFO, directly;
  • it is blind to timing — a delayed periodic signal is still the same periodic signal, so ε leaves the inter-window phase untouched (modem.py).

The periodic preamble thus yields only the fractional CFO. The integer CFO and all of the timing come from later stages. The estimate δ is removed by multiplying the tail W = sig[g0:] by exp(−j·2π·δ·m/sps) (modem.py).

6.3 Integer CFO/STO split from up vs down bins

On the frequency-corrected tail W, measure two bins to sub-bin precision:

  • b_up — the preamble up-chirp bin, dechirped with the down reference and averaged over windows 1…5, each parabola-interpolated and unwrapped around an anchor with _smod (_preamble_bin, modem.py).
  • b_dn — the SFD down-chirp bin, dechirped with the up reference. The SFD is located as the first window where the up-reference peak exceeds 1.3 × the down-reference peak (modem.py); the stronger of that window and the next is kept (the first hit may straddle the sync/SFD boundary, modem.py) and its peak is parabola-interpolated (modem.py).

Because an up-chirp's frequency rises with time while a down-chirp's falls, a delay shifts their dechirp tones in opposite directions, whereas a carrier offset shifts both the same way. Hence:

   b_up = (c − s) mod N          b_dn = (c + s) mod N
   ─────────────────────────────────────────────────
   c = ( b_up + b_dn ) / 2   (integer CFO, bins)
   s = ( b_dn − b_up ) / 2   (integer STO, bins)

The code needs only s explicitly (modem.py):

s0 = _smod(b_dn - b_up, n) / 2.0                                 # modem.py

c is never computed as a number — it is absorbed later by referencing every symbol to the preamble bin (§6.6). The timing is applied by realigning the sample grid: the candidate offset handed to _try_grid is τ = round(s·OS) samples (modem.py), converting a bin shift back to samples.

6.4 The N/2 ambiguity and its arbitration

s = (b_dn − b_up)/2 is computed from a difference known only mod N, so dividing by two admits two solutions a half-grid apart: s0 and s0 + N/2. This is the classic LoRa integer-timing ambiguity. Both hypotheses are evaluated and the winner is the one that makes the known sync word ring loudest (modem.py):

for s in (s0, _smod(s0 + n / 2.0, n)):                           # modem.py
    syms, data0, q = self._try_grid(W, int(round(s * self.os)))  # modem.py
    if syms:
        cands.append((q, syms, data0))
if cands:
    _, syms, data0 = max(cands, key=lambda c: c[0])              # modem.py  -> keep strongest

Why quality arbitrates correctly. The wrong (N/2-alias) grid is offset by half a symbol, so its dechirp windows straddle symbol boundaries; each window then splits its energy between two tones and the folded peaks are weaker. The true grid keeps each symbol whole, so its sync-word peaks are strongest. quality is exactly the sum of the two sync-word folded peak magnitudes (modem.py), and max(...) picks the true grid — an energy-locked grid decision.

6.5 Per-grid verification and demod (_try_grid, modem.py)

For a candidate sample offset τ, on the realigned grid base = τ (wrapped positive, modem.py):

  1. Find the SFD window md — the first window where the up-reference peak exceeds 1.5 × the down-reference peak (modem.py); reject if md is absent or < 3 (modem.py), since there must be room for the preamble/sync windows before it.
  2. Second fractional-CFO pass on the aligned grid (_grid_frac, modem.py) — the same periodic-preamble phase-drift measurement as §6.2 but over the grid's own preamble windows (range(max(0,md−7), md−2), modem.py), a low-SNR refinement. If the residual |d2| > 0.02 bins, W is de-rotated by it (modem.py). A ±1-bin ambiguity here is harmless because the shift is common to reference, sync and data windows alike.
  3. Read three known windows relative to the SFD: rp = md−3 (last plain preamble), r1 = md−2 (sync word 1), r2 = md−1 (sync word 2) (modem.py). Set the demod origin ref = rp[0].
  4. Sync-word gate + bin calibration. With the sync symbols known, their measured offset from ref is a residual constant-shift calibration (modem.py):
    e1 = _smod(r1[0] - (ref + self.sync[0]), n)                   # modem.py
    e2 = _smod(r2[0] - (ref + self.sync[1]), n)
    if abs(e1) > 1 or abs(e2) > 1 or abs(e1 - e2) > 1:            # modem.py  reject frame
        return None, None, 0.0
    cal = int(round((e1 + e2) / 2.0))                             # modem.py
    Both sync bins must land within ±1 of their expected positions (and agree with each other to ±1), or the frame is rejected — this is the sync-word/network filter. The averaged residual cal absorbs any leftover constant bin shift.
  5. Demodulate. Data starts at data0 = base + md·sps + round(2.25·sps) (past the 2.25-symbol SFD, modem.py). Each data window is dechirped with the down reference and its symbol recovered as (peak_bin − ref − cal) mod N (modem.py), up to 300 symbols (modem.py).

6.6 Residual-CFO cancellation by preamble reference

Note what step 5 does not do: it never adds back an integer CFO c. Each data symbol is (peak − ref − cal) mod N, and ref is the preamble bin measured on the same corrected grid. Any residual constant bin offset — an uncorrected integer CFO c, an SFO-induced bias, a half-bin rounding — sits identically in ref, in the sync words (hence in cal), and in every data peak. Subtracting ref + cal cancels it exactly, without ever estimating c explicitly (modem.py). This is the payoff of the whole design: the frame is self-referencing, so only differences of bins matter and the absolute carrier offset drops out.

6.7 Frame advance

On success the loop appends the symbols and advances pos to just past the frame: pos = g0 + data0 + len(syms)·sps (modem.py); on failure it steps past the preamble (pos = g0 + preamble_len·sps, modem.py) and re-detects. This lets one demodulate() call return several frames from one buffer.


7. Constants and tables

constant value source
PREAMBLE_LEN (default up-chirps) 8 modem.py
SYNC_WORD (default) 0x12 (private) → symbols (8, 16) modem.py
sync-word encoding (byte>>4)·8 , (byte&0x0F)·8 modem.py
SFD 2.25 down-chirps: [down, down, down[:sps//4]] modem.py
amplitude scale amp = 0.7 modem.py
oversampling (RX) OS = 2 (rx_rate = 2·BW) sdr.py
detection threshold folded peak > 3.0 × mean modem.py
detection run length preamble_len − 2 modem.py
bin-consistency tolerance _bdist ≤ 1 modem.py
SFD up/down ratio (detect) > 1.3× (main), > 1.5× (_try_grid) modem.py
fractional-CFO windows k = 1…5 modem.py
grid residual-CFO threshold de-rotate if ` d2
sync-word acceptance ` e1
max data symbols/frame 300 modem.py
sync_len (overhead samples) (preamble_len+2)·sps + round(2.25·sps) modem.py

Meshtastic profile (meshtastic.py): sync word MESHTASTIC_SYNC = 0x2B → symbols (16, 88), MESHTASTIC_PREAMBLE = 16, MESHTASTIC_CR = 1 (4/5). Modem presets (SF, BW) live in PRESETS (meshtastic.py): ShortTurbo (7, 500k), ShortFast (7, 250k), ShortSlow (8, 250k), MediumFast (9, 250k), MediumSlow (10, 250k), LongFast (11, 250k) (default), LongModerate (11, 125k), LongSlow (12, 125k).


8. sdr.py glue (summary)

The SoapySDR/LimeSDR RF path is out of scope; two pieces of sdr.py are in scope.

_resample(iq, fs_in, fs_out) (sdr.py). Rational resampler between the SDR capture rate and the modem's 2·BW. Uses SciPy resample_poly (polyphase, up/down = fs_out/fs_in reduced by their GCD) when SciPy is present, else falls back to linear np.interp on real and imaginary parts separately (sdr.py). Returns the input unchanged when the rates already match.

LoRaFramer (sdr.py). Streaming adapter that owns a modem.Demodulator and a LoRaPHY. It fixes rx_rate = 2·bw (sdr.py), buffers incoming IQ after resampling, and demods once the buffer holds a trigger's worth of samples (_trigger = sync_len + max(chunk_symbols, 260)·sps, sdr.py), then keeps a one-frame tail (_keep = sync_len + 260·sps, sdr.py) so a packet straddling a chunk boundary is never split — bounded-latency streaming. Decoded symbol vectors go to phy.decode(); successful payloads are emitted as dicts (with optional Meshtastic decode) via the on_packet callback (sdr.py). Transmit is the mirror: build_tx_iq() modulates at 2·BW, resamples up to the SDR rate, and pads with silence (sdr.py).


9. Interoperability and validation

The modem is validated in test_lorasdr.py at four increasing levels of stress; all run in the project's pure-NumPy test suite.

9.1 Symbol-exact loopback across the full matrix

test_iq_loopback_all_sf_cr (test_lorasdr.py) modulates and demodulates a real payload for every SF ∈ {7,8,9,10,11,12} × CR ∈ {4/5,4/6,4/7,4/8} at BW125k, fs = 250k (OS2), with leading/trailing silence, and asserts byte-exact recovery with CRC OK. test_tx_iq_loopback repeats the check through the full build_tx_iq → LoRaFramer path at the SDR sample rate (1 MHz), and test_tx_sync_word_isolation confirms a 0x12 frame does not decode under sync 0x2b.

9.2 CFO/STO robustness — deterministic sweep

test_iq_cfo_sto_robustness (test_lorasdr.py) injects a carrier offset base · exp(j·2π·f_c·m/fs) and a leading sample offset, then requires exact decode over the cross product of

  • CFO f_c ∈ {0, 200, −900, 3333, −12000, 25000, −28000} Hz, and
  • STO off ∈ {0, 137, 511, 1023} samples,

at SF8/BW125k/OS2. The extreme ±28 kHz is ±22.4 % of the 125 kHz bandwidth; the modem's design target (modem.py) is ~BW/4 (≈ ±31 kHz), which is beyond the ±30 ppm crystal error of a real 868 MHz node (± ~26 kHz). Passing this sweep is the proof that the up/down-bin CFO/STO split and the N/2 arbitration are correct: the estimator recovers the injected (f_c, τ) well enough to land every symbol on its bin.

9.3 Randomized torture test with noise

test_iq_random_torture (test_lorasdr.py) runs 25 randomized trials (range(25), generator seed 7) at SF8/BW125k/OS2, each with:

  • CFO f_c ~ Uniform(−28000, +28000) Hz,
  • STO off ~ Uniform{0…2048} samples,
  • a random 4–40-byte payload and random CR ∈ {4/5…4/8}, and
  • additive complex Gaussian noise σ = 0.2 per component (test_lorasdr.py) against the amp = 0.7 signal — a per-sample SNR of 0.49 / (2·0.2²) ≈ 6.1 (≈ +8 dB), which the SF8 dechirp lifts by its ≈ 24 dB spreading gain, giving the synchroniser a wide margin and letting correct sync and demod extend well below 0 dB. Every trial must decode byte-exact with CRC OK.

9.4 PHY interop anchor

The layer below is pinned to an external reference: test_decode_reference_vector (test_lorasdr.py) decodes the SF12/BW125k symbol vector published by jkadbear/LoRaPHY to the payload 01 02 … 09 with the expected CRC BA 2E, confirming byte-level interoperability with real Semtech LoRa.

9.5 Offline capture path

test_sdr_offline_chain (test_lorasdr.py) writes a modulated + resampled frame to a .cf32 file and decodes it back through sdr.decode_iq_file, exercising the streaming framer and resampler end to end without hardware — the same entry point used to validate against real recorded LimeSDR captures.


10. Limitations and future work

  • Sampling-frequency offset (SFO) on long frames. The synchroniser corrects CFO and STO but assumes the TX and RX sample clocks run at the same rate. A residual SFO makes the effective symbol boundary drift slowly through the frame; over a short SF7 packet this is negligible, but a maximum-length SF11/SF12 frame spans thousands of symbols and the accumulated drift can eventually walk a symbol off its bin. The self-referencing demod (§6.6) absorbs a constant residual but not a ramp. This is the acknowledged remaining refinement (modem.py); the classical fix is a per-symbol timing track or an SFO estimate from the preamble-to-SFD phase slope.
  • Single-frame, single-path. The detector finds one preamble at a time and assumes a clean channel; there is no multipath equalisation and no handling of two overlapping transmissions on the same channel (the LoRa capture effect is not modelled).
  • Fixed OS = 2 in the streaming path. The modem math is OS-agnostic, but the LoRaFramer hard-wires rx_rate = 2·BW; a higher OS would improve sub-bin timing at proportional CPU cost.
  • Detection threshold is a fixed multiple of the mean. The 3.0×/1.3×/1.5× ratios (§7) are robust in the tested regime but are not adaptive to strongly coloured interference; a CFAR-style threshold would harden detection in a busy band.

None of these affect the correctness demonstrated in §9 for the SF/CR/CFO/STO ranges tested; they bound where the current estimator stops being exact.


11. References

  1. Semtech, AN1200.22 — LoRa™ Modulation Basics, 2015 — up-chirp definition, frame structure, preamble/sync/SFD.
  2. L. Vangelista, "Frequency Shift Chirp Modulation: The LoRa Modulation," IEEE Signal Processing Letters, 2017 — the CSS symbol = cyclic-shift result and the dechirp-to-tone demodulator.
  3. C. Bernier, F. Dehmas, N. Deparis, "Low Complexity LoRa Frame Synchronization for Ultra-Low-Power Software-Defined Radios," IEEE Trans. Communications, 2020 — the up-chirp-preamble vs down-chirp-SFD bin method for jointly estimating integer CFO and STO, and the N/2 timing ambiguity.
  4. J. Tapparel et al., "An Open-Source LoRa Physical Layer Prototype on GNU Radio" (gr-lora_sdr), 2020 — reference synchroniser structure and the alias-folded FFT demod at OS > 1.
  5. jkadbear/LoRaPHY — the MATLAB/Python reference implementation and the SF12 interop symbol vector pinned in test_lorasdr.py.
  6. Meshtastic firmware, RadioLibInterface — the 0x2B sync word, 16-symbol preamble and CR 4/5 on-air constants (meshtastic.py).
  7. LoRa PHY codec — the symbol⇄byte layer this modem feeds; and ../rvqvoice.md — the depth/structure template for this document.