Load elevation data onto a node opt-in
A node that holds the elevation of its area of operations judges every positioned neighbour's path against the ground: line of sight, the first Fresnel zone at each link's own frequency, and the diffraction loss at the ridge in between. A link sliding into terrain shadow is steered away from before the radio confirms the fade. This guide takes free elevation data to a running node and shows where to read what it computes. The mechanism is described in Terrain.
/etc/atlas/ on each node, and a position for every node that should be judged: [interface] position for fixed nodes, the telemetry socket or PUT /api/position for moving ones.Get elevation data
Any of these free global datasets works. Each comes as 1°×1° tiles named after their south-west corner: N42E024 covers 42–43° N, 24–25° E.
| Dataset | Spacing | Format | Notes |
|---|---|---|---|
| SRTM 1 arc-second | about 30 m | .hgt | Public-domain US government data. Bakes directly. |
| NASADEM | about 30 m | .hgt | Reprocessed SRTM with fewer voids. Public domain. Bakes directly. |
| Copernicus GLO-30 | about 30 m | GeoTIFF | A surface model: it includes buildings and forest canopy, which a radio path also has to clear. Export each tile to .hgt first. Free to use under the Copernicus DEM licence, which requires an attribution notice when you publish or redistribute the data or products made from it. |
To export a GLO-30 tile, resample it to the 3601×3601 grid of a one-arc-second .hgt and give the output the south-west-corner name the baking tool reads:
gdal_translate -of SRTMHGT -outsize 3601 3601 \
Copernicus_DSM_COG_10_N42_00_E024_00_DEM.tif N42E024.hgt
Bake a tile
nexus-atlas-terrain/tools/dem_bake.py turns one .hgt into one .nadt tile, the flat little-endian grid the daemon reads. Crop to your area of operations with --bbox (south, west, north, east, in degrees): a full one-degree tile is about 26 MB, a crop of ten by eleven kilometres well under half a megabyte.
$ ./dem_bake.py N42E024.hgt -o ao.nadt --bbox 42.10 24.00 42.20 24.14
wrote ao.nadt: 362x506, N=42.20028 W=24.00000 step=1.0"
$ ./dem_bake.py N42E024.hgt -o region.nadt
wrote region.nadt: 3601x3601, N=43.00000 W=24.00000 step=1.0"
An area that crosses a degree line takes one bake per source tile. Where tiles overlap, the finest one answers, so a detailed crop of a valley can sit beside a coarser regional tile and win wherever it covers.
Configure the node
Copy the tiles to /etc/atlas/terrain/ on every node. A relative entry in tiles is read from there; an entry can also be an absolute path or a directory, which loads every *.nadt inside it.
[terrain]
enabled = true
tiles = ["ao.nadt", "region.nadt"] # /etc/atlas/terrain/…
frequency_mhz = 5180 # paths between other nodes, links without an entry
link_frequency_mhz = { rfd900 = 915, wifi = 5180 }
antenna_height_m = 2.0
[[link]]
name = "rfd900"
# …
[[link]]
name = "wifi"
# …
link_frequency_mhz is keyed by [[link]] name. The first Fresnel zone grows as the frequency falls, so the same path can be clear for the 5 GHz link and marginal for the 915 MHz one; each link is scored at its own frequency. The remaining keys keep their defaults in most deployments: k_factor (4/3, the standard atmosphere), fresnel_clearance (0.6), samples (256 per path), interval_ms (1000), max_paths (512), max_tiles_mb (256) and clamp_to_ground (true). All of them are in the configuration reference.
Validate before you restart. atlasd config check refuses values out of range (frequencies 1 to 100 000 MHz, antenna 0 to 1000 m, k_factor 0.5 to 10, samples 16 to 4096, interval_ms 100 to 60 000) and enabled = true with no tile. It reads each tile's 48-byte header, never its grid, and warns about a file that is missing, truncated or not a baked tile, and about a link_frequency_mhz key that names no link:
$ atlasd config check
⚠ terrain.link_frequency_mhz.lte: no [[link]] is named `lte` — this frequency is never used
✓ /etc/atlas/config.toml is loadable (1 warning)
A tile the daemon cannot read is a warning, not an error: terrain is advisory, so the node carries traffic without that tile's coverage. Tiles load at start, so restart atlasd after changing the section. The one key that applies live is prediction. The log says what the node holds:
terrain: loaded ao.nadt — 362x506 samples, 42.1000..42.2003 N, 24.0000..24.1403 E, 31 m cells, 0.4 MB
terrain: 1 tile(s) cover 42.1000..42.2003 N, 24.0000..24.1403 E (about 11.1 x 11.6 km), finest 31 m, loaded in 0 ms
terrain: assessing paths every 1000 ms (prediction on — terrain steers scheduling and SPF)
Load the same tiles on every node of the fleet. Routing weighs each edge from the tiles a node holds, and nodes holding different tiles weigh the same edge differently.
Check what the node loaded
GET /api/terrain answers from the running daemon. With tiles loaded:
$ curl -s 'http://gs:9800/api/terrain?interface=atlas0'
{"interface":"atlas0","loaded":true,"prediction":true,
"tiles":[{"name":"ao.nadt","rows":362,"cols":506,"bounds":[42.1,24.0,42.2003,24.1403],
"cell_m":30.87,"min_m":402,"max_m":1012,"bytes":366392}],
"bounds":[42.1,24.0,42.2003,24.1403],"cell_m":30.87,"load_errors":[],
"grid_max":512,"grid_default":256}
Without them, "loaded": false, configured says whether the config has the section, load_errors lists each tile that did not load with its reason, and hint says how to bake, copy and list a tile. GET /api/terrain/grid?interface=atlas0&bbox=S,W,N,E&size=256 returns the elevation the dashboard draws: a resampled .nadt as application/octet-stream, at most 512 samples a side and never finer than the finest tile, with −32768 where no tile covers a cell. A box off the tiles is a 422; repeating a request with If-None-Match is a 304. Both endpoints read under the same gate as /api/stats. See the API reference.
Read the snapshot
The stats snapshot carries a terrain block:
$ jq .terrain /tmp/atlas/atlas0.json
{
"enabled": true, "prediction": true,
"frequency_mhz": 5180, "antenna_height_m": 2, "k_factor": 1.3333,
"fresnel_clearance": 0.6, "lookahead_secs": 2,
"self_state": "ok", "self_ground_m": 412,
"neighbors": [
{ "node_name": "relay", "state": "marginal", "terrain_risk": 0.48, "shadowed": false,
"clearance_m": 7.1, "fresnel_ratio": 0.31, "fresnel_obstruction": 0.69,
"diffraction_db": 2.4, "distance_m": 6460, "since": 1792765921,
"links": [
{ "link": "rfd900", "frequency_mhz": 915, "terrain_risk": 0.48, "steering": true },
{ "link": "wifi", "frequency_mhz": 5180, "terrain_risk": 0.0, "steering": false } ] },
{ "node_name": "drone", "state": "shadowed", "terrain_risk": 1.0, "shadowed": true,
"clearance_m": -49, "fresnel_obstruction": 1.0, "diffraction_db": 19.2,
"distance_m": 6600, "worst": { "lat": 42.151, "lon": 24.07, "terrain_m": 998 },
"links": [ … ] }
],
"edges": [ { "from": "1f3a..c2d0", "to": "77be..0e19", "state": "shadowed",
"terrain_risk": 1.0, "shadowed": true } ],
"counters": { "passes": 122, "paths_last": 5, "unknown_last": 0, "skipped_last": 0,
"shadow_enter": 2, "shadow_leave": 1, "route_changes": 7,
"link_penalties": 3, "steered": 10, "pass_us_last": 76 }
}
self_stateisok,no_position(this node has no fix) orout_of_coverage(its fix is off the tiles).- Per neighbour:
stateisclear,marginal,shadowedorunknown(with areason:out_of_coverage,path_leaves_coverage,no_position,self_no_position).clearance_mis the smallest gap between the line of sight and the ground, negative when the ground stands in it;fresnel_ratiois that gap over the first-Fresnel radius at the worst point;fresnel_obstructionis the share of the zone the terrain occupies;diffraction_dbis the knife-edge loss at the dominant obstruction;worstis where it is.terrain_riskis the worst of the neighbour's links. - Per local link: the link's frequency, its risk, and
steering— true while terrain is what sets that link's scheduler score, above the measured loss and every other prediction. edges[]is routing's view: every assessed link between positioned nodes, judged from advertised positions atfrequency_mhz.- Counters:
route_changescounts destinations whose next hop moved when terrain risks changed,link_penaltiesthe times terrain began setting a link's score,steeredtheir sum;skipped_lastabove zero meansmax_pathscut the last pass short;pass_us_last,pass_us_maxandpass_us_avgare the pass time.
Watch the journal
A neighbour's path entering terrain shadow is journalled as link.terrain_shadow (warn); leaving it, as link.terrain_clear (info):
$ atlasd journal --code link.terrain_shadow --json -n 1
{"v":1,"seq":2207,"ts":1792765944,"category":"link","severity":"warn","code":"link.terrain_shadow",
"msg":"terrain now stands between this node and drone (49 m into the line of sight, 19.2 dB diffraction) — traffic is steered away from the path; the link stays up for as long as its probes answer",
"fields":{"interface":"atlas0","node":"77be..0e19","state":"shadowed","node_name":"drone",
"terrain_risk":1.0,"clearance_m":-49.0,"diffraction_db":19.2}}
The link stays up: only missed probes declare it dead. A shadowed link whose probes still answer remains a last resort.
Open the Terrain tab
On the node's dashboard (:9800) a Terrain tab appears beside Topology once the node reports a [terrain] section. It draws the node's own grid for a box around the positioned nodes as a lit, elevation-coloured surface; every positioned node at its latitude, longitude and altitude with its platform symbol, name and address; and each link coloured by its line of sight: clear green, marginal amber, shadow red, unknown grey dashed. A shadowed path shows its worst obstruction and a cyan dashed reroute through the node this node's routes actually use. Orbit, zoom and pan with mouse or touch; follow this node, reset the view, toggle labels and set vertical exaggeration from 1× to 5×. Clicking a node gives its height above the grid; clicking a link gives its risk, clearance, Fresnel obstruction and diffraction. Below the view, a table lists each neighbour with its per-link risk and steering, and the counters. The 3D code loads only when the tab opens, and everything it draws comes from the node, so it works with no internet connection. With no tile loaded, the tab lists the load errors and how to bake, copy and list a tile.
Analysis without steering
To watch what terrain would do before letting it steer, set prediction = false. The analysis, the snapshot block, the journal and the Terrain tab all carry on; no link score or route cost changes. The key applies live, so turning it back on needs no restart.
Boundaries
A path with a position or a stretch outside the tiles reads unknown and carries no penalty: unknown is never treated as clear. The model is a single knife-edge with one antenna height for every node, and tiles and positions are taken to be on the same vertical datum; clamp_to_ground lifts a fix reported below the ground onto it. A moving node's position reaches the others within about a second of moving 25 m and at least every 10 s while it moves (position adverts), so routing moves a moving node's shadowed path one position advert after the move while that node's own scheduler reacts within one pass. The full list is under Known limitations.