oljasz.com / field notes
Entry 014 of 016 / D Radio
Warsaw
Last entry 07.07.2026
No. 014SDR for SIGINT / Part 3 of 5

Decoding LoRa Below the Noise Floor: A Dechirp Demodulator in 80 Lines of numpy

How LoRa's chirp modulation works, a from-scratch dechirp demodulator validated down to -5 dB SNR on synthetic frames, and an honest negative result: my rooftop signal was simply too weak. A lesson in proving your tools before blaming the air.

Filed
Reading
4 min
Section
D / Radio & DSP
Tags

The burst hunter from earlier in this series kept flagging chirps on 868.3 MHz. That is LoRa - the long-range IoT modulation that famously works below the noise floor. I wanted to decode it. There was no GNU Radio on the machine and no appetite to install a multi-gigabyte stack on a nearly-full disk, so I wrote my own demodulator. It is about 80 lines of numpy. It works. The signal still did not decode, and the reason is the actual lesson.

01How LoRa works, briefly

LoRa is Chirp Spread Spectrum. A symbol is a linear frequency sweep (a chirp) across the bandwidth, with the starting frequency encoding the symbol value. To demodulate, you multiply the incoming signal by a reference down-chirp. This "dechirp" turns each up-chirp symbol into a constant tone, and an FFT then reveals which bin - which symbol value - it was.

Two parameters define the link: bandwidth (typically 125 kHz) and spreading factor SF (7 to 12), which sets the symbol length as 2^SF / BW. A LoRa frame begins with a preamble of 8 identical up-chirps. That preamble is the key to detection: dechirp it and you get the same strong FFT bin, symbol after symbol.

02The demodulator

The core is tiny:

Listing 1 / python
def upchirp(N):
    n = np.arange(N)
    return np.exp(1j*2*np.pi*(n*n/(2*N) - n/2)).astype(np.complex64)

def detect(sig, SF):
    N = 2**SF
    down = upchirp(N).conj()        # dechirp reference
    hop = N // 16                    # fine alignment search
    ns = (len(sig) - N) // hop
    # dechirp every window, record peak bin + peak-to-mean ratio
    rr, bb = [], []
    for i in range(ns):
        X = np.abs(np.fft.fft(sig[i*hop:i*hop+N] * down))
        pk = X.argmax()
        rr.append(X[pk] / (X.mean() + 1e-9)); bb.append(pk)
    # a preamble = a run of windows with high ratio at the SAME bin
    ...

The peak-to-mean ratio is the whole game. During a real preamble, the dechirped energy collapses into one bin and the ratio spikes. In noise it stays flat. Find a sustained run of high-ratio windows at a stable bin and you have found a LoRa frame - and incidentally measured its SF and bandwidth.

03Prove the tool before you trust the result

My first attempts found nothing. The temptation is to assume the demodulator is broken and start fiddling. Instead I did the thing that saves hours: I tested it against a signal I fully controlled.

I generated a synthetic LoRa frame (8 preamble symbols + sync + payload), added calibrated Gaussian noise, and ran the detector across a sweep of signal-to-noise ratios:

SNR peak-to-mean ratio preamble lock
+20 dB 66 yes
+10 dB 33 yes
0 dB 13 yes
-5 dB 8 yes
-10 dB 5 no
-15 dB 4 no

The demodulator locks cleanly down to about -5 dB SNR. That is the real LoRa magic - it decodes signals weaker than the noise around them. Below roughly -8 to -10 dB it falls apart, which is expected.

04The honest negative result

Back to the rooftop. My captured 868.3 signal produced peak-to-mean ratios of about 5 to 7. Map that onto the table: that is roughly -8 to -10 dB SNR - right at or below the cliff.

So the conclusion is not "my code is broken." The synthetic test proves the code is correct. The conclusion is the signal is too weak. The dominant emitter near that channel was actually a strong wideband signal at 867.9; the genuine LoRa traffic was faint, coming from distant nodes the rooftop antenna could barely hear.

This is the most useful kind of result, because it points at the real fix. It is not software. It is the front end: a more sensitive receiver (14-bit instead of 8-bit), a low-noise amplifier with a band filter at the antenna, and ideally a directional antenna aimed at the nodes. Those few extra dB are exactly what would push a ratio of 6 over the lock threshold.

There is a second wall behind the first: even decoded, LoRaWAN payloads are AES-encrypted. Passive demodulation gets you the PHY and the cleartext MAC header - device address, frame counter - but not the application data without keys. Worth knowing before you invest in the antenna.

05The takeaway

When a detector finds nothing, the question is never just "is it there?" It is "would my tool see it if it were?" A synthetic ground-truth test answers that in minutes and tells you whether to debug the code or change the hardware. I almost rewrote a working demodulator chasing a hardware problem.

Next in the series: mapping the entire radio spectrum from 24 to 1766 MHz in nine seconds, and what is actually flying over the house.

Passive reception only. LoRaWAN payloads are encrypted; this covers PHY-layer demodulation of ambient signals, which is legal in the EU.

Written by

Paweł Oljasz

Engineering Leader - infrastructure, security, AI, embedded/IoT and SDR. 15+ years in production; leads a team of senior specialists. This notebook is where the lab work gets written down.

Cite this entry
@misc{oljasz2026lorabelow,
  author       = {Oljasz, Pawe{\l}},
  title        = {Decoding LoRa Below the Noise Floor: A Dechirp Demodulator in 80 Lines of numpy},
  howpublished = {Lab notebook, entry 014},
  year         = {2026},
  month        = jul,
  url          = {https://oljasz.com/blog/lora-below-noise-floor}
}

Entry 014 / filed 03.07.2026 / stable URL, no tracking parameters.