Skip to content

Commit 79a5dd0

Browse files
authored
Merge pull request #461 from SimmerV/feature-add
feat: Coverage: per-pixel canopy + building DSM, multi-origin merge
2 parents 0e36a3b + 94a5309 commit 79a5dd0

33 files changed

Lines changed: 4046 additions & 132 deletions

Dockerfile.buildings

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# One-shot bake image. Run via `docker compose --profile bake run --rm building-bake`.
2+
# rasterio's manylinux wheels link against libexpat which python:slim variants
3+
# don't bundle — install it explicitly.
4+
FROM python:3.14-slim
5+
6+
RUN apt-get update \
7+
&& apt-get install -y --no-install-recommends libexpat1 \
8+
&& rm -rf /var/lib/apt/lists/*
9+
10+
# Cap GDAL's per-process cache. Default 5% of RAM × N workers blows past
11+
# Docker Desktop's 16 GB ceiling and OOMs mid-bake.
12+
ENV GDAL_CACHEMAX=64
13+
14+
WORKDIR /app
15+
16+
COPY scripts/requirements-buildings.txt scripts/
17+
RUN pip install --no-cache-dir -r scripts/requirements-buildings.txt
18+
19+
COPY scripts/building_tiles.py scripts/
20+
21+
CMD ["python", "scripts/building_tiles.py"]

Dockerfile.canopy

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# One-shot bake image. Run via `docker compose --profile bake run --rm canopy-bake`.
2+
# rasterio's manylinux wheels link against libexpat which python:slim variants
3+
# don't bundle — install it explicitly.
4+
FROM python:3.14-slim
5+
6+
RUN apt-get update \
7+
&& apt-get install -y --no-install-recommends libexpat1 \
8+
&& rm -rf /var/lib/apt/lists/*
9+
10+
# Cap GDAL's per-process cache. Default 5% of RAM × N workers blows past
11+
# Docker Desktop's 16 GB ceiling and OOMs mid-bake.
12+
ENV GDAL_CACHEMAX=64
13+
14+
WORKDIR /app
15+
16+
COPY scripts/requirements-canopy.txt scripts/
17+
RUN pip install --no-cache-dir -r scripts/requirements-canopy.txt
18+
19+
COPY scripts/canopy_tiles.py scripts/
20+
21+
CMD ["python", "scripts/canopy_tiles.py"]

Dockerfile.landcover

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
# One-shot bake image. Run via `docker compose --profile bake run --rm landcover-bake`.
2-
# Kept separate from the prod image to keep that image lean. rasterio ships GDAL
3-
# wheels on PyPI, so no system GDAL install is needed.
2+
# rasterio's manylinux wheels link against libexpat which python:slim variants
3+
# don't bundle — install it explicitly.
44
FROM python:3.14-slim
55

6+
RUN apt-get update \
7+
&& apt-get install -y --no-install-recommends libexpat1 \
8+
&& rm -rf /var/lib/apt/lists/*
9+
610
WORKDIR /app
711

812
COPY scripts/requirements-landcover.txt scripts/

RF-MODEL.md

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,38 @@ The full per-class P.452 (hₐ, dₖ) and P.833 (γ, A) parameters are visible i
103103

104104
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.
105105

106+
## Per-pixel building heights — JRC GHS-BUILT-H 100 m (optional)
107+
108+
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.
109+
110+
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:
111+
112+
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.
113+
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.
114+
115+
**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.
116+
117+
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.
118+
119+
**Operator runbook for the building bake:** [scripts/README-buildings.md](scripts/README-buildings.md).
120+
121+
## Per-pixel canopy heights — ETH 10 m (optional)
122+
123+
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.
124+
125+
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:
126+
127+
- The path-integral gate `zPath < terrain + canopyHeight` evaluates against measured canopy at every profile sample.
128+
- Where the measured value is 0 (clearings, fire scars, recent logging), MED contributes nothing for that sample — the path is above any canopy.
129+
- Where the bake doesn't cover a pixel (out-of-bbox / ocean adjacency), the loop falls back to the class-nominal value silently.
130+
- 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.
131+
132+
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.
133+
134+
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.
135+
136+
**Operator runbook for the canopy bake:** [scripts/README-canopy.md](scripts/README-canopy.md).
137+
106138
## Setup — running the bake
107139

108140
NLCD data is not bundled with MeshInfo (it's ~2 GB). Operators run a one-shot bake script after deploying:
@@ -119,6 +151,22 @@ CONUS at full resolution takes 15–90 min depending on CPU. Tiles are bind-moun
119151

120152
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.
121153

154+
For the optional canopy-height refinement, run the canopy bake separately:
155+
156+
```bash
157+
docker compose --profile bake run --rm canopy-bake # CONUS default; --scope global also supported
158+
```
159+
160+
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.
161+
162+
For the optional building-height refinement, run the building bake:
163+
164+
```bash
165+
docker compose --profile bake run --rm building-bake # global default; --scope conus also supported
166+
```
167+
168+
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.
169+
122170
## The aggression scaler
123171

124172
Inside the Coverage and Scan panels, the **Clutter** control is a 3-stop slider:
@@ -142,7 +190,7 @@ The default (1.0×) reflects ITU-R P.452 / P.833 published values. Don't sit at
142190
These are scope limits that affect prediction accuracy in specific scenarios:
143191

144192
- **Indoor receivers (ITU-R P.2109)** — RX inside a building gets +10–20 dB additional loss not modeled. Outdoor-to-outdoor only.
145-
- **Per-tree / per-building height**uses class-nominal canopy heights (15 m deciduous, 20 m evergreen, etc.), not measured heights from LIDAR. A single tall redwood next to your antenna isn't picked up.
193+
- **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.
146194
- **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.
147195
- **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.
148196
- **Polarization-dependent vegetation loss** — uses unpolarized averages (the slight per-polarization difference is in the noise floor here).
@@ -156,6 +204,8 @@ These are scope limits that affect prediction accuracy in specific scenarios:
156204
- ITU-R Rec. **P.2108** — newer clutter-loss recommendation (future migration target)
157205
- ITU-R Rec. **P.2109** — building entry loss (indoor RX, deferred)
158206
- USGS NLCD: <https://www.mrlc.gov/data>
207+
- ETH Global Canopy Height 2020 (Lang et al. 2023): <https://langnico.github.io/globalcanopyheight/>
208+
- JRC GHS-BUILT-H R2023A: <https://human-settlement.emergency.copernicus.eu/ghs_buH2023.php>
159209
- ITM v1.4: <https://github.com/NTIA/itm>
160210

161211
## How to validate

api/api.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,46 @@ async def server_config(request: Request) -> JSONResponse:
449449
tile_dir,
450450
)
451451

452+
# Canopy-height tiles for the P.833-9 vegetation loss loop. Pre-baked by
453+
# scripts/canopy_tiles.py; missing tiles 404 and the frontend falls back
454+
# to class-nominal heights. See RF-MODEL.md.
455+
canopy_cfg = self.config.get("canopy", {}) or {}
456+
if canopy_cfg.get("enabled", False):
457+
tile_dir = Path(canopy_cfg.get("tile_dir", "output/canopy"))
458+
if tile_dir.is_dir():
459+
app.mount(
460+
"/tiles/canopy",
461+
TileFiles(directory=str(tile_dir)),
462+
name="canopy_tiles",
463+
)
464+
logger.info("Mounted canopy-height tiles at /tiles/canopy from %s", tile_dir.resolve())
465+
else:
466+
logger.info(
467+
"Canopy-height tiles enabled but %s does not exist — frontend will use class-nominal heights. "
468+
"Run scripts/canopy_tiles.py to populate.",
469+
tile_dir,
470+
)
471+
472+
# Building-height tiles for the P.452 endpoint formula and ITM DSM.
473+
# Pre-baked by scripts/building_tiles.py; missing tiles 404 and the
474+
# frontend falls back to class-nominal. See RF-MODEL.md.
475+
buildings_cfg = self.config.get("buildings", {}) or {}
476+
if buildings_cfg.get("enabled", False):
477+
tile_dir = Path(buildings_cfg.get("tile_dir", "output/buildings"))
478+
if tile_dir.is_dir():
479+
app.mount(
480+
"/tiles/buildings",
481+
TileFiles(directory=str(tile_dir)),
482+
name="building_tiles",
483+
)
484+
logger.info("Mounted building-height tiles at /tiles/buildings from %s", tile_dir.resolve())
485+
else:
486+
logger.info(
487+
"Building-height tiles enabled but %s does not exist — frontend will use class-nominal heights. "
488+
"Run scripts/building_tiles.py to populate.",
489+
tile_dir,
490+
)
491+
452492
allow_origins = os.getenv("ALLOW_ORIGINS", "").split(",")
453493
logger.info("Allowed origins: %s (%d)", allow_origins, len(allow_origins))
454494

config.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,22 @@
138138
"tile_dir": "output/landcover",
139139
"source": "USGS NLCD 2024",
140140
},
141+
# Per-pixel measured canopy heights for the P.833 vegetation loss loop.
142+
# Tiles are pre-baked via scripts/canopy_tiles.py; absent tile_dir → frontend
143+
# falls back to class-nominal heights.
144+
"canopy": {
145+
"enabled": True,
146+
"tile_dir": "output/canopy",
147+
"source": "ETH Global Canopy Height 2020",
148+
},
149+
# Per-pixel measured building heights for P.452 endpoint clutter and the
150+
# ITM DSM. Tiles are pre-baked via scripts/building_tiles.py; absent
151+
# tile_dir → frontend falls back to class-nominal heights.
152+
"buildings": {
153+
"enabled": True,
154+
"tile_dir": "output/buildings",
155+
"source": "JRC GHS-BUILT-H R2023A",
156+
},
141157
"debug": False,
142158
}
143159

config.toml.sample

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -306,16 +306,56 @@ style = "mapbox/dark-v11"
306306
# mounts it at /tiles/landcover. Without a bake, the frontend falls back to a
307307
# default class everywhere — coverage still works, just less accurate.
308308
#
309-
# To populate tiles, run scripts/landcover_tiles.py once. See
310-
# scripts/README-landcover.md for the operator guide and RF-MODEL.md for
311-
# the RF model.
309+
# To populate tiles, run the one-time bake (idempotent, safe to re-run):
310+
# docker compose --profile bake run --rm landcover-bake
311+
# See scripts/README-landcover.md for sub-region bakes, disk/time estimates,
312+
# and the host-Python alternative. RF model details in RF-MODEL.md.
312313
# ─────────────────────────────────────────────────────────────────────────────
313314

314315
[landcover]
315316
enabled = true
316317
tile_dir = "output/landcover" # bind-mounted into the meshinfo container
317318
source = "USGS NLCD 2024" # surfaced in the map UI attribution chip
318319

320+
# ─────────────────────────────────────────────────────────────────────────────
321+
# Canopy heights — Optional
322+
# Per-pixel measured canopy heights for ITU-R P.833-9 path-integrated
323+
# vegetation loss. Replaces class-nominal heights (15-20 m by class) with
324+
# measurements from ETH Global Canopy Height 2020 (Lang et al. 2023, 10 m).
325+
# When tile_dir exists and enabled = true, the API mounts it at /tiles/canopy.
326+
# Without a bake, the frontend falls back to class-nominal heights.
327+
#
328+
# To populate tiles, run the one-time bake (idempotent, safe to re-run):
329+
# docker compose --profile bake run --rm canopy-bake
330+
# See scripts/README-canopy.md for sub-region bakes, disk/time estimates,
331+
# and the host-Python alternative.
332+
# ─────────────────────────────────────────────────────────────────────────────
333+
334+
[canopy]
335+
enabled = true
336+
tile_dir = "output/canopy" # bind-mounted into the meshinfo container
337+
source = "ETH Global Canopy Height 2020"
338+
339+
# ─────────────────────────────────────────────────────────────────────────────
340+
# Building heights — Optional
341+
# Per-pixel measured building heights for ITU-R P.452-17 endpoint clutter and
342+
# the ITM DSM along the propagation path. Replaces class-nominal heights
343+
# (4/9/15/25 m for the four NLCD developed-intensity classes) with
344+
# measurements from JRC GHS-BUILT-H ANBH (100 m global, CC-BY-4.0).
345+
# When tile_dir exists and enabled = true, the API mounts it at /tiles/buildings.
346+
# Without a bake, the frontend falls back to class-nominal heights.
347+
#
348+
# To populate tiles, run the one-time bake (idempotent, safe to re-run):
349+
# docker compose --profile bake run --rm building-bake
350+
# See scripts/README-buildings.md for sub-region bakes, disk/time estimates,
351+
# and the host-Python alternative.
352+
# ─────────────────────────────────────────────────────────────────────────────
353+
354+
[buildings]
355+
enabled = true
356+
tile_dir = "output/buildings" # bind-mounted into the meshinfo container
357+
source = "JRC GHS-BUILT-H R2023A"
358+
319359
# ─────────────────────────────────────────────────────────────────────────────
320360
# UI Customization — Optional
321361
# External tools and node detail links shown in the web UI.

docker-compose-dev.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,5 +87,23 @@ services:
8787
volumes:
8888
- ./output:/app/output
8989

90+
# `docker compose -f docker-compose-dev.yml --profile bake run --rm canopy-bake` — see scripts/README-canopy.md.
91+
canopy-bake:
92+
profiles: ["bake"]
93+
build:
94+
context: .
95+
dockerfile: Dockerfile.canopy
96+
volumes:
97+
- ./output:/app/output
98+
99+
# `docker compose -f docker-compose-dev.yml --profile bake run --rm building-bake` — see scripts/README-buildings.md.
100+
building-bake:
101+
profiles: ["bake"]
102+
build:
103+
context: .
104+
dockerfile: Dockerfile.buildings
105+
volumes:
106+
- ./output:/app/output
107+
90108
volumes:
91109
meshinfo_pgdata:

docker-compose.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,5 +85,23 @@ services:
8585
volumes:
8686
- ./output:/app/output
8787

88+
# `docker compose --profile bake run --rm canopy-bake` — see scripts/README-canopy.md.
89+
canopy-bake:
90+
profiles: ["bake"]
91+
build:
92+
context: .
93+
dockerfile: Dockerfile.canopy
94+
volumes:
95+
- ./output:/app/output
96+
97+
# `docker compose --profile bake run --rm building-bake` — see scripts/README-buildings.md.
98+
building-bake:
99+
profiles: ["bake"]
100+
build:
101+
context: .
102+
dockerfile: Dockerfile.buildings
103+
volumes:
104+
- ./output:/app/output
105+
88106
volumes:
89107
meshinfo_pgdata:

0 commit comments

Comments
 (0)