A LoRaWAN link budget and coverage map calculator
Compute EU868 LoRaWAN uplink sensitivity, time on air, duty-cycle limits and range per spreading factor from Semtech, LoRa Alliance, ITU-R and 3GPP sources, then map coverage around one gateway in NumPy.
| Runs on | Laptop CPU (macOS, Linux or Windows), Python and NumPy, under 5 seconds |
|---|---|
| Time | about 3 hours |
| Stack | python, numpy |
What you will build
This project is a planning calculator for a LoRaWAN deployment, in one NumPy file. For each spreading factor (SF) it works out the receiver sensitivity, the time on air of a 12-byte uplink, the number of uplinks per hour allowed by the 1 % duty cycle, the maximum path loss and the range under two propagation models. It then lays a 10 km by 10 km grid around one gateway, applies a path-loss model with random shadowing, and reports which SF each point needs.
Every input is sourced or stated as an assumption below. In our run a 12-byte uplink takes 61.7 ms at SF7 and 1482.8 ms at SF12. The 1 % duty cycle then allows 583 and 24 uplinks per hour. With an urban path-loss model the median range runs from 1.96 km at SF7 to 4.22 km at SF12, and 69.2 % of the 5 km disc around the gateway is covered at some SF.
The lessons on decibels and link budgets and noise, SNR and capacity explain the arithmetic. The decibels and link budget cheat sheet and the propagation formulas have the formulas in one place.
What you need
- Python 3.10 or later and NumPy. We ran it with Python 3.12 and NumPy 2.5.4. It also uses
mathandstatisticsfrom the standard library. - No radio and no maps. Terrain and buildings are a statistical model, not geography.
- About 3 hours.
Sources and assumptions
| Input | Value | Source or reason |
|---|---|---|
| Region | EU863-870, uplink on 868.1 MHz | Default channel in LoRa Alliance RP002-1.0.4 (Regional Parameters, 2022) |
| Data rates | DR0 to DR5 = SF12 to SF7 at 125 kHz | RP002-1.0.4, EU863-870 data rate table |
| Device EIRP | +16 dBm | RP002-1.0.4 default maximum EIRP for EU863-870 |
| Duty cycle | 1 % | RP002-1.0.4 lists less than 1 % for the default channels |
| LoRa settings | preamble 8 symbols, explicit header, CRC on, coding rate 4/5 | RP002-1.0.4 LoRa physical layer settings for uplinks |
| Low data rate optimisation | on for SF11 and SF12 at 125 kHz | RP002-1.0.4, and the SX1261/2 datasheet’s 16.38 ms symbol-time rule |
| Frame overhead | 13 bytes (MHDR 1, FHDR 7, FPort 1, MIC 4) | LoRa Alliance TS001-1.0.4 frame format, with no FOpts |
| Sensitivity, SF7 and SF12 at 125 kHz | −124 dBm and −137 dBm | SX1261/2 datasheet Rev 2.2 (December 2024), Table 3-8, Rx boosted gain, 1 % packet error rate with 64-byte packets |
| Sensitivity, SF8 to SF11 | linear in SF between the two datasheet values | Assumption, cross-checked against the noise floor below |
| Demodulator SNR per SF | −7.5 dB at SF7 to −20 dB at SF12, 2.5 dB steps | SX1261/2 datasheet Rev 2.2, Table 6-1 (typical values) |
| Gateway receiver | performs like the SX1262 | Assumption. Gateway concentrators have their own datasheets |
| Gateway antenna and feeder | 6 dBi, 2 dB loss | Assumption |
| Design margin | 3 dB | Assumption, for small-scale fading and implementation loss |
| Free-space model | 32.4 + 20 log f(MHz) + 20 log d(km) | ITU-R P.525-5, equation (6) |
| Empirical model | UMa NLOS, gateway 25 m, device 1.5 m, 10 m to 5 km | 3GPP TR 38.901 V19.5.0 (Release 19), Table 7.4.1-1, valid from 0.5 GHz |
| Shadow fading | log-normal, 6 dB standard deviation, independent per 50 m cell | TR 38.901 UMa NLOS σ, correlation distance 50 m |
| Location probability for range | 90 % | Assumption. Margin = 1.28 × 6 dB = 7.7 dB |
The SX1262 is Semtech’s sub-GHz LoRa and FSK transceiver. According to the SX1261/2 datasheet it covers 150 MHz to 960 MHz continuously, supports LoRa bandwidths from 7.8 kHz to 500 kHz and transmits up to +22 dBm. It is a half-duplex transceiver, so it either transmits or receives at any moment, and the datasheet does not give an ADC resolution. We read revision 2.2 of the datasheet (December 2024). Semtech’s product page lists the datasheet with a date of 7 April 2025. We could not download that file, so check the sensitivity table against it. See the hardware table for more.
Design
Time on air
A LoRa symbol at spreading factor SF and bandwidth BW lasts 2^SF / BW. A LoRaWAN uplink has an 8-symbol preamble, 4.25 symbols of sync word and start-of-frame, 8 symbols that carry the header, and then the payload symbols. The number of payload symbols is ceil((8 PL + 16 CRC − 4 SF + 8 + 20 H) / (4 (SF − 2 DE))) × (CR + 4), where PL is the PHY payload in bytes, CRC is 1 when the CRC is on, H is 1 for an explicit header, DE is 1 when low data rate optimisation is on and CR is 1 for coding rate 4/5. The datasheet revision we read points to the SX126x drivers for this formula, and the code follows the integer arithmetic in Semtech’s sx126x_driver for SF7 to SF12. RP002-1.0.4’s LoRa packet structure table gives the same 8 + 4.25 + 8 symbol layout.
The PHY payload is the 12 application bytes plus 13 bytes of LoRaWAN framing: a 1-byte MAC header, a 7-byte frame header with no options, a 1-byte port and a 4-byte message integrity code.
Duty cycle
At 1 % a device may transmit for 36 s in any hour. The calculator divides 36 s by the time on air and rounds down. RP002-1.0.4 points to ETSI EN 300 220-2 for the detailed European rules, and a network operator may impose tighter limits, so treat these numbers as an upper bound.
Sensitivity
The SX1261/2 datasheet gives LoRa sensitivity at SF7 and SF12 only, for each bandwidth. The calculator draws a straight line in SF between −124 dBm and −137 dBm at 125 kHz. As a check it also computes the noise-floor estimate −174 dBm/Hz + 10 log10(125 kHz) + NF + SNRmin, with the datasheet’s typical demodulator SNR per SF and an assumed 6 dB noise figure. The two agree within 0.5 dB at every SF, which supports the interpolation. The 6 dB noise figure is only for this check. It is not a datasheet value.
Link budget
Maximum path loss = EIRP + gateway antenna gain − feeder loss − sensitivity − design margin. With +16 dBm EIRP, 6 dBi, 2 dB and 3 dB, that is 141.0 dB at SF7 and 154.0 dB at SF12. Each step of SF adds 2.6 dB. See the glossary entries for EIRP, receiver sensitivity and link margin.
Path-loss models
Free-space path loss from ITU-R P.525-5 is the best case. It assumes a clear line of sight and no ground, so at street level it overstates range by orders of magnitude. It is here to show that.
The empirical model is the urban macro (UMa) non-line-of-sight model from 3GPP TR 38.901 Table 7.4.1-1. The NLOS loss is the larger of the UMa LOS loss and 13.54 + 39.08 log10(d3D) + 20 log10(fc) − 0.6 (hUT − 1.5), with fc in GHz and d3D in metres. The LOS part uses a breakpoint distance 4 h’BS h’UT fc / c with an effective environment height of 1 m, which the report specifies for a device below 13 m. TR 38.901 states the table applies from 0.5 GHz, so 868 MHz is inside its range, and from 10 m to 5 km. It was built for cellular macro sites, which share the geometry of a gateway on a 25 m mast in a town. It has no terrain.
Coverage grid
The grid is 201 by 201 points at 50 m spacing with the gateway in the centre. Only the 31,416 points between 10 m and 5 km from the gateway are inside the model’s range and count. Each point gets the UMa NLOS median loss plus a shadowing sample with a 6 dB standard deviation. TR 38.901 gives 50 m as the shadowing correlation distance for UMa NLOS, equal to the grid step, so we draw independent samples per cell as a simplification. Each point is then assigned the fastest SF whose maximum path loss it meets.
Code
Save this as lorawan_coverage.py. It is one block: parameters, time on air, the two path-loss models, the link budget, the grid and the payload limits.
# lorawan_coverage.py: EU868 LoRaWAN uplink link budget, time on air, duty cycle and a coverage grid.
import math
from statistics import NormalDist
import numpy as np
# Region and radio (sources and assumptions are listed on the page)
FREQ_MHZ = 868.1 # EU868 default channel
BW_HZ = 125e3 # DR0 to DR5 use 125 kHz
SFS = [7, 8, 9, 10, 11, 12] # DR5 ... DR0
EIRP_DBM = 16.0 # RP002-1.0.4 default max EIRP for EU863-870
DUTY_CYCLE = 0.01 # default channels: less than 1 %
APP_PAYLOAD = 12 # application bytes per uplink
LORAWAN_OVERHEAD = 13 # MHDR 1 + FHDR 7 + FPort 1 + MIC 4, no FOpts
PREAMBLE_SYMB = 8 # LoRaWAN uplink preamble
CR = 1 # coding rate 4/5
SENS_DATASHEET = {7: -124.0, 12: -137.0} # SX1262 at 125 kHz, Rx boosted gain (dBm)
SNR_MIN_DB = {7: -7.5, 8: -10.0, 9: -12.5, 10: -15.0, 11: -17.5, 12: -20.0}
NF_DB = 6.0 # assumed noise figure for the cross-check only
# Link and site assumptions
GW_ANT_GAIN_DBI = 6.0 # gateway antenna
GW_CABLE_LOSS_DB = 2.0 # feeder and connectors
DESIGN_MARGIN_DB = 3.0 # small-scale fading and implementation allowance
H_BS, H_UT = 25.0, 1.5 # 38.901 UMa defaults: gateway on a mast, device at street level
SIGMA_SF_DB = 6.0 # 38.901 UMa NLOS shadow fading standard deviation
LOCATION_PROB = 0.90 # edge-of-cell location probability for the range table
GRID_STEP_M, GRID_HALF_M = 50.0, 5000.0
rng = np.random.default_rng(868)
def time_on_air_s(payload_bytes: int, sf: int, bw_hz: float = BW_HZ, preamble: int = PREAMBLE_SYMB,
cr: int = CR, crc: bool = True, explicit_header: bool = True) -> float:
"""LoRa time on air for SF7 to SF12, following Semtech's SX126x driver."""
ldro = (2 ** sf) / bw_hz >= 16.38e-3 # on for SF11 and SF12 at 125 kHz
num = 8 * payload_bytes + 16 * crc - 4 * sf + 8 + 20 * explicit_header
den = 4 * (sf - 2 * ldro)
n_payload = math.ceil(max(num, 0) / den) * (cr + 4)
n_symbols = preamble + 4.25 + 8 + n_payload
return n_symbols * (2 ** sf) / bw_hz
def fspl_db(d_m: np.ndarray) -> np.ndarray:
"""ITU-R P.525-5 equation (6): 32.4 + 20 log f(MHz) + 20 log d(km)."""
return 32.4 + 20 * np.log10(FREQ_MHZ) + 20 * np.log10(d_m / 1e3)
def uma_nlos_db(d2d_m: np.ndarray) -> np.ndarray:
"""3GPP TR 38.901 Table 7.4.1-1, UMa NLOS = max(UMa LOS, PL'UMa-NLOS). fc in GHz, d in m."""
fc = FREQ_MHZ / 1e3
d3d = np.sqrt(d2d_m ** 2 + (H_BS - H_UT) ** 2)
d_bp = 4 * (H_BS - 1.0) * (H_UT - 1.0) * fc * 1e9 / 3e8 # hE = 1 m because hUT is below 13 m
pl1 = 28.0 + 22 * np.log10(d3d) + 20 * np.log10(fc)
pl2 = 28.0 + 40 * np.log10(d3d) + 20 * np.log10(fc) - 9 * np.log10(d_bp ** 2 + (H_BS - H_UT) ** 2)
pl_los = np.where(d2d_m <= d_bp, pl1, pl2)
pl_nlos = 13.54 + 39.08 * np.log10(d3d) + 20 * np.log10(fc) - 0.6 * (H_UT - 1.5)
return np.maximum(pl_los, pl_nlos)
def range_for(model, max_loss_db: float) -> float:
"""Largest distance (m) at which the model loss stays below max_loss_db, by bisection."""
lo, hi = 10.0, 1e7
for _ in range(200):
mid = math.sqrt(lo * hi)
lo, hi = (mid, hi) if model(np.array([mid]))[0] <= max_loss_db else (lo, mid)
return lo
# 1. Receiver sensitivity per SF: datasheet anchors, linear in SF between them, plus a noise-floor check.
sens = {sf: SENS_DATASHEET[7] + (sf - 7) * (SENS_DATASHEET[12] - SENS_DATASHEET[7]) / 5 for sf in SFS}
print("SF DR sensitivity (dBm) check -174+10log(BW)+NF+SNRmin ToA (ms) uplinks/hour at 1 %")
phy = APP_PAYLOAD + LORAWAN_OVERHEAD
toa = {sf: time_on_air_s(phy, sf) for sf in SFS}
for sf in SFS:
check = -174 + 10 * math.log10(BW_HZ) + NF_DB + SNR_MIN_DB[sf]
per_hour = math.floor(3600 * DUTY_CYCLE / toa[sf])
print(f"{sf:>2} {12 - sf:>2} {sens[sf]:>17.1f} {check:>31.1f} {toa[sf] * 1e3:>8.1f} {per_hour:>19}")
print(f"PHYPayload {phy} bytes, preamble {PREAMBLE_SYMB} symbols, CR 4/5, CRC on, explicit header")
# 2. Link budget and range per SF.
z = NormalDist().inv_cdf(LOCATION_PROB)
shadow_margin = z * SIGMA_SF_DB
print(f"\nEIRP {EIRP_DBM} dBm, gateway gain {GW_ANT_GAIN_DBI} dBi, cable {GW_CABLE_LOSS_DB} dB, "
f"design margin {DESIGN_MARGIN_DB} dB, shadowing margin {shadow_margin:.1f} dB ({LOCATION_PROB:.0%})")
print("SF max path loss (dB) range free space (km) range UMa NLOS median (km) at 90 % (km)")
mapl = {}
for sf in SFS:
mapl[sf] = EIRP_DBM + GW_ANT_GAIN_DBI - GW_CABLE_LOSS_DB - sens[sf] - DESIGN_MARGIN_DB
r_fs = range_for(fspl_db, mapl[sf]) / 1e3
r_med = range_for(uma_nlos_db, mapl[sf]) / 1e3
r_90 = range_for(uma_nlos_db, mapl[sf] - shadow_margin) / 1e3
print(f"{sf:>2} {mapl[sf]:>18.1f} {r_fs:>21.0f} {r_med:>26.2f} {r_90:>12.2f}")
# 3. Path loss against distance for the chart.
print("\nd (km) FSPL (dB) UMa NLOS (dB)")
for d_km in [0.1, 0.2, 0.3, 0.5, 0.7, 1.0, 1.5, 2.0, 3.0, 4.0, 5.0]:
d = np.array([d_km * 1e3])
print(f"{d_km:>6} {fspl_db(d)[0]:>9.1f} {uma_nlos_db(d)[0]:>13.1f}")
# 4. Coverage grid: 10 km x 10 km, gateway at the centre, UMa NLOS plus log-normal shadowing.
axis = np.arange(-GRID_HALF_M, GRID_HALF_M + GRID_STEP_M, GRID_STEP_M)
x, y = np.meshgrid(axis, axis)
d2d = np.hypot(x, y)
inside = (d2d >= 10.0) & (d2d <= 5000.0) # UMa NLOS validity: 10 m to 5 km
loss = uma_nlos_db(np.maximum(d2d, 10.0)) + rng.normal(0.0, SIGMA_SF_DB, d2d.shape)
best_sf = np.full(d2d.shape, 0)
for sf in reversed(SFS): # overwrite with the fastest SF that closes
best_sf[(loss <= mapl[sf]) & inside] = sf
n_in = int(inside.sum())
print(f"\ncoverage grid: {len(axis)} x {len(axis)} points at {GRID_STEP_M:.0f} m, {n_in} inside the 5 km disc")
print("SF share of area, fastest SF that closes (%) cumulative (%) by ring 0-1, 1-2, 2-3, 3-4, 4-5 km (%)")
cum = 0.0
rings = [(0, 1000), (1000, 2000), (2000, 3000), (3000, 4000), (4000, 5000)]
for sf in SFS:
share = 100 * np.sum(best_sf[inside] == sf) / n_in
cum += share
ring_cov = []
for r0, r1 in rings:
sel = inside & (d2d > r0) & (d2d <= r1)
ring_cov.append(100 * np.sum((best_sf[sel] > 0) & (best_sf[sel] <= sf)) / sel.sum())
print(f"{sf:>2} {share:>41.1f} {cum:>14.1f} " + ", ".join(f"{c:.0f}" for c in ring_cov))
print(f"not covered at any SF: {100 - cum:.1f} % of the disc")
# 5. Payload limits: the longest EU868 uplink, and the US915 400 ms dwell time at DR0 (SF10, 125 kHz).
t51 = time_on_air_s(51 + LORAWAN_OVERHEAD, 12)
print(f"\nEU868 DR0, 51-byte application payload: {t51 * 1e3:.1f} ms, {math.floor(36 / t51)} uplinks/hour at 1 %")
for n_app in (11, 12):
t = time_on_air_s(n_app + LORAWAN_OVERHEAD, 10)
print(f"US915 DR0 (SF10), {n_app}-byte application payload: {t * 1e3:.1f} ms, "
f"{'within' if t <= 0.4 else 'over'} the 400 ms dwell time")
range_for solves for distance by bisection on a logarithmic scale, so it works for both models.
Run
python -m pip install numpy
python lorawan_coverage.py
Test
This is the output from our run, with the shadowing seed fixed at 868.
SF DR sensitivity (dBm) check -174+10log(BW)+NF+SNRmin ToA (ms) uplinks/hour at 1 %
7 5 -124.0 -124.5 61.7 583
8 4 -126.6 -127.0 113.2 318
9 3 -129.2 -129.5 205.8 174
10 2 -131.8 -132.0 411.6 87
11 1 -134.4 -134.5 823.3 43
12 0 -137.0 -137.0 1482.8 24
PHYPayload 25 bytes, preamble 8 symbols, CR 4/5, CRC on, explicit header
EIRP 16.0 dBm, gateway gain 6.0 dBi, cable 2.0 dB, design margin 3.0 dB, shadowing margin 7.7 dB (90%)
SF max path loss (dB) range free space (km) range UMa NLOS median (km) at 90 % (km)
7 141.0 310 1.96 1.25
8 143.6 418 2.29 1.45
9 146.2 564 2.67 1.70
10 148.8 761 3.11 1.98
11 151.4 1027 3.62 2.30
12 154.0 1385 4.22 2.68
d (km) FSPL (dB) UMa NLOS (dB)
0.1 71.2 90.9
0.2 77.2 102.4
0.3 80.7 109.2
0.5 85.2 117.8
0.7 88.1 123.5
1.0 91.2 129.6
1.5 94.7 136.4
2.0 97.2 141.3
3.0 100.7 148.2
4.0 103.2 153.1
5.0 105.2 156.9
coverage grid: 201 x 201 points at 50 m, 31416 inside the 5 km disc
SF share of area, fastest SF that closes (%) cumulative (%) by ring 0-1, 1-2, 2-3, 3-4, 4-5 km (%)
7 19.7 19.7 100, 73, 25, 6, 1
8 6.8 26.5 100, 83, 40, 12, 3
9 8.6 35.1 100, 91, 57, 22, 7
10 10.4 45.5 100, 96, 72, 37, 15
11 11.7 57.2 100, 99, 84, 53, 27
12 11.9 69.2 100, 100, 92, 70, 43
not covered at any SF: 30.8 % of the disc
EU868 DR0, 51-byte application payload: 2793.5 ms, 12 uplinks/hour at 1 %
US915 DR0 (SF10), 11-byte application payload: 370.7 ms, within the 400 ms dwell time
US915 DR0 (SF10), 12-byte application payload: 411.6 ms, over the 400 ms dwell time
How to read it:
- Time on air and duty cycle. Going from SF7 to SF12 buys 13 dB of link budget and costs 24 times the airtime (61.7 ms against 1482.8 ms). The battery pays for that airtime too. The largest EU868 uplink at DR0, 51 application bytes, takes 2793.5 ms, which allows only 12 uplinks per hour.
- Range. Free space gives 310 km at SF7. That number is meaningless at street level and is why free space must not be used for ground-level planning. The UMa median range is 1.96 km at SF7 and 4.22 km at SF12. To be covered at 90 % of locations on the edge of the cell, those shrink to 1.25 km and 2.68 km.
- The grid. Inside 1 km every point closes at SF7. Between 4 km and 5 km only 43 % of points close even at SF12. Overall 30.8 % of the disc is not covered. Serving the full 5 km radius here needs a better site, a higher antenna or a second gateway.
- US915. At DR0 (SF10, 125 kHz) an 11-byte payload takes 370.7 ms, within the 400 ms dwell time. A 12-byte payload takes 411.6 ms and does not fit. That is why RP002-1.0.4 limits the DR0 application payload in US902-928 to 11 bytes.
If your output differs, check the frequency units first. P.525 takes the frequency in MHz and the distance in km, while TR 38.901 takes the frequency in GHz and the distance in metres. Mixing them shifts the loss by tens of dB.
On a real radio
This section needs radios: an SX1262 development kit or module as the device and a LoRaWAN gateway connected to a network server.
- Measure, then fit. Drive a test device around the gateway and log RSSI and SNR for each uplink with its GPS position. Fit path loss against log distance and compare with the UMa curve. The measured spread replaces the 6 dB shadowing assumption.
- Check the gateway. The gateway concentrator, not an SX1262, receives the uplinks. Replace the sensitivity assumption with the figures from its datasheet, and add the actual antenna gain and feeder loss.
- Indoor devices. Meters in basements and cupboards add building entry loss. TR 38.901 has outdoor-to-indoor penetration models in clause 7.4.3. Add one before you promise indoor coverage.
- Regulation. Stay within the EIRP and duty-cycle limits of your region. RP002-1.0.4 summarises them per region but states that it is not intended to be an authoritative source of regional regulation. Check the national rules where you deploy.
Things to try
- Switch to US915. Set the uplink data rates to SF7 to SF10 at 125 kHz and the frequency to 902.3 MHz, and replace the duty-cycle limit with the US902-928 rules in RP002-1.0.4. Then add the 400 ms dwell-time check to every SF and see which payload sizes survive at each one. RP002-1.0.4 also has the US902-928 power limits.
- A rural site. Replace the UMa model with the TR 38.901 rural macro (RMa) NLOS model with a 35 m mast, valid to 5 km for NLOS.
- Correlated shadowing. Generate white Gaussian noise on the grid and smooth it with a 2D filter so that the correlation distance is 50 m, then rescale it to 6 dB standard deviation.
- A bigger payload. Set
APP_PAYLOAD = 51. Check the time on air at each SF against the 1 % budget for an hourly reporting interval with retransmissions. - Second gateway. Put a second gateway 4 km east, compute the loss to both, and take the better one per point. Report the coverage gain.
Summary
- A LoRaWAN link budget needs four documents: the regional parameters (power, duty cycle, data rates), the MAC frame format (overhead), the radio datasheet (sensitivity) and a propagation model. Name the version of each.
- Each SF step adds about 2.6 dB of link budget and doubles the airtime. SF12 had 13 dB more budget than SF7 and used 24 times the airtime here.
- The 1 % EU868 duty cycle allowed 583 uplinks per hour at SF7 and 24 at SF12 for a 12-byte payload.
- Free-space loss overstates ground-level range by two orders of magnitude. The TR 38.901 UMa NLOS model gave median ranges of 1.96 km at SF7 and 4.22 km at SF12.
- Shadowing turns a range into a probability. A 90 % location margin cut the SF12 range to 2.68 km, and 30.8 % of the 5 km disc was left uncovered.
Background: the LPWAN and IoT and link budgets and noise topic hubs, and the glossary entry for path loss.