← RFC

Design Note — Adaptive Modulation (SF/BW/CR) per Link

Status: Stage-3 design proposal (pre-sim for the per-link extension). Issue #66. Builds on docs/analysis/adaptive-modulation-note.md (Checkpoint-A Q3), adaptive-modulation-result.md (sim-result-v4), mac-spec.md §2.1/§8, packet-format.md §8.1/§8.2, and the wire types in infra/crates/proto/src/lib.rs / client/src/loomwave_proto.h. Every per-link claim below is a proposal flagged for Stage-2 sim validation. Date: 2026-06-22.

Companion to #65 (frequency authority, frequency-agility-multisector-note.md) and #64/v4 (spatial reuse). This note works out the modulation axis; #65 works out the frequency axis. They are the same control loop and should ship as one PHY-authority mechanism.


1. Problem and motivation

The as-built fleet runs a single fixed modulation: SF9 / BW125 / CR4:5 (client/src/main.cpp BW_KHZ = 125.0f, loomwave_proto.h writes buf[9] = (9<<3)|0b000, infra CellDesc { sf: 9, bw: 0b000, freq_offset: 0 }). The beacon already carries a CELL_DESC with sf/bw fields and clients parse them (cell_sf/cell_bw), but the value is hard-coded and the radio is configured statically — so the agility the wire already affords is unused.

Fixing modulation at SF9/BW125 leaves capacity and range on the table at both ends:

The structural enabler is already in place: the coordinator is the modulation authority (adaptive-modulation-note.md §1), and the exact inputs the SF decision needs — per-member SNR/RSSI — are already logged. REGISTER carries LINK_REPORTS (packet-format.md §8.2), gathered for routing and coordinator election. Adaptive modulation is the application of state already collected, not new sensing.


2.1 Per-cell (already wire-supported)

The whole cell shares one (SF, BW), announced in the beacon CELL_DESC (3 bytes: [23:19] SF, [18:16] BW, [15:0] freq_offsetproto::CellDesc::encode). Selection is bounded by the worst-margin client the cell must serve (adaptive-modulation-note.md §3.1). This is the version v4 measured (SF set per cell by construction). It is cheap: the field already exists and costs ≈0 incremental beacon airtime.

