← Back to Logs

LoRa: The Long Range Radio That Changes Everything

What is LoRa?

LoRa stands for Long Range. It is a radio modulation technique that lets small, battery-powered devices send data over distances of 2–15+ kilometers using very little power, sometimes as low as 25 milliwatts.

That sounds unremarkable until you think about what it replaces. Traditionally, if you wanted a sensor in a field to talk to a server, you had two options:

  1. Wi-Fi: range of ~50 meters, power-hungry, needs infrastructure
  2. Cellular (4G/5G): unlimited range but requires a SIM card, a subscription, and significant power draw

LoRa sits in an entirely different design space. No SIM card. No subscription. No cellular tower dependency. Your microcontroller (an ESP32, an STM32, a Raspberry Pi Pico) just broadcasts a radio signal that a gateway picks up kilometers away.

The trade-off? Bandwidth. LoRa is not streaming video. It is sending small packets like temperature readings, GPS coordinates, soil moisture levels, and alarm triggers, at rates between 0.3 and 27 kbps. For IoT, that is more than enough.


How Does LoRa Actually Work?

Most radio systems encode data by shifting frequency (FSK) or amplitude (ASK). LoRa does something different: it uses Chirp Spread Spectrum (CSS).

Chirps

A chirp is a signal whose frequency sweeps continuously from low to high (up-chirp) or high to low (down-chirp) across the entire bandwidth. Instead of sitting on one frequency like FM radio, LoRa slides through all of them.

This has a critical consequence: the signal is spread across the entire bandwidth at all times. Narrowband interference, like a spike on one frequency, only corrupts a tiny fraction of each chirp. The receiver can still reconstruct the data from the rest of the sweep. This is why LoRa can decode signals below the noise floor where the Signal-to-Noise Ratio (SNR) can be as low as -20 dB and the packet still gets through.

Spreading Factor

LoRa has a key parameter called the Spreading Factor (SF), ranging from SF7 to SF12. It controls how many chirps encode each symbol:

SF Chirps per Symbol Range Airtime (approx.) SNR Threshold
7 128 Short ~56 ms (20 bytes) -7.5 dB
8 256 ~100 ms -10.0 dB
9 512 ~185 ms -12.5 dB
10 1024 ~370 ms -15.0 dB
11 2048 ~740 ms -17.5 dB
12 4096 Long ~1.5 s -20.0 dB

Higher SF = more redundancy = longer range, but at the cost of much longer airtime and lower data rate. SF12 sends the same packet ~30x slower than SF7.

This trade-off is fundamental to LoRa network design: you want the lowest SF that reliably reaches the gateway to minimize airtime, energy consumption, and channel congestion.

Bandwidth and Coding Rate

Two other parameters shape the signal:

  • Bandwidth (BW): Usually 125 kHz, 250 kHz, or 500 kHz. Wider bandwidth = faster transmission but less sensitivity. Most deployments use 125 kHz.
  • Coding Rate (CR): Forward error correction ratio (4/5, 4/6, 4/7, or 4/8). Higher coding rate = more error correction at the cost of extra airtime. 4/5 is the default.

The Airtime Formula

The exact time a LoRa packet takes to transmit (airtime) is calculated from these parameters:

Symbol time = (2^SF) / BW
 
Preamble time = (preamble_symbols + 4.25) × symbol_time
 
Payload symbols = 8 + max(
    ceil((8×payload_bytes - 4×SF + 28 + 16) / (4×SF)) × (CR + 4),
    0
)
 
Payload time = payload_symbols × symbol_time
 
Total airtime = preamble_time + payload_time

For a 20-byte payload at SF9 / 125 kHz / CR 4/5, the airtime is approximately 185 ms. At SF12, the same payload takes about 1.5 seconds. This matters enormously for battery life and duty cycle compliance.


LoRa vs LoRaWAN

An important distinction that often causes confusion:

  • LoRa is the physical layer, the radio modulation technique (CSS). It defines how bits become chirps.
  • LoRaWAN is a network protocol built on top of LoRa. It defines how devices join networks, how gateways route packets to servers, device classes (A, B, C), security (AES-128), and adaptive data rate.

You can use LoRa without LoRaWAN. Projects like Meshtastic use LoRa for direct peer-to-peer mesh communication with their own protocol. You can also write custom LoRa firmware that ignores LoRaWAN entirely.

This blog post focuses on the LoRa physical layer and link-level behavior, the part you need to understand before any protocol sits on top.


