Synchronization algorithm (Xhonneux et al. 2021)
The receiver's synchronization stage is a faithful implementation of
Algorithm 1 of M. Xhonneux, O. Afisiadis, D. Bol and J. Louveaux,
A Low-Complexity LoRa Synchronization Algorithm Robust to Sampling Time
Offsets, IEEE Internet of Things Journal, 2021
(arXiv:1912.11344). All code lives in
softlora/sync.py; every estimator carries its equation number from the
paper.
The paper models the received signal as
with a carrier frequency offset \(\Delta f_c = \frac{B}{N}\,(L_\text{CFO} + \lambda_\text{CFO})\) and a sampling time offset \(\tau = \frac{1}{B}\,(L_\text{STO} + \lambda_\text{STO})\). \(L_*\) and \(\lambda_*\) are the integer and fractional parts. The key property used throughout is that the two fractional offsets are not equivalent: the CFO adds a phase that is continuous across symbol boundaries, while the fractional STO resets to \(-2\pi\lambda_\text{STO}\) at each boundary (Eq. 8-9, Fig. 5).
Stage 1 — preamble detection and fractional CFO (Eq. 14)
stage1_detect_and_cfo slides N-sample windows, dechirps each with the
reference downchirp and computes, for every window that has a predecessor,
with s the argmax bin. A preamble is declared when N_detect consecutive
windows peak within one bin. The fractional CFO is then the phase of the sum
of the last two bin-product vectors:
Because the CFO phase accumulates by \(2\pi\lambda_\text{CFO}\) per symbol, this estimate is independent of the STO (Section IV-A).
Stage 2 — first correction of the fractional STO (Eq. 20)
stage2_preliminary_sto dechirps the N_detect windows following detection
with the fractional CFO removed (phase ramp
\(e^{-j2\pi\hat\lambda_\text{CFO}\, n/N}\) with n the absolute sample
index, so the three spectra add coherently) and averages them into Y_avg.
\(\tilde s_\text{up}\) is taken from the first of those windows (Algorithm
1 step 12), giving \(\tilde M = N - \tilde s_\text{up}\), and the preliminary
fractional STO is
with \(M = \tilde M\) and \(i = \tilde s_\text{up}\). The paper then "realigns the receiver by \(\tilde\lambda_\text{STO}\) samples"; in this decoder that intermediate realignment is folded into the final timing correction (it cancels against the \(-\tilde\lambda_\text{STO}\) of Algorithm 1 step 22), so only the net correction is applied.
Stage 3 — integer offsets and definitive fractional STO (Eq. 17, 18, 20)
stage3_final_sync demodulates the final full preamble upchirp (window
\(l+4\), i.e. U7) and the first SFD downchirp (window
\(l + (N_\text{preamble\_up} - N_\text{detect}) + N_\text{netid} + 1\), which
is \(l+8\) for the standard 8-upchirp preamble), again with the fractional CFO
removed. The integer offsets follow from their peak bins (Eq. 15-16):
The definitive fractional STO is Eq. 20 again, this time evaluated on the
stored Y_avg with the exact \(\hat M = N - \hat L_\text{STO}\).
The receiver then removes the carrier offset by shifting the signal in frequency by \(-\frac{B}{N}(\hat L_\text{CFO} + \hat\lambda_\text{CFO})\) and slices the payload at
which is the physical payload position implied by the paper's realignment
steps (the paper's \(+N/4\) term is absorbed by the frame-origin convention of
its window indexing). Eq. 18 leaves \(\hat L_\text{STO}\) in \([0, N)\), but a
free-running scan has an arbitrary grid phase: a slightly negative STO comes
back as \(\approx N\) and would push the payload a full symbol early. The
decoder therefore unwraps it with \(\Gamma_N\) into \([-N/2, N/2)\) before
subtracting (\(\text{total\_sto} = \Gamma_N(L_\text{STO}, N) + \lambda_\text{STO}\)
in LoRaDecoder.sync).
Disambiguation of the N/2 boundary
When the estimated integer STO lands exactly on the wrap boundary
\(\hat L_\text{STO} = N/2\), the single D1 window has demodulated at the
upchirp peak (a sign that the receiver grid is misaligned with the frame and
the D1 window straddles the net-id/SFD boundary). The decoder re-estimates
the integer offsets from the second SFD downchirp (D2) to disambiguate
(see LoRaDecoder.sync in decoder.py). This is a robustness measure of
the scanning receiver; the estimators in sync.py are unchanged.
Preamble-length generalization
The paper assumes a standard 8-upchirp, 2-net-id preamble where D1 is window
\(l+8\). Real captures (e.g. dataset/lora_capture2.dat) may carry more
upchirps, so the D1 window is computed as
\(l + (N_\text{preamble\_up} - N_\text{detect}) + N_\text{netid} + 1\), which
reduces to \(l+8\) for the standard preamble and keeps the exact window
indexing otherwise.
The final-upchirp window is likewise generalized. Algorithm 1 uses U7
(window \(l+4\)); the implementation uses
\(l + \min(4,\, N_\text{preamble\_up} - N_\text{detect})\), which is U7 whenever
the preamble has at least 7 upchirps (unchanged behavior) and the last
upchirp otherwise. This makes preambles with 6 upchirps (e.g.
6 up + 2 sync + 2.25 SFD, a common short configuration) decode correctly —
configure the decoder with N_preamble_up=6. Six upchirps is the practical
minimum: stage 2 averages the U4-U6 windows, so fewer upchirps would leak a
net-id symbol into the Y_avg estimate.
Validation
- Estimator tests (
tests/test_sync_paper.py): Eq. 19 is the \(\hat M = 0\) case of Eq. 20; Eq. 17/18 recover every \((L_\text{CFO}, L_\text{STO})\) pair; Eq. 14 recovers a noiseless fractional CFO exactly (also under an STO); Eq. 20 recovers a noiseless fractional STO; and the full pipeline decodes synthetic packets with injected integer/fractional CFO and STO. - gr-lora_sdr packets (
scripts/gr_packet_test.py): packets generated by gr-lora_sdr's own TX (SF 7-10, code rates 1/2/4, with/without CRC, with and without injected offsets) decode with the correct payload and CRC; payloads are cross-checked against gr-lora_sdr's RX chain. - Over-the-air packets (
scripts/ota_test.py): the 4 packets ofdataset/lora_capture2.datdecode at the reference positions with "Hi from Shayan" and a valid CRC; gr-lora_sdr's RX reports the same payloads.