← RFC

Adaptive SF — the SELECTION loop, measured (per-cell-auto vs per-link)

Status: Stage-2 result. Date: 2026-06-22. Tag: sim-result-v5. Reproduce: cd sim && PYTHONPATH=. python3 -m experiments.adaptive_sf_selection Regression test: sim/tests/test_sf_select.py (14 tests). Model: sim/loomwave_sim/sf_select.py (SNR→SF link budget + TDMA capacity).

This measures the loop adaptive-modulation-result.md (v4) explicitly left unmeasured. v4's "Open follow-ups" listed "SF selection driven by measured per-client link budget (here SF is set per cell by construction)", and adaptive-modulation-design.md §6 Phase-2 lists per-link mixed-SF slots as owing a sim. v4 proved the capacity envelope of fast SF + reuse; this proves what an auto-selection loop actually realizes from per-member SNR, and what per-cell leaves on the table versus per-link.

Everything below is a real run. Capacity uses the repo's unit-tested ToA law (phy.time_on_air_ms, 162 B max-message frame, BW125, CR4:5) and the repo path-loss model (phy.PathLossModel, n=2.7). Per-member SNR is derived from distance + fading, the same input the coordinator already logs via LINK_REPORTS (design-note §1).


1. The model (what's new vs v4)

v4 ran the full SimPy TDMA collision plane but told each cell its SF. This experiment adds the missing physics — the SNR→SF decision — and the TDMA capacity each selection rule yields:

Cell geometry: members placed uniform-over-area in a 6 km cell (17 dBm, n=2.7) so SNR spans the SF ladder — near members hit SF7, edge stragglers SF11 — the regime where the rules diverge. Distance→SNR→SF, median path:

distance SNR (dB) fastest SF
300 m +28 SF7
1 km +13 SF7
2 km +5 SF7
4 km −3 SF8
8 km −11 SF11
15 km −19 unreachable

Aggregate delivered capacity (pkt/s, 162 B frames) and reach (members served), averaged over 8 cell-geometry draws, no fading (the structural gap, not a fading artifact):

N static-SF9 (pps / reach) per-cell-auto (pps / reach / SF) per-link (pps / reach) oracle-static (pps / reach / SF)
10 0.873 / 7.6 0.760 / 10.0 / SF9.8 1.295 / 10.0 1.053 / 6.9 / SF8.5
50 0.833 / 36.4 0.632 / 50.0 / SF10 1.225 / 50.0 1.105 / 17.1 / SF7.2
200 0.861 / 150.4 0.632 / 200.0 / SF10 1.266 / 200.0 1.159 / 73.4 / SF7.2

The gap per-cell leaves on the table — per-link / per-cell:

N per-cell-auto per-link gap
10 0.760 pps 1.295 pps ×1.70
50 0.632 pps 1.225 pps ×1.94
200 0.632 pps 1.266 pps ×2.00

Per-link roughly DOUBLES per-cell capacity (×1.7–2.0), and the gap grows with N. The cause is exactly the motivation in the #66 design: with ≥1 edge straggler, the worst-margin member pins the whole cell to SF10, so every fast near-member pays SF10 airtime it doesn't need. Per-link lets each near-member run its short SF7 slot and charges only the straggler its long SF11 slot. At N=200 there is always a straggler, so per-cell saturates at SF10 (0.632 pps) while per-link holds ~1.27 pps — the ×2.0 gap is the per-link prize, and it is what justifies building the variable-width-slot machinery (design-note §2.2/§4.2).

2.1 The honest twist: per-cell-auto trades capacity for REACH, not for raw pps

Per-cell-auto is slower in pps than static-SF9 (0.63 vs 0.83 at N=50) — but it serves 100% of members where static-SF9 serves only ~73% (36.4/50). Static-SF9 looks faster only because it drops every member below SF9's floor (the ~27% of stragglers) and lets the survivors run SF9. So the per-cell-auto win is coverage (no member is left off the net), not throughput; the throughput win belongs to per-link. This is the real shape of the trade and should be stated plainly: per-cell-auto buys reach; per-link buys capacity.

2.2 vs the oracle best-static-SF

The oracle picks the single fixed SF that maximizes delivered pps — and it lands on a fast SF (SF7.2 at N≥50), because maximizing pps means abandoning the stragglers and running the dense core fast. Oracle-static gets 1.1 pps but reaches only ~34% of members (17/50). Per-link beats even the oracle-static (1.23 vs 1.10 pps) while reaching 100% — adaptation's value over any constant is that it doesn't have to choose between speed and reach globally.


3. Thrash / stability under churn + fading

500-tick loop: each tick draws i.i.d. log-normal fading (σ = 6 dB) per member and random-walks member distance (std 150 m/tick). A "flip" is a committed-SF change between ticks.

N per-cell pps per-link pps gap cell-flip / tick link-flip / member-tick
50 0.144 0.804 ×5.6 0.000 0.382
200 0.142 0.716 ×5.0 0.000 0.409

Per-cell is essentially thrash-FREE; per-link thrashes ~0.4 flips per member per tick — at N=50 that is ≈19 SF changes per tick across the cell vs ~0 for per-cell. Small-N confirms the mechanism (cell-flip rises only when the cell is small enough that the worst member itself changes): cell-flip/tick = 0.000 (N=5), 0.002 (N=10), 0.004 (N=20), →0 by N≥50. Reason: under hysteresis the committed cell SF is pinned by the slowest member, and the slowest member's identity is stable — at any realistic N there is always someone at the SF11/SF12 edge, so the cell SF barely moves. Per-link, every member rides its own fading independently, so the aggregate flip rate is ~N× higher.

This is a second, independent argument for the phased plan: per-cell is not only cheaper on the wire, it is dramatically more stable — it converts N independent fading processes into one slow worst-member process. Per-link's ~doubled capacity comes with ~100× the control churn (each flip is a re-announced per-grant modulation byte, design-note §4.2), which the per-link beacon machinery and handoff loop must absorb.

(The churn-loop per-cell pps is far below the static-snapshot 0.63 because σ=6 dB fading plus movement repeatedly pushes the worst member to SF12, pinning the whole cell slow — a per-cell pathology per-link sidesteps by isolating the straggler.)


4. Hysteresis tuning

Flip rate and capacity vs the hysteresis band (N=50, σ=6 dB), per-link (per-cell is ~0 at all bands):

hysteresis (dB) link-flip / member-tick per-link pps
0.0 0.436 0.902
1.0 0.416 0.863
2.0 0.394 0.823
2.5 0.382 0.804
3.0 0.371 0.784
5.0 0.326 0.706

Hysteresis trades capacity for stability monotonically: 0→5 dB cuts flips ~25% (0.436→0.326) at a ~22% capacity cost (0.902→0.706). The design-note's proposed 2–3 dB band sits at the knee — 2.5 dB already removes ~12% of flips for ~11% capacity, and the curve flattens past 3 dB. The selector uses asymmetric hysteresis (sticky on speeding up — needs headroom; prompt on falling back — a link about to drop must drop SF immediately or be lost), which is why even 0 dB hysteresis keeps reach intact; the band only governs the speed-up ping-pong.

Recommendation: ship per-cell-auto first (Phase 1) — it is thrash-free regardless of hysteresis and is the firmware's immediate next step (honor the wire field it hard-codes). Tune the per-link band (Phase 2) to 2.5–3 dB; below 2 dB the flip rate is not worth the marginal capacity, above 3 dB you pay capacity for stability you've mostly already bought.


5. What's idealized vs measured

Measured (real runs): - All capacity is the repo's unit-tested ToA law over the real path-loss model. The distance→SNR→SF mapping, the worst-member pinning, the per-link superframe sum, and the flip counts are all computed, not asserted. - The per-cell-vs-per-link gap, the reach/capacity trade, and the thrash ratio are direct outputs averaged over seeded geometry/fading draws.

Idealized (honest boundaries — same spirit as v4 §2 caveat): - No intra-cell PHY collisions. TDMA gives each member a slot and we sum per-slot rates. v4 already validated the collision-free TDMA plane on the full SimPy collision model, so this isolates the SF-selection question rather than re-litigating contention. Cross-cell reuse is out of scope here (v4 covered it; per-link operates inside one cell). - Datasheet SNR floors, uncalibrated. SF7 −7.5 … SF12 −20 dB are SX126x datasheet figures (design-note §3), flagged there for calibration against the GL5712-UX reference receiver. The gap is robust to the absolute floors (it depends on SF ladder spacing, ~2.5 dB/step, not the zero point); the absolute reach distances will move with calibration. - i.i.d. per-tick fading (memoryless). Real fading is temporally correlated, so field flip rates will be lower than reported — this over-estimates thrash, the safe direction for hysteresis tuning. Movement is a simple Gaussian random walk on distance. - No per-link wire cost charged. The +1 B/grant modulation byte (design-note §4.2 option A) and the runtime slot-fit packing are not deducted from per-link capacity; per-link's real net win is slightly below the ×2 measured here once beacon overhead and packing inefficiency are charged. Per-cell pays ≈0 incremental wire cost (the CELL_DESC SF field already exists).


6. What this licenses

Open follow-ups: charge the per-link beacon/packing overhead against its capacity (close the gap to its net value); BW as a second selection axis (BW250 buys ~2× rate for ~3 dB, design-note §3); correlated fading + a real mobility trace once the DB export lands; boundary-client behavior under per-link SF (joint with mac-spec.md §11), still deferred.