Planning a LoRa Mesh on Paper: A 400-Line Python Simulator
I wanted to put a 6-node LoRa mesh on three rooftops and a hilltop in my city. The first thing I learned: nobody sells a LoRa mesh planner. Tools I tried:
- FLoRa (built on ns-3) — physics-accurate, but ns-3 takes an evening to install
- LoRaSim (MATLAB) — good models, but the MATLAB license is the bottleneck
- A spreadsheet — fine for "will SF7 reach?" but useless for duty cycle and routing
So I built loramesh. It's 400 lines of pure stdlib Python, runs anywhere, and answers the three questions I actually need answered before buying $200 of SX1278 modules.
The three questions
1. Will the link close? For a 3 km link at 868 MHz with a 2 dBi whip, the simulator returns:
pair d (m) rss (dBm) noise (dBm) snr (dB) connected
n0-n1 3000 -110.58 -117.03 6.45 True
The RSS matches what the Semtech SX1276 datasheet predicts to within a dB. The "connected" column comes from the SX1276 sensitivity table for SF9/BW125, which is -126 dBm. SNR = 6.45 dB > -7.5 dB (the minimum for SF9) → connected.
2. Am I under the 1% duty limit? EU868 has a 1% per-hour cap on most sub-bands. The simulator's rolling-window regulator claims airtime and returns False if the budget is hit:
from loramesh.duty import DutyCycle
from loramesh.airtime import airtime_seconds
t = airtime_seconds(payload_bytes=20, sf=10, bw_hz=125_000)
dc = DutyCycle(limit_pct=1, window_s=3600)
if dc.claim(t):
transmit()
else:
defer()
For a 20-byte packet at SF9/BW125 the airtime is 185 ms. At 1% of 3600 s = 36 s budget, that's 194 packets per hour. If I want to send one packet every 30 s, that's 120/hour — fine. If I want 5 packets/min, that's 300/hour — duty is over.
3. Will multi-hop work? A 4-hop linear chain: PDR drops from 100% (single hop) to maybe 60% (4 hops) because each link is independent. The simulator runs a controlled flood with a hop cap and reports end-to-end PDR:
from loramesh.mesh import MeshSimulator
sim = MeshSimulator(nodes, seed=0)
pdr = sim.run_traffic(src="n0", dst="n7", count=50)
print(f"PDR: {pdr * 100:.1f}%")
The flood router is AODV-style: RREQ from the source, RREP back along the first path the destination hears. Route table prefers fresher sequence numbers and breaks ties on hop count.
What I learned the hard way
Airtime at high SF is brutal. SF11 takes 700+ ms for a 20-byte payload. SF12 is over a second. The duty cycle limit is the binding constraint on throughput — not the link budget. Most "the LoRa didn't deliver" debugging sessions are actually "we ran out of duty budget".
Path loss follows log-distance with PL0 exponent ~2.5-3.0 in urban areas. Free-space (PL0=2.0) is wildly optimistic. The simulator defaults to PL0=2.5, and you can crank shadowing σ up to 6 dB to model "there is a hill between the houses".
Multi-hop is not magic. Each hop costs airtime and duty budget. A 4-hop chain with 1% duty is actually a 4%-of-original-time-share budget. Either you shrink the chain, you raise the duty cap (different sub-band), or you lower the data rate (higher SF = more airtime, so worse).
How it compares to FLoRa
FLoRa is the reference. It models interference at the bit level, captures the orthogonality between SFs, and is validated against hardware measurements. If you're publishing a paper, use FLoRa.
If you're planning a deployment and want to know "is this link even going to work in a typical week, and how often can I send a packet?" — use loramesh. The CLI prints a 30-line table; you read it; you decide.
The code
The whole thing is one Python package, five files, zero deps. The airtime formula is the standard Semtech one (symbol period, header flags, low-data-rate optimisation). The link model is log-distance with shadowing. The duty cycle model is a deque of (start, duration) slots with rolling-window eviction. The router is plain BFS with hop cap.
# from loramesh.routing
def flood(src, dst, neighbors, hop_cap=8):
if src == dst: return [src]
visited = {src}
frontier = [(src, [src])]
while frontier:
cur, path = frontier.pop(0)
for nbr in neighbors[cur]:
if nbr in visited: continue
new_path = path + [nbr]
if nbr == dst: return new_path
visited.add(nbr)
if len(new_path) < hop_cap:
frontier.append((nbr, new_path))
return None
33 unit tests, all stdlib, run in 0.25 s. No Docker, no ns-3, no MATLAB.
Try it
git clone https://github.com/AmSach/loramesh
cd loramesh
pip install pytest
pytest tests/ -v
python -m loramesh.cli --nodes 8 --distance 3000 --seed 42
If you're building a LoRa mesh, run the CLI with your actual distance, payload size, and SF. The output will tell you what the radio will tell you — only 10x faster.
Top comments (0)