Real-World Propagation

Theory says LoRa can reach 15+ km in line-of-sight. Reality depends on the environment:

Environment Typical Range Path Loss Exponent
Line of sight 10–15 km ~2.0
Suburban 3–5 km ~2.7
Urban (dense) 1–2 km ~3.5
Indoor to outdoor 200–500 m ~4.0+

The path loss exponent describes how quickly signal strength drops with distance. In free space, signal power falls with the square of distance (exponent = 2.0). Walls, trees, buildings, and humidity increase it.

The log-distance path loss model commonly used:

Path Loss (dB) = Reference Loss + 10 × exponent × log₁₀(distance / reference_distance)
RSSI (dBm) = TX Power - Path Loss
SNR (dB) = RSSI - Noise Floor

With a typical LoRa configuration (14 dBm TX power, -118 dBm noise floor, SF9) you need an SNR above -12.5 dB for successful reception. Working through the math for a suburban environment (exponent 2.7), the signal fades below threshold at roughly 2.5–3 km. Increase to SF12 and the threshold drops to -20 dB, extending range to 4–5 km, but now each packet takes 8x longer to transmit.


The Collision Problem

LoRa uses ALOHA-like channel access, meaning devices transmit whenever they need to, without listening first. In a single-device network, this is fine. In a dense deployment with hundreds of sensors, collisions become the dominant failure mode.

Two packets collide when they overlap in time on the same channel. LoRa has some resilience here:

  • SF orthogonality: Packets at different spreading factors are quasi-orthogonal. An SF7 packet and an SF12 packet on the same frequency usually don't interfere, as long as the power difference exceeds ~10 dB.
  • Capture effect: If two packets at the same SF collide, the stronger signal wins if it exceeds the weaker by the capture threshold (typically 6 dB).
  • Multiple demodulation paths: Modern LoRa gateways (like the SX1301/SX1302) can demodulate 8+ simultaneous packets on different SFs.

But when two devices at the same SF and similar power transmit simultaneously, both packets are lost. In a 1000-device network, this happens constantly.


Duty Cycle: The Legal Constraint

In most regions (EU868, for example), LoRa operates in unlicensed ISM bands with regulatory duty cycle limits, typically 1%. This means: for every second of airtime, you must stay silent for 99 seconds.

At SF12 with a 20-byte payload (~1.5 seconds airtime), that is a 150-second off-time between transmissions per sub-band. This fundamentally limits how often devices can report data and how the network handles retries.


Confirmed Messages and ACKs

LoRaWAN Class A devices support confirmed uplinks where the device sends a packet and expects an acknowledgement from the gateway. The ACK flow:

  1. Device transmits uplink
  2. RX1 window opens 1 second later, where the gateway responds on the same frequency and SF
  3. If RX1 fails (interference, timing), RX2 window opens 2 seconds after uplink, where the gateway responds on a fixed frequency (e.g., 869.525 MHz) at SF12 for maximum reliability
  4. If both windows fail, the device retries with exponential backoff

This is important for applications that cannot tolerate data loss, like actuator commands, alarm confirmations, and firmware updates. But every ACK consumes downlink airtime and gateway capacity, so you use confirmed messages sparingly.


Adaptive Data Rate (ADR)

Running every device at SF12 "just to be safe" wastes airtime and battery. Adaptive Data Rate solves this by dynamically adjusting each device's spreading factor based on link quality:

  • If recent delivery rate is high (>90%) and the link is stable → decrease SF (faster, less airtime)
  • If recent delivery rate is low (<60%) → increase SF (more range, more robustness)
  • If the preferred gateway changes → increase SF (link instability)

ADR typically uses a sliding window (e.g., last 5–20 messages) to avoid reacting to transient failures. The algorithm runs per-device, not globally.

The impact is significant: in a mixed-distance deployment, nearby devices run at SF7 (56 ms airtime) while distant devices use SF11–12 (740 ms–1.5 s). The nearby devices free up channel capacity that the distant devices desperately need.


Pros and Cons

Advantages

  • Extreme range: kilometers of coverage from a single gateway
  • Ultra-low power: years of battery life on coin cells for periodic sensors
  • No infrastructure dependency: no SIM cards, subscriptions, or cellular towers
  • License-free spectrum: ISM bands available globally
  • Penetration: CSS modulation handles walls, foliage, and urban environments better than most alternatives
  • Low cost: LoRa modules (SX1276, SX1262) cost $2–5, gateways $50–150
  • Mesh capability: with projects like Meshtastic, devices can relay packets across multi-hop networks

