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:
- Wi-Fi: range of ~50 meters, power-hungry, needs infrastructure
- 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_timeFor 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 FloorWith 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:
- Device transmits uplink
- RX1 window opens 1 second later, where the gateway responds on the same frequency and SF
- 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
- 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.jsonOutput:
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.406J100% 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.jsonNow 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 42This 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 10050 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 42Side-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 generationKey design decisions:
- Frozen dataclasses for all domain objects, which prevents silent mutation bugs
- Event queue based on Python's
heapqfor 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.htmlThe 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.