The slot-timing model is consistent with per-cell modulation because all slots are sized in the same ToA. The slot-fit invariant (mac-spec.md §2.1, bug #60) — slot width ≥ ToA(largest grantable frame) + G — holds for the whole superframe with one modulation, and the compile-time guards (lora_toa_ms_sf9(162) ≤ SLOT_W) stay valid by recomputing against the cell SF.

Each member gets its own (SF, BW, CR) matched to its measured link budget: high-SNR near members run SF7/BW250, the distant straggler runs SF11. This maximizes each member's rate instead of dragging the whole cell down to the worst client.

Tradeoff — it breaks the shared-beacon TDMA timing assumption. Today every slot is the same width because every frame is the same modulation. Slots are sized in time-on-air, and ToA is a strong function of SF/BW (see §7). With mixed SF, slots have mixed widths:

Net: per-link is a real capacity/reach win but is not free — it converts the slot geometry from "N × fixed width" to "variable-width packing," and pushes the slot-fit invariant from compile-time to runtime-per-grant. Per-cell captures most of the v4 SF gain at near-zero cost; per-link captures the residual at meaningful machinery cost.


3. Selection algorithm (proposal)

The coordinator already holds per-member SNR/RSSI (LINK_REPORTS, snr_q quantized SNR, packet-format.md §8.2). The selection is a table lookup against a link-budget margin.

Per-cell (Phase 1): pick the fastest preset whose required SNR + a margin M (proposal: M ≈ 6 dB, to absorb fading — the same capture margin v4 relied on) is met by the worst member the cell must keep. Members below margin either fall to a slower-SF sub-plane or are served by a reach radio (adaptive-modulation-note.md §3.1).

Per-link (Phase 2): independently, per member, pick the fastest preset whose demodulation SNR floor + M ≤ that member's reported SNR. Hysteresis on the SNR→SF mapping (proposal: 2–3 dB) to avoid per-frame thrashing as a member fades across a boundary — same anti-ping-pong concern as mac-spec.md §11 boundary clients. A member that moves/fades drops to a slower SF without losing its slot association (the handoff machinery of mode-and-handoff-note.md, with SF as a handoff parameter — adaptive-modulation-note.md §5.2).

Link-budget reference (SF vs sensitivity vs airtime). Sensitivity figures are the standard Semtech SX126x demod floors at BW125 (datasheet, not yet measured on the Loomwave fleet — flagged for calibration against the GL5712-UX reference receiver, frequency-agility-multisector-note.md §2). Airtime is the repo airtime law (§7), 162 B frame:

Preset Demod SNR floor RX sensitivity (BW125) ToA 162 B Use when
SF7/BW250 −7.5 dB ~−123 dBm 135 ms dense, all-high-SNR, short range
SF7/BW125 −7.5 dB ~−126 dBm 270 ms dense, short range
SF9/BW125 (today) −12.5 dB ~−131 dBm 873 ms the as-built hub-cell baseline
SF11/BW125 −17.5 dB ~−136 dBm 3412 ms sparse/rural, distant clients
SF12/BW125 −20.0 dB ~−137 dBm 6332 ms max reach, link-margin-limited

Each SF step ≈ +2.5 dB sensitivity for ≈ 2× airtime — the lever the coordinator trades against measured margin. (BW250 buys ~2× rate for ~3 dB sensitivity loss — the other half of the knob.)


4. Wire encoding

4.1 Per-cell — already present, CR is the gap

CELL_DESC encodes SF (5 bits, 5–12) and BW (3-bit code) today. No wire change for per-cell SF/BW. Two notes:

The beacon grant list (grants[8], n_grants, #63) carries owning addresses but no modulation. Per-link needs each grant to carry its (SF, BW[, CR]). Two options:

Recommendation: option (A) for granted message slots (the slots whose width actually varies), keep the telemetry pool at the cell modulation (per-cell), VER-gated as a v0b011 beacon extension. The client must also be told the slot width implied by its grant's modulation; since width is a deterministic function of (SF,BW) via the shared airtime law (§7), the client computes it rather than receiving it — provided firmware carries the general lora_toa_ms(sf,bw,pl) law, not just the SF9 special-case it has today (mac/src/lib.rs::lora_toa_ms_sf9, client/src/main.cpp::lora_toa_ms). Generalising that ToA law across SF/BW is a prerequisite and must be unit-tested to cross-validate sim/firmware/infra identically (the bug #61 discipline, sim/tests/test_slot_fit.py).


5. Interaction with frequency authority (#65) and spatial reuse (#64/v4)

frequency-agility-multisector-note.md §1 makes the same architectural argument for frequency that adaptive-modulation-note.md makes for modulation: INFRA is the authority, the beacon is the announcement channel, the client follows. They share one descriptorCELL_DESC already binds (SF, BW, freq_offset) in 3 bytes, and mac-spec.md §8 / adaptive-modulation-note.md §6 already state "modulation authority = channel authority: one INFRA control loop owns (frequency, SF) jointly." The selection loop in §3 should emit (freq, SF, BW, CR) as one decision, not three.

Spatial reuse (v4) composes multiplicatively. v4 measured that non-overlapping cells reuse the same (freq, SF) tuple cleanly because capture (clients ~0.7 km from own hub, ≥3 km from neighbours, ~17 dB margin) makes intra-cell delivery collision-immune to neighbours; cross-cell waste vanishes by ~12 km ≈ 1.5× cell range. Per-cell adaptive SF is what sets each cell's tuple in that reuse plan, and multi-radio coordinators (mac-spec.md §8, node-roles-spec.md) can run a robust reach plane and a fast capacity plane concurrently — each radio its own CELL_DESC. Per-link modulation operates inside one cell and is orthogonal to reuse.

The one place these interact dangerously: boundary clients (mac-spec.md §11). A client hearing two cells at different SF — or, under per-link, holding a slow per-link SF near a cell boundary — is the open item v4 explicitly deferred (adaptive-modulation-result.md §2 caveat: capture margin is lost at the boundary). The per-link hysteresis (§3) and the §11 cell-association rule must be designed together.


6. Phased plan

Phase 1 — per-cell adaptive (cheap; beacon already supports it). - Drive CellDesc.sf/.bw from the coordinator's worst-margin selection (§3) instead of the hard-coded {9,0b000,0}. - Generalise the airtime law to lora_toa_ms(sf, bw, pl) across SF7–SF12 / BW125–250 (replaces the SF9 special-case), unit-tested across sim/firmware/infra (bug #61 discipline). - Recompute the slot-fit invariant and Superframe::build geometry against the chosen cell SF (still one width per superframe — the §2.1 invariant holds unchanged in form). - Freeze the BW code→kHz table in packet-format.md. - Sim-validate against v4's per-cell numbers as the regression baseline (this is the part v4 already measured; Phase 1 is mostly making the firmware honor the wire field that already exists).

Phase 2 — per-link (needs the slot-width-per-grant work). - Per-grant modulation byte (§4.2 option A), VER-gated beacon extension. - Variable-width slot packing in the coordinator; move the slot-fit invariant from compile-time const-assert to a runtime per-grant check in Superframe::build. - Per-member SNR→SF selection with hysteresis; SF as a handoff parameter (mode-and-handoff-note.md). - Boundary-client behavior under mixed/per-link SF (joint with mac-spec.md §11). - New sim work required — per-link is not covered by v4 (which set SF per cell by construction; adaptive-modulation-result.md "Open follow-ups" explicitly lists "SF selection driven by measured per-client link budget" as unmeasured). Measure: realized capacity vs per-cell, packing efficiency of variable-width slots, and thrash/stability of the per-member loop under churn. - CR encoding (§4.1) only if a sim shows CR adaptation pays for its VER bump.


7. Airtime budget table (real ToA numbers)

Computed from the Semtech ToA law as implemented in the repo (mac/src/lib.rs doc-comment; CR4:5, 16-symbol preamble, CRC on, explicit header, LDRO auto). Validation anchor: this formula reproduces the repo's pinned constants exactly — SF9/BW125 162 B = 873 ms here vs the firmware's unit-tested lora_toa_ms_sf9(162) = 874 (±1 ms rounding), and 135 B = 751, 35 B = 280, matching mac/src/lib.rs tests.

Time-on-air, 162 B max-message frame (the slot-fit-critical case):

SF7 SF8 SF9 (today) SF10 SF11 SF12
BW125 270 ms 478 ms 873 ms 1583 ms 3412 ms 6332 ms
BW250 135 ms 239 ms 437 ms 792 ms 1460 ms 3166 ms

Time-on-air, 35 B telemetry record:

SF7 SF8 SF9 SF10 SF11 SF12
BW125 85 ms 160 ms 280 ms 559 ms 1118 ms 2073 ms
BW250 43 ms 80 ms 140 ms 280 ms 518 ms 1036 ms

Implied per-grant slot width (162 B ToA + ~3 ms guard) and raw capacity (162 B, back-to-back):

Preset slot width (ToA+G) raw pkt/s vs SF9/BW125
SF7/BW250 ~138 ms 7.4 ×6.7
SF7/BW125 ~273 ms 3.7 ×3.4
SF9/BW125 (today) ~876 ms (≈ as-built SLOT_W 900) 1.1 ×1.0
SF11/BW125 ~3415 ms 0.3 ×0.27
SF12/BW125 ~6335 ms 0.2 ×0.16

Slot-fit consequence (the §2.1 / bug #60 invariant, per-grant): the as-built SLOT_W = 900 ms exactly holds one 162 B frame at SF9/BW125 (873 ms + guard). Under per-link, a single SLOT_W cannot span this range — SF11/BW125 needs ~3.9× that width, SF7/BW250 wastes ~85% of it — which is precisely why per-link requires variable-width slot packing (§2.2, §4.2) rather than a wider fixed slot. The whole-superframe budget (FRAME_T = 10400 ms, mac-spec.md §2.1) must accommodate the sum of heterogeneous widths plus beacon + advert + downlink, checked at runtime in Superframe::build.


8. One-line summary

The beacon already carries (SF, BW, freq_offset) and the coordinator already logs per-member SNR — Phase 1 (per-cell adaptive) is mostly making the firmware honor a wire field it currently hard-codes, recovering the ~×3.4 SF lever v4 measured; Phase 2 (per-link) trades a real machinery cost — variable-width slots and a per-grant slot-fit invariant — for the residual per-member rate, and is the part still unmeasured and owed a sim.