Limitations

  • Low bandwidth: 0.3–27 kbps, unsuitable for audio/video/large files
  • High latency: airtime of 50 ms to 1.5 s per packet, plus duty cycle wait
  • Collision sensitivity: ALOHA access means dense networks need careful SF and channel planning
  • Duty cycle regulations: EU limits constrain transmission frequency
  • One-way bias: downlink capacity is much lower than uplink (gateway is also duty-cycle limited)
  • No built-in security at PHY level: LoRaWAN adds AES-128, but raw LoRa is unencrypted

Simulating LoRa Before You Build

Here is the real question: before you buy hardware, deploy sensors in a field, and discover that your 500-device network has a 40% packet loss rate due to collisions, how do you test your design?

This is exactly why I built lora-sim.

What is lora-sim?

lora-sim is a deterministic, event-driven LoRa network simulator written in pure Python. It models:

  • LoRa physical layer: Accurate airtime calculation, path loss propagation, SNR thresholds per SF
  • Collision detection: Gateway demodulation limits, capture effect, SF orthogonality
  • MAC constraints: Duty cycle enforcement, channel guard timing
  • Confirmed uplinks: RX1 and RX2 ACK windows with interference modeling
  • Adaptive Data Rate: Per-node SF adjustment based on delivery history
  • Energy accounting: TX, RX, and idle power consumption per node
  • Retry logic: Configurable attempts with backoff

It has zero external dependencies, just Python 3.11+ stdlib. No numpy, no matplotlib, no heavy frameworks. You define a scenario in JSON, run it, and get deterministic results.

Why Deterministic?

If you run the same scenario with the same seed, you get identical results every time. This is critical for:

  • Regression testing: ensure protocol changes don't break delivery rates
  • A/B comparisons: test two designs with the exact same RNG sequence
  • Debugging: reproduce a specific collision pattern to understand why packets were lost

Defining a Scenario

A scenario is a JSON file that describes your network. Here is a simple single-link test:

{
  "name": "Simple link baseline",
  "duration_seconds": 60.0,
  "seed": 42,
  "channel": {
    "noise_floor_dbm": -118.0,
    "path_loss_exponent": 2.7,
    "reference_distance_m": 1.0,
    "reference_loss_db": 32.0,
    "interference_probability": 0.0,
    "corruption_probability": 0.0,
    "snr_margin_db": 3.0,
    "capture_threshold_db": 6.0,
    "sf_orthogonality_margin_db": 10.0,
    "gateway_demodulation_paths": 8,
    "duty_cycle_fraction": 1.0,
    "channel_guard_seconds": 0.0
  },
  "nodes": [
    {
      "node_id": "sensor-a",
      "role": "end_device",
      "x_m": 0.0,
      "y_m": 0.0,
      "radio": {
        "frequency_hz": 868100000,
        "bandwidth_hz": 125000,
        "spreading_factor": 9,
        "coding_rate": "4/5",
        "tx_power_dbm": 14,
        "preamble_symbols": 8,
        "explicit_header": true,
        "crc_enabled": true
      },
      "traffic": {
        "packet_count": 8,
        "interval_seconds": 1.5,
        "payload_size_bytes": 20,
        "destination_id": "gateway",
        "confirmed_messages": true
      }
    },
    {
      "node_id": "gateway",
      "role": "gateway",
      "x_m": 1200.0,
      "y_m": 0.0,
      "radio": {
        "frequency_hz": 868100000,
        "bandwidth_hz": 125000,
        "spreading_factor": 9,
        "coding_rate": "4/5",
        "tx_power_dbm": 14,
        "preamble_symbols": 8,
        "explicit_header": true,
        "crc_enabled": true
      }
    }
  ]
}

One sensor, one gateway, 1.2 km apart, 8 confirmed packets at 1.5-second intervals. Clean channel, no interference.

Running It

# Install
git clone https://github.com/dragonGR/lora-sim.git
cd lora-sim
pip install -e .
 
# Run a scenario
lora-sim run scenarios/simple_link.json

Output:

Scenario: Simple link baseline
Seed: 42
Packets Sent: 8
Packets Delivered: 8
Delivery Rate: 100.00%
Collisions: 0
Retries: 0
ACK Requests: 8
ACK Successes: 8
Average Latency: 1.116s
Total Energy: 0.406J

