Deep Dive

Understanding LoRa Modulation through Comparison

Written by Alex, K2XAP of nyme.sh

LoRa packets come in a wide variety of shapes and sizes, depending on their parameters. These shapes bring with them pros and cons. One of the biggest impacts is the airtime. Meshes have to choose between range, noise tolerance, and time on the air, finding the right balance of link reliability and congestion for their density and location.

Series of transmissions at varying widths and lengths, labeled with the preset names.

Real-time video (345kb) of the above waterfall. This graphic and the video are CC 0 or public domain — feel free to share and republish as useful.

The Trade-offs

Range, Airtime & Noise

At the default settings, a Meshtastic packet can take literally a whole second to transmit. This gives it great range and resilience, but a mesh can quickly become congested as soon as it exceeds the number of contacts a typical device can hold. Faster settings sacrifice range for much lower airtime, and can use infrastructure to compensate for lower link reliability with retries of packets. Each step faster in Meshtastic presets is a roughly 45% reduction in airtime.

The bandwidth also changes the exposure to noise. Wider bandwidth gives LoRa a much quicker transmission speed and reduces risk of "vertical" collisions in the temporal dimension, but it increases the chance of "horizontal" collisions in the spectral dimension — in addition to increased thermal noise exposure.

SDR view of several hours showing spectral density of different frequencies. Large blocks of yellow get progressively wider and wider amid various columns of transmissions.

Every permutation of bandwidth, spread factor, and coding rate, from 62.5 kHz to 500 kHz.

Key Terms

The Three Modulation Parameters

Three settings define every LoRa preset. Together they decide how far a packet reaches, how long it stays on the air, and how well it survives noise.

Spreading Factor
How many chips encode each symbol (SF7–SF12). Higher spreading factor means more processing gain and longer range — but each step roughly doubles airtime.
Bandwidth
The width of the channel in kHz (62.5–500). Wider bandwidth transmits faster and resists temporal collisions, but exposes the signal to more noise and spectral collisions.
Coding Rate
Forward-error-correction overhead (4/5 through 4/8, written here as 5–8). More coding adds redundancy that recovers corrupted packets, at the cost of additional airtime.

Rule of thumb: longer range and better noise tolerance always cost airtime. A denser, busier mesh usually wants faster presets so more packets fit on the air.

Presets

Presets and Calculated Attributes

These are the presets and their calculated attributes based on the Semtech LoRa calculator:

preset bandwidth spread_factor coding_rate effective_data_rate_bps time_on_air_ms link_budget_dB range_km processing_gain_dB
MC Narrow–Long62.595879248154.04.8927
LongMod250118671297153.04.5833
LongFast2501151074248153.04.5833
MediumSlow2501051953124150.53.8930
MC Narrow62.575273472149.03.5321
Meshoregon12585312572148.53.4124
MediumFast25095351662148.03.3027
LongTurbo5001181343148148.03.3033
ShortSlow25085625036145.52.8024
ShortFast250751093818143.02.3821
ShortTurbo50075218759138.01.7221

Reading the table: presets at the top reach farther (higher link budget and range) but sit on the air far longer; presets at the bottom clear the air in milliseconds at the cost of range.

Demonstration

Reproduce the Waterfall

These steps will reproduce the demonstration above. This uses MeshCore because it can change radio settings without a device reboot, but the output is equivalent to any LoRa transmission.

  1. Tune an SDR to the desired frequency. The script defaults to 912.4 MHz.
  2. Flash a node as a USB MeshCore companion.
  3. Install the meshcore python library: pip install meshcore.
  4. Run the script (set the INTERFACE to the connected device):
import asyncio
from meshcore import MeshCore, EventType

INTERFACE = "/dev/tty.usbmodem1301"  # Set this to your local device
FREQUENCY = 912.4 # MHz, Change this to the desired frequency
PRESETS = [
    (250,11,5),   # MT LongFast
    (250,10,5),   # MT MediumSlow
    (250,9,5),    # MT MediumFast
    (250,8,5),    # MT ShortSlow
    (250,7,5),    # MT ShortFast
    (125,8,5),    # MeshOregon
    (62,7,5),     # MC Narrow
    (62,9,5),     # MC Narrow Long
    (500,11,8),   # MT LongTurbo
    (500,7,5),    # MT ShortTurbo
]

async def main():
    meshcore = await MeshCore.create_serial(INTERFACE)
    await meshcore.commands.set_tx_power(1) # Be a good RF neighbor
    await asyncio.sleep(1)
    for (bw,sf,cr) in PRESETS:
        await meshcore.commands.set_radio(FREQUENCY, bw, sf, cr)
        print((bw, sf, cr))
        result = await meshcore.commands.send_chan_msg(0, "The quick brown fox jumped over the lazy dog")
        print(result)
        await asyncio.sleep(1)
    await meshcore.disconnect()

asyncio.run(main())