This is the propagation model behind the Coverage and Best Neighbors (Scan) tools on the map. It predicts how far a given Meshtastic node can reach in a specific terrain — the output is the colored coverage bubble around an origin pin and the per-target reach classification in the scan tool.
For each receiver pixel (or scan target), MeshInfo computes:
RSSI = TX_power + TX_gain + RX_gain
− path_loss_ITM ← terrain diffraction (Longley-Rice)
− clutter_loss ← buildings + vegetation (ITU-R)
− cable_loss
margin = RSSI − sensitivity − fade_margin
Pixels with positive margin are reachable; pixels with margin ≥ 15 dB are reliable. The model is terrain-aware (real elevation everywhere) and per-pixel land-cover-aware (real building / forest classification at every sample). It's calibrated to ITU-R recommendations, not invented numbers.
The Irregular Terrain Model is the industry standard for predicting how a radio wave bends, diffracts, and scatters over real terrain. MeshInfo runs the canonical ITM v1.4 implementation as WebAssembly per pixel, sampling real elevations from USGS 3DEP / Tilezen along each propagation path.
ITM models terrain diffraction over bare earth and produces a basic transmission loss in dB. It does not model anything above the ground — no buildings, no trees, no rooftops. That's where the next two components come in.
Each end of the path (TX and RX) has a height-gain correction based on:
- The antenna's height above local terrain (h)
- The nominal clutter height at that location (hₐ — e.g. 20 m for evergreen forest, 25 m for dense urban)
- The nominal distance from antenna to the clutter (dₖ — typically 20–100 m)
- Frequency (915 MHz for US Meshtastic)
The formula:
A_h = 10.25 · F_fc · exp(−d_k) · {1 − tanh[6 · (h/h_a − 0.625)]} − 0.33 [dB]
At 915 MHz, F_fc ≈ 1. The shape: antenna inside clutter ≈ 19 dB loss; antenna above clutter ≈ 0 dB, smooth transition through h ≈ 0.625 · hₐ.
Worked examples:
| Class | hₐ | dₖ | h | Aₕ |
|---|---|---|---|---|
| Dense urban | 25 m | 0.02 km | 2 m (handheld) | 19.7 dB |
| Dense urban | 25 m | 0.02 km | 30 m (tower) | ≈ 0 dB |
| Suburban | 9 m | 0.025 km | 2 m | 19.5 dB |
| Suburban | 9 m | 0.025 km | 10 m | ≈ 0 dB |
| Evergreen forest | 20 m | 0.05 km | 2 m | 19.1 dB |
| Evergreen forest | 20 m | 0.05 km | 25 m | ≈ 0 dB |
| Open water | — | — | any | 0 dB |
This explains why a handheld inside trees barely gets a kilometer, but the same node 5 m above the canopy reaches 30+ km — the model captures both regimes correctly.
For path segments that pass through foliage (between the endpoint zones), the model accumulates additional attenuation:
L = A · {1 − exp[−γ · d / A]} [dB]
Where γ is the per-metre specific attenuation (dB/m) and A is the saturation ceiling (dB) for the class. Properties:
- Linear in distance for short grazes (10 m of evergreen → ~6 dB)
- Saturates for long penetration (1000 m of evergreen → ~27 dB, doesn't grow further)
Per-class accumulation: a path crossing 200 m of deciduous forest then 200 m of evergreen forest computes both losses separately and sums them.
P.452 endpoint clutter handles "antenna immersed in nearby clutter" (within ~50 m). P.833 path-integration handles "ray traveling through canopy beyond that zone." MeshInfo skips the first/last dₖ km of profile from MED accumulation so the same metre of clutter isn't counted twice.
The per-pixel classification uses USGS NLCD (National Land Cover Database) — public domain, 30 m native resolution, 16 standard classes for CONUS, well-validated by EPA / USGS / MRLC.
Classes the model uses:
| ID | Class | Penetrable? |
|---|---|---|
| 11 | Open Water | — |
| 12 | Perennial Ice/Snow | — |
| 21 | Developed, Open Space | — |
| 22 | Developed, Low Intensity | — |
| 23 | Developed, Medium Intensity | — |
| 24 | Developed, High Intensity | — |
| 31 | Barren Land | — |
| 41 | Deciduous Forest | yes |
| 42 | Evergreen Forest | yes |
| 43 | Mixed Forest | yes |
| 52 | Shrub/Scrub | yes |
| 71 | Grassland/Herbaceous | — |
| 81 | Pasture/Hay | — |
| 82 | Cultivated Crops | yes |
| 90 | Woody Wetlands | yes |
| 95 | Emergent Herbaceous Wetlands | yes |
The full per-class P.452 (hₐ, dₖ) and P.833 (γ, A) parameters are visible in the class legend popover inside the Coverage and Scan settings panels — click "Show class legend" to see the dB at 2 m AGL for every class.
Outside the United States (or anywhere the NLCD bake doesn't cover), the model falls back to Mixed Forest as a conservative default for every pixel. Future work tracks adding ESA WorldCover as a global fallback.
NLCD's developed-intensity classes (21/22/23/24) ship with class-nominal heights of 4 / 9 / 15 / 25 m from P.452 Table 4. These are continental averages — wrong by ±15 m for any specific neighbourhood. They also can't put real diffractors (apartment blocks, industrial silos, isolated office towers) into the ITM propagation profile.
When building tiles are baked from JRC GHS-BUILT-H R2023A ANBH (Average Net Building Height, 100 m global, CC BY 4.0), two integration sites in the model use the measurements:
- DSM into the ITM profile. Mid-path samples become
terrain + measured_building_heightso ITM's diffraction algorithm sees rooftops as actual obstacles. Endpoints (TX and RX) stay bare-earth — yourtxHeightM/rxHeightMare AGL above local ground, not on top of a presumed building. Operators with rooftop-mounted antennas should add the building's height to their antenna AGL field manually. - Measured
h_ain P.452 endpoint clutter. For developed-class pixels (21/22/23/24) at TX and RX, the measured ANBH replacescls.nominalHeightMin the height-gain formula. A 2 m handheld in a 100 m cell that ANBH says is 4 m gets the same ~19 dB endpoint loss; in a cell that's actually 14 m, the cell-specific value drives the calculation. Non-developed classes (vegetation, water, etc.) keep their class-nominal heights so trees don't get erased when a forest pixel happens to read ANBH = 0.
100 m averaging caveats. ANBH averages over the built portion of each 100 m cell, so a neighbourhood of 30% 9 m houses + 5% 25 m apartment block reads as ~11 m — the apartment block is captured as elevated DSM but its sharp edges get smeared. ITM's above-rooftop diffraction (the dominant path at typical link distances) is well-served by this; ground-level street-canyon waveguide effects need ray-tracing, not raster, regardless of resolution.
The "Building heights" toggle in the Coverage and Scan settings panels lets operators turn the refinement off (compare measured-vs-nominal, or use bare-earth ITM for a baseline). Default is on.
Operator runbook for the building bake: scripts/README-buildings.md.
NLCD tells the model what kind of canopy is at a pixel; class-nominal heights from P.452 Table 4 (15 m deciduous, 20 m evergreen, 15 m mixed, 10 m woody-wetland, 2 m emergent-wetland) tell it how tall that canopy is. Real canopy varies — a 50 m redwood next to a 5 m madrone in the same NLCD "Evergreen Forest" pixel both got coded as 20 m without measurement.
When canopy tiles are baked from ETH Global Canopy Height 2020 (Lang et al. 2023, 10 m global, CC BY 4.0), the P.833 MED loop uses the measured height per sample instead of the class-nominal:
- The path-integral gate
zPath < terrain + canopyHeightevaluates against measured canopy at every profile sample. - Where the measured value is 0 (clearings, fire scars, recent logging), MED contributes nothing for that sample — the path is above any canopy.
- Where the bake doesn't cover a pixel (out-of-bbox / ocean adjacency), the loop falls back to the class-nominal value silently.
- Endpoint clutter (P.452) still uses class-nominal heights — Tier 3 only refines the path-integrated MED. Endpoint heights are a Tier-2 (per-pixel building heights) refinement.
If the bake includes the optional standard-deviation file (scripts/canopy_tiles.py --include-stddev), pixels with σ ≥ height are blended 50/50 with the class-nominal so a single noisy ETH pixel can't dominate the integral.
The "Canopy heights" toggle in the Coverage and Scan settings panels lets operators turn the refinement off (e.g. to compare measured-vs-nominal predictions, or to validate the older behavior). Default is on.
Operator runbook for the canopy bake: scripts/README-canopy.md.
NLCD data is not bundled with MeshInfo (it's ~2 GB). Operators run a one-shot bake script after deploying:
pip install -r scripts/requirements-landcover.txt
# Download NLCD source from MRLC and extract, then:
python scripts/landcover_tiles.py --source /path/to/nlcd_*.tif --out output/landcoverCONUS at full resolution takes 15–90 min depending on CPU. Tiles are bind-mounted into the meshinfo container automatically, no extra config needed.
Full operator runbook: scripts/README-landcover.md
Once tiles exist, the API mounts them at /tiles/landcover/{z}/{x}/{y}.png and the frontend fetches them on every coverage compute. The "Land cover: USGS NLCD" status chip in the Coverage panel shows whether tiles are healthy or the model is using the fallback class.
For the optional canopy-height refinement, run the canopy bake separately:
docker compose --profile bake run --rm canopy-bake # CONUS default; --scope global also supportedCONUS canopy bake is ~30 GB of source COGs + ~1–2 GB of output tiles. Time: 1–4 hours depending on the ETH file server and CPU. Without this bake, the model uses class-nominal canopy heights — the coverage tool still works.
For the optional building-height refinement, run the building bake:
docker compose --profile bake run --rm building-bake # global default; --scope conus also supportedGlobal building bake is ~1.8 GB of source GeoTIFF + ~1–2 GB of output tiles. Time: 1–3 hours. Without this bake, the model uses class-nominal heights for developed classes and bare-earth ITM — the coverage tool still works.
Inside the Coverage and Scan panels, the Clutter control is a 3-stop slider:
| Stop | Multiplier | When to use |
|---|---|---|
| Conservative | 0.7× | Predictions are pessimistic — measured links beat what the model says |
| Calibrated | 1.0× | Default. ITU baseline. Use this unless you have measured-link data |
| Aggressive | 1.3× | Predictions are optimistic — measured links fall short. Heavier obstruction than published averages |
The scaler multiplies the final (A_h_TX + A_h_RX + L_v) clutter-loss sum. ITM path loss and free-space loss are unaffected.
The default (1.0×) reflects ITU-R P.452 / P.833 published values. Don't sit at 0.7× or 1.3× without a reason — the calibrated baseline is the most accurate prediction. The slider exists because:
- Real-world obstruction varies: a Sierra-foothills canopy is denser than the published "evergreen forest" averages; a Mojave Desert "shrub/scrub" is thinner than the averages
- Operators with measured links can tune to match observations
- The same calibration won't be perfect everywhere on Earth
These are scope limits that affect prediction accuracy in specific scenarios:
- Indoor receivers (ITU-R P.2109) — RX inside a building gets +10–20 dB additional loss not modeled. Outdoor-to-outdoor only.
- Per-building height — when the building bake is in place, JRC GHS-BUILT-H 100 m measurements replace class-nominal heights for developed classes and feed buildings into the ITM DSM. The 100 m averaging captures first-order diffraction physics but smooths individual rooftops; per-building precision (a single tall office tower across the street from your antenna) needs vector pipelines beyond this raster model.
- Seasonal foliage — deciduous classes assume leaf-on (summer) at 0.5 dB/m. Out-of-leaf is ~0.15 dB/m; not modeled. Future work.
- Frequencies other than 915 MHz — the P.833 specific-attenuation values are calibrated at 915 MHz. Adding 868 MHz (EU) or 433 MHz would need a per-band lookup.
- Polarization-dependent vegetation loss — uses unpolarized averages (the slight per-polarization difference is in the noise floor here).
- Atmospheric refractivity — fixed at N=301 (Continental Temperate, NA Meshtastic default). Maritime / arid regions are slightly different.
- ITU-R Rec. P.526 — diffraction-related propagation
- ITU-R Rec. P.452-17 — terrestrial interference; §4.5.4 is the clutter formula
- ITU-R Rec. P.833-9 — vegetation attenuation; §4.1 is the MED model
- ITU-R Rec. P.2108 — newer clutter-loss recommendation (future migration target)
- ITU-R Rec. P.2109 — building entry loss (indoor RX, deferred)
- USGS NLCD: https://www.mrlc.gov/data
- ETH Global Canopy Height 2020 (Lang et al. 2023): https://langnico.github.io/globalcanopyheight/
- JRC GHS-BUILT-H R2023A: https://human-settlement.emergency.copernicus.eu/ghs_buH2023.php
- ITM v1.4: https://github.com/NTIA/itm
The right way to know if the model is accurate for your mesh: pick known links with measured RSSI/SNR and predicted distance, run them through the Best Neighbors / Coverage tools, compare predicted vs. measured. The aggression slider exists for exactly this tuning step.
A formal validation harness (feed N known links → publish RMSE) is on the roadmap but depends on a corpus of validated link data appearing.