# MeshInfo Coverage / Scan RF Model

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.

## Executive summary

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 three components

### 1. Path loss — ITM / Longley-Rice (ITU-R P.526 family)

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.

### 2. Endpoint clutter loss — ITU-R P.452-17 §4.5.4

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.

### 3. Path-traversed vegetation — ITU-R P.833-9 §4.1 (modified exponential decay)

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.

### Avoiding double-counting

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.

## Where the land cover comes from

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.

## Per-pixel building heights — JRC GHS-BUILT-H 100 m (optional)

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:

1. **DSM into the ITM profile.** Mid-path samples become `terrain + measured_building_height` so ITM's diffraction algorithm sees rooftops as actual obstacles. Endpoints (TX and RX) stay bare-earth — your `txHeightM` / `rxHeightM` are 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.
2. **Measured `h_a` in P.452 endpoint clutter.** For developed-class pixels (21/22/23/24) at TX and RX, the measured ANBH replaces `cls.nominalHeightM` in 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](scripts/README-buildings.md).

## Per-pixel canopy heights — ETH 10 m (optional)

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 + canopyHeight` evaluates 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](scripts/README-canopy.md).

## Setup — running the bake

NLCD data is not bundled with MeshInfo (it's ~2 GB). Operators run a one-shot bake script after deploying:

```bash
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/landcover
```

CONUS 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](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:

```bash
docker compose --profile bake run --rm canopy-bake     # CONUS default; --scope global also supported
```

CONUS 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:

```bash
docker compose --profile bake run --rm building-bake   # global default; --scope conus also supported
```

Global 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.

## The aggression scaler

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

## What the model does *not* do

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.

## References

- 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>

## How to validate

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.
