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.
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:
sim-result-v4 (adaptive-modulation-result.md) measured that letting each cell run the fastest modulation its clients' link budgets allow — combined with spatial reuse — yields ×16.0 aggregate throughput at 50 nodes (2.04 → 32.75 pkt/s) and, reorganising the 500-node single-domain ceiling into MediumFast cells, ×34 per-node (0.247 → 8.29 msg/min, ~1 msg/4 min → 8+ msg/min). The v4 result decomposed the ×16 as ~×3.4 from SF (MediumFast vs LongFast) × ~×5 from spatial reuse. The SF lever alone is worth ~3–4×. mac-spec.md §8 already records this as the licensing result for INFRA-as-modulation-authority.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.
The whole cell shares one (SF, BW), announced in the beacon CELL_DESC (3 bytes: [23:19] SF, [18:16] BW, [15:0] freq_offset — proto::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:
SLOT_W (900 ms today) cannot serve both: it overruns for the slow member and wastes ~85% airtime for the fast one.mac-spec.md §2.1 slot-fit invariant: "a slot's width MUST be ≥ the airtime of the largest frame that may be granted into it." Under per-link modulation the invariant becomes per-grant: slot_width(member) ≥ ToA(MAX_DATA_FRAME @ member's (SF,BW)) + G. The superframe is no longer N equal slots; it is a sum of heterogeneous slot widths, and the coordinator must pack it so the total still fits FRAME_T (mac-spec.md §2, Superframe::build overcommit check).ToA(MAX_DATA_FRAME) ≤ SLOT_W no longer reduces to a single constant. The slot-fit assertion must move to a runtime check in Superframe::build keyed on each grant's modulation (the const-assert can only bound the fastest allowed SF; the runtime packer enforces the rest).mac-spec.md §3), but a client must now learn its own slot's width from the schedule, not infer it from a single cell SLOT_W. That is the new wire cost (§4).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.
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.)
CELL_DESC encodes SF (5 bits, 5–12) and BW (3-bit code) today. No wire change for per-cell SF/BW. Two notes:
bw field is a 3-bit code; firmware convention is bw=0b000 ⇒ BW125 (loomwave_proto.h:213), while the radio drivers key on kHz directly (sx126x: 125→0x04, 250→0x05, 500→0x06). The code→kHz table must be frozen in packet-format.md before BW is varied on the wire (proposal: 0=125, 1=250, 2=500). Today only 0 is exercised.CELL_DESC has no coding-rate field; CR is fixed at 4:5 everywhere (the airtime law assumes it: lora_toa_ms_sf9 uses ×(CR+4) with CR_idx=1). Adding adaptive CR costs 2 bits in the descriptor. The 3-byte CELL_DESC has 16 bits of freq_offset; 2 of those (or a VER-gated 4th descriptor byte) could hold CR. Cost is small but it is a wire-format change → VER bump (packet-format.md §3 reserves VER 0b010–0b111; the PACT-lite block already gates on VERSION=0b010). Recommendation: defer CR to Phase 2 — SF/BW dominate the airtime trade; CR4:5→4:8 is only ~1.6× and rarely the right lever.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:
addr24 ‖ mod_byte where mod_byte = (SF<<3)|BW (same packing as CELL_DESC byte 9). Cost: +1 B/grant on the beacon (8 grants → +8 B). The client reads its grant, learns its slot's modulation, and configures the radio for that slot only. This is the cleaner fit: modulation travels with the grant that defines the slot, so the slot-width packing and the modulation are announced together.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).
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 descriptor — CELL_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.
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.
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.
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.