100% delivery. Now let's make it interesting.

Stress Testing: Collisions

Add a second sensor 40 meters from the first, both targeting the same gateway at 1.5 km. Reduce intervals so transmissions overlap. Add 5% environmental interference:

lora-sim run scenarios/multi_node_collision.json

Now you see collisions, retries, and lost packets. The simulator logs every packet with its SNR, RSSI, collision status, and reason for loss.

Parameter Sweeps

Want to know at what distance your link starts failing?

lora-sim sweep scenarios/simple_link.json \
  --param nodes.gateway.x_m \
  --range 500:3000:500 \
  --seed 42

This runs the scenario at 500m, 1000m, 1500m, 2000m, 2500m, and 3000m, showing delivery rate, collisions, and energy at each point. Export the JSON results and plot them with your tool of choice.

Monte Carlo Analysis

A single seed gives one outcome. For statistical confidence, run the same scenario with rolling seeds:

lora-sim monte-carlo scenarios/multi_node_collision.json \
  --iterations 50 \
  --seed 100

50 independent runs. You get mean delivery rate, standard deviation, and per-run results. This tells you whether your 83% delivery rate is stable or just lucky.

Comparing Designs

Test two network designs under identical conditions:

lora-sim compare scenarios/simple_link.json \
  scenarios/multi_node_collision.json \
  --seed 42

Side-by-side metrics with the same RNG seed, the only fair way to A/B test network configurations.


Architecture of lora-sim

The simulator follows a clean domain-driven structure:

src/lora_sim/
├── domain/          # Immutable data structures (Packet, Node, Radio, Channel)
├── models/          # Stateless physics (propagation, interference, ADR, corruption)
├── simulation/      # Event-driven engine, scenario loader, experiment runners
├── io/              # JSON and CSV result export
└── app/             # CLI, runner, report generation

Key design decisions:

  • Frozen dataclasses for all domain objects, which prevents silent mutation bugs
  • Event queue based on Python's heapq for microsecond-precision discrete event simulation
  • Stateless physics models where propagation, interference, and corruption functions take parameters and return results with no hidden state
  • Full type hints throughout, targeting Python 3.11+ with strict typing

Every packet transmission goes through a complete lifecycle: scheduling → MAC constraint check → propagation calculation → gateway selection → collision detection → ACK window handling → delivery or retry. Each step is logged in a PacketRecord with 25+ fields for post-hoc analysis.


What Can You Do With It?

Use Case How
Validate gateway placement Sweep distance parameter, find the fade-out point
Test ADR algorithm Compare fixed-SF vs adaptive scenarios
Size a deployment Monte Carlo with N nodes, find the collision threshold
Estimate battery life Check per-node energy output across traffic patterns
Debug packet loss Read packet records: was it collision, interference, or link budget?
Compare network designs Use compare with same seed for fair A/B testing
Teach LoRa physics Change SF/BW/CR and observe airtime and range changes

Getting Started

git clone https://github.com/dragonGR/lora-sim.git
cd lora-sim
pip install -e .
 
# Run the included scenarios
lora-sim run scenarios/simple_link.json
lora-sim run scenarios/multi_node_collision.json
lora-sim run scenarios/gateway_capacity.json
lora-sim run scenarios/multi_gateway_ack.json
lora-sim run scenarios/duty_cycle_rx2.json
 
# Export results for analysis
lora-sim run scenarios/multi_node_collision.json --out results.json
lora-sim run scenarios/multi_node_collision.json --out packets.csv
 
# Generate HTML report
lora-sim run scenarios/simple_link.json --report report.html

The repository includes five reference scenarios covering: baseline links, collision stress testing, gateway demodulation limits, confirmed uplinks with dual gateways, and duty cycle constraints with RX2 fallback.


Closing Thoughts

LoRa is one of those technologies that feels like it should not work. Sending data 10 km on 25 milliwatts of power, decoding signals buried 20 dB below the noise floor, using nothing but frequency sweeps. It is elegant engineering.

But elegance at the physical layer creates complexity at the network layer. Collisions, duty cycle constraints, ADR tuning, gateway capacity: these are the real engineering challenges. And they are impossible to reason about without simulation.

That is why lora-sim exists. Define your network in JSON, run it, break it, fix it, all before spending a cent on hardware.

The source code is at github.com/dragonGR/lora-sim. It is pure Python, zero dependencies, and designed to be read and extended.