> Source: https://nexusatlas.dev/docs/3.0.0/concepts/scheduling — Nexus Atlas developer documentation (Concepts). Converted from the HTML page; the page is canonical.

# Multi-link scheduling

The scheduler is where Atlas earns its keep: continuous measurement of every link, and a per-packet placement decision that shifts traffic away from trouble *gradually* — not on a binary up/down trigger that flaps. This page covers how links are measured, the eight strategies you can choose from, and the knobs that make asymmetric bearers behave.

## Probes: how a link earns trust

Every (peer, link) pair is probed on its own clock — `[link_monitor] probe_interval_ms`, default 250 ms. Each probe updates an RTT EWMA, a windowed jitter figure and a windowed loss rate. Two principles govern the bookkeeping:

- **Aliveness requires evidence.** A link is never considered alive before its first completed round trip, and is declared dead after `dead_probe_count` (default 5) consecutive misses.

- **A late probe is not proof of life.** Past `unusable_rtt_ms` (default 10 000; per-link override available) a probe that does return stops counting as evidence the link works — so a link buried under its own transmit backlog reads as *down*, not as "up at 40 s RTT with 0% probe loss". The measured RTT still reaches your dashboards.

Per-link overrides matter on mixed bonds: `[[link]] probe_interval_ms` lets a cellular link answer every 250 ms while a geostationary VSAT (~600 ms RTT) is probed every 1000 ms, each with timeouts scaled to its own cadence. And because sparse control probes underestimate loss on full-size packets, `data_loss_probe_every_n` (default 10) piggybacks loss accounting on every Nth real data fragment — the loss figure the scheduler acts on is measured on traffic shaped like yours.

## Queues below the tunnel

Probes measure the path; they do not see the backlog in front of it. A link can answer every probe and still hold seconds of traffic in a buffer between the daemon and the air, so each link also reports how long a packet sent now would wait before it reaches the device. Per link in the snapshot:

- `links[].queue_delay_ms` — the wait, in milliseconds: the larger of the [standing-queue delay](https://nexusatlas.dev/docs/3.0.0/concepts/scheduling#standing-queue) on the daemon's own send queue for that link and the kernel egress delay below it. This is the figure the scheduler uses and the link card's Queue row shows; `atlasd status` prints it as `queue N ms` once it reaches a millisecond.

- `links[].egress_queue` — the kernel side, sampled every [egress_queue_sample_ms](https://nexusatlas.dev/docs/3.0.0/config#cfg-link_monitor-egress_queue_sample_ms) (200 ms; `0` turns sampling off):

- `socket_bytes` — bytes the link's UDP socket has not yet sent.

- `qdisc_bytes`, `qdisc_drops` — backlog and cumulative drops of the bound device's root qdisc, read over netlink (no `tc` shell-out).

- `serial_bytes` — a serial radio's UART transmit queue, as its adapter reports it; on a [native serial link](https://nexusatlas.dev/docs/3.0.0/concepts/serial-link), everything below Atlas — the writer hand-off, the serial line and the radio's own buffer (omitted on other links).

- `delay_ms` — those bytes as time. The qdisc backlog is converted at the rate the queue actually drained between two samples while it stays busy, so a link whose rate has collapsed below its declared capacity reads its true wait; otherwise at the declared capacity. The socket figure stands in only for a device without a qdisc. Serial bytes, plus the PPP qdisc backlog, are converted at the reported baud rate, ten bit times per byte.

The serial figures come over the [telemetry socket](https://nexusatlas.dev/docs/3.0.0/concepts/telemetry): the SiK adapter sends `sik_serial_tx_queued_bytes` and `sik_baud` in its `extras`, and a report older than 3 s is ignored. Prometheus carries `atlas_link_queue_delay_seconds`, `atlas_link_egress_queue_bytes` (label `queue="socket"|"qdisc"`), `atlas_link_egress_queue_delay_seconds` and `atlas_link_qdisc_drops_total`. [[link_monitor] standing_queue = false](https://nexusatlas.dev/docs/3.0.0/config#cfg-link_monitor-standing_queue) keeps every figure visible but stops the scheduler from reacting to it. Both keys reload without a restart.

**Boundaries.** A link bound to a wildcard address has no single device to read, so its `egress_queue` is absent. The radio's or modem's own buffer cannot be read from the host — except on a native serial link, where the radio reports its buffer fill in-band and the link paces on it; a probe RTT that climbs while every figure above reads empty is the sign of a queue inside the device.

## The standing-queue signal

Queue length means a different wait on every bearer — 32 packets are under a millisecond on gigabit Ethernet and seconds on a serial radio — so each link judges its queue by time. The send worker records how long every packet waited in the daemon's queue for that link (its sojourn) and applies the controlled-delay test to it, with constants that follow the link's smoothed RTT:

```
target   = max(5 ms,   RTT / 10)
interval = max(100 ms, 2 × RTT)

standing  when the minimum sojourn over an interval stays above target
          for a whole interval; cleared by the first packet under target
delay     = that minimum while standing, else 0
```

A queue that drains always lets some packet through almost at once, so its minimum stays near zero however bursty the traffic; only a queue that never empties keeps even its shortest wait above target. The same rule runs on the kernel egress figure (`links[].egress_queue.delay_ms`), because a backlog that builds in a device queue leaves the daemon's own queue short. Per link:

- `links[].standing_queue` — true while either queue is standing.

- `links[].queue_delay_ms` — the larger of the two delays: the estimate above and the kernel egress delay.

Nothing is dropped by the test; the deadline check already discards packets past their budget. The delay enters the scheduler's delivery time — `(RTT/2 + size/capacity + queue_delay_ms) × 1/(1 − loss)` — which ranks links for `lowest_delivery_time`, `shortest_path`, the `adaptive` cost and `redundancy_factor`, and the per-class feasibility check. Weighted, priority and broadcast placement and goodput-weighted stripes do not use it. The QoS escalation guard skips escalation while any link to the peer waits more than `escalation_guard.queue_delay_max_ms` (policy document, default 250). [[link_monitor] standing_queue = false](https://nexusatlas.dev/docs/3.0.0/config#cfg-link_monitor-standing_queue) keeps the figures in the snapshot and takes them out of both decisions. Prometheus: `atlas_link_standing_queue` (1 while standing) and `atlas_link_queue_delay_seconds`; `atlasd status` prints `queue N ms standing`; the link card shows a yellow standing-queue chip.

**Boundaries.** The test reads the daemon's queue and the kernel's. A buffer inside the radio or modem is inferred from a rising probe RTT, never read.

## The eight strategies

Set `[scheduler] strategy` to one of:

| Strategy | Placement rule | Reach for it when |
|----------|----------------|-------------------|
| lowest_delivery_time | Default. Per fragment, minimise the expected delivery time, (size/capacity + RTT/2 + queue delay) × 1/(1 − loss) — capacity-, size-, queue- and loss-aware, so measured or predicted loss moves traffic. | General bonding; links of unequal speed. |
| weighted_round_robin | Spread packets proportionally to [[link]] weight. | Deliberate load ratios across similar links. |
| broadcast | Every fragment on every alive link; the receiver's anti-replay window deduplicates. | N× bandwidth is cheaper than any loss; survives N−1 failures. |
| priority | Strict failover through priority_order, a list of link names. | A cheap primary and an expensive standby (e.g. satellite backup). |
| shortest_path | SPF on hop counts; local link choice falls back to lowest_delivery_time. | Multi-hop meshes where route stability beats RTT-chasing. |
| adaptive | 1→2→3-link redundancy driven by cost ratios between the best links. | Redundancy that scales with how bad things are. |
| loss_adaptive | Same escalation, driven purely by measured loss (enter at 20% / 50%). | Loss-dominated bearers. |
| latency_adaptive | Same escalation, driven by best-link RTT (enter at 40 ms / 100 ms). | Latency-sensitive traffic where rising RTT is the early warning. |

The three adaptive strategies share an anti-flap discipline: **escalation is instant, de-escalation waits** (`adaptive_dwell_ms`, default 5000) and the enter/exit thresholds form a hysteresis gap, so a link hovering at a threshold cannot make the bond oscillate. For the non-adaptive strategies, `redundancy_factor = N` tops up any placement to the best N links at once.

```
[scheduler]
strategy = "latency_adaptive"
latency_mirror_ms = 40.0        # ≥ this best-link RTT: send on 2 links
latency_triplicate_ms = 100.0   # ≥ this: send on 3
adaptive_dwell_ms = 5000        # de-escalation dwell; escalation is immediate
```

## Gradual by construction

Link scores update on every probe: loss inflates a link's cost geometrically (`1/(1−loss)`), jitter inflates it further, and cost-based strategies shift traffic as the numbers move — long before anything is "down". The only binary events are the ones that should be binary: a dead link leaving the candidate set, and the adaptive strategies stepping their redundancy level. By the time a failing link is formally dead, it was already carrying almost nothing.

## Predictive failover

`[link_monitor] prediction_source` — `none` (default), `rssi`, `position`, or `both` — lets the scheduler act on where a link is *heading*:

- **rssi:** the signal-strength trend from [radio telemetry](https://nexusatlas.dev/docs/3.0.0/concepts/telemetry) is projected `rssi_lookahead_secs` ahead against `rssi_floor_dbm`; a link fading toward the floor has its perceived loss inflated (capped at 75%) so traffic drains off it before it dies.

- **position:** peers advertise position and velocity; the projected distance is scored against each radio's range envelope (`[[link]] max_range_m`, warning ramp from `range_warn_m`) and stretched links get expensive — locally and in mesh route costs.

Prediction only ever biases scheduling. It never declares a link dead — probes remain the sole authority — and dashboards keep showing measured values, never predicted ones. A third source, [terrain](https://nexusatlas.dev/docs/3.0.0/concepts/scheduling#terrain), has its own section and its own switch.

## Terrain opt-in

Range and signal prediction share a blind spot. A formation moving together keeps every distance unchanged while a ridge slides into the path between two of its nodes; a drone descending behind a hill three kilometres from its ground station stays well inside its radio's range. Both links score perfectly until they die. With [[terrain]](https://nexusatlas.dev/docs/3.0.0/config#cfg-terrain) enabled and elevation tiles loaded on the node, it sees that coming from the ground itself. Loading the tiles is covered in [Load elevation data onto a node](https://nexusatlas.dev/guides/terrain).

### What the node computes

For every neighbour whose advert carries a position, one task samples the ground along the path ([samples](https://nexusatlas.dev/docs/3.0.0/config#cfg-terrain-samples), 256) and evaluates three things against it:

- **Line-of-sight clearance** between the two antennas (position plus `antenna_height_m`), with the earth's bulge corrected by the effective-earth-radius factor `k_factor` (4/3, the standard atmosphere).

- **First-Fresnel-zone obstruction** at each local link's own frequency (`link_frequency_mhz` keyed by link name, `frequency_mhz` otherwise). The path is sampled once and evaluated per frequency, so a 915 MHz link and a 5 GHz link to the same neighbour get their own verdicts.

- **Knife-edge diffraction loss** at the dominant obstruction, single knife-edge after ITU-R P.526.

These collapse to one `terrain_risk` per path and link: 0 while [fresnel_clearance](https://nexusatlas.dev/docs/3.0.0/config#cfg-terrain-fresnel_clearance) (0.6) of the first zone is clear, 1 once the ground reaches the line of sight, linear in between — the same shape as range risk. The path is judged now and `rssi_lookahead_secs` ahead along both nodes' velocities, and the worse verdict holds. The states are `clear`, `marginal` (the sightline holds but terrain eats into the zone, or will) and `shadowed` (terrain stands between the antennas). A pass runs every `interval_ms` (1000), or within 250 ms of a position moving, and covers at most `max_paths` (512) paths: this node's neighbours first, then the links between other nodes. A full 512-path pass costs about 3 ms of CPU on a desktop processor (5.8 µs per 256-sample path at one frequency).

### How it steers

- **The scheduler, at once.** Terrain risk joins the RSSI and range risks with one `max()` in the link scores: the scheduler's view of a link's loss is inflated by risk × 0.75, the same ceiling the other predictions use. The scheduler chooses between this node's own links, so it uses this node's latest fix; traffic leaves a shadowed link within one pass.

- **Routing, on the next advert.** SPF raises an edge's cost by up to four times on the same `max()` with range risk. It weighs every edge from *advertised* positions at `frequency_mhz`, never a fresher local reading, so every node holding the same tiles computes the same weights from the same adverts and link-state routing stays loop-free.

- **Advisory only.** Terrain never marks a link dead and never changes a measured value. A fully shadowed link whose probes still answer stays alive as a last resort; only missed probes declare death.

- **Unknown is not clear.** A node with no position, a position off the tiles, or a path that leaves them reads `unknown`: no risk value and no penalty in either direction.

`[terrain] prediction` (true) is the switch for steering and the one key of the section that applies live. Off, the analysis, the snapshot's `terrain` block, the `link.terrain_shadow` and `link.terrain_clear` journal events and the 3D view carry on and nothing is steered. Terrain is independent of `prediction_source`. `clamp_to_ground` (true) lifts a fix reported below the ground onto it before the antenna height is added, because GPS altitude on the ellipsoid can sit tens of metres from tiles on the geoid.

### Seeing it

The snapshot's `terrain` block gives this node's `self_state`, per neighbour the state, risk, clearance, Fresnel figures, diffraction loss and worst point, per local link its frequency, risk and whether terrain is `steering` its score, `edges[]` as routing sees them, and counters for passes, shadow entries, route changes and link penalties. The dashboard's Terrain tab draws the node's own elevation grid in 3D with every positioned node at its real position and each link coloured by line of sight; a shadowed path shows its worst obstruction and the reroute through the relay the routes actually use. `GET /api/terrain` reports the loaded tiles and `GET /api/terrain/grid` serves the grid the view draws. The engineering write-up, with diagrams and captures of the view, is on [nexusatlas.io](https://nexusatlas.io/prediction#terrain).

### Boundaries

Load the same tiles on every node: nodes holding different tiles weigh the same edge differently, which link-state routing cannot keep loop-free, and the hop limit bounds any loop. 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](https://nexusatlas.dev/docs/3.0.0/concepts/mesh#position-adverts)), so routing moves a moving node's shadowed path one position advert after the move while that node's scheduler reacts within one pass; velocity projection covers `rssi_lookahead_secs`. The model is a single knife-edge, with no clutter or buildings beyond what a surface model such as GLO-30 carries, one antenna height for every node, and tiles and positions on the same vertical datum. See [Known limitations](https://nexusatlas.dev/limitations#terrain).

## Metered links

A link with a data budget declares it: `quota = "20 GB"` with `quota_period` (`monthly`, `weekly`, `daily`, `rolling-30d`), an anchor day, and a warning threshold (`quota_warn_pct`, default 80). Accounting is honest — wire bytes, including framing, probes, handshakes and redundancy copies — and persists across restarts. An over-budget link drops out of the scheduler's candidate set, but **a quota never takes down the last usable path**: when the expensive link is the only link, it carries the traffic and the journal records the crossing.

## Mirrors, stripes and aggregation

In the [transport-class path](https://nexusatlas.dev/docs/3.0.0/concepts/qos), each class's multipath policy compiles to a typed send plan: **Mirror** (a copy on every listed link — redundancy) or **Stripe** (each packet on exactly one link, weighted by effective goodput *capacity × (1 − loss)* — aggregation). Striping is what makes two 20 Mbps bearers carry materially more than one: measured 36.5 Mbps across two such links under 60 Mbps offered load, 91% of the theoretical sum, versus 19.2 Mbps when the same traffic was mirrored. Links that miss a class's latency budget are excluded from its stripe set before weights are computed, and an emptied set falls back to the single best link rather than dropping traffic.

## A tuning checklist for asymmetric bonds

- Declare honest capacities per link (`capacity = "64 kbps"` … `"1.5 Gbps"`) — the default assumption is 80 Mbps, and cost functions divide by it.

- Slow the probe cadence on high-RTT bearers (`probe_interval_ms` per link) and raise their `unusable_rtt_ms`.

- Budget metered links with `quota` so the scheduler, not an invoice, discovers the ceiling.

- Give narrowband radios a slower mesh advertisement cadence — see [route flooding on narrow bearers](https://nexusatlas.dev/docs/3.0.0/concepts/mesh#narrow-bearers).

- Prefer an adaptive strategy when link quality varies faster than a human can retune; prefer `priority` when the economics, not the physics, should decide.
