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).
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:
sf_select.fastest_sf_for_snr): the fastest SF whose Semtech demod
floor + a margin M the member's measured SNR clears. Floors are the design-note §3 datasheet
figures (SF7 −7.5 … SF12 −20 dB); BW125 noise floor ≈ −117 dBm.client/src/main.cpp SF9/BW125).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).
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.
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.
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.)
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.
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).
CELL_DESC field it currently hard-codes (design-note §6 Phase-1).
Its capacity is below static-SF9 by design; the point of per-cell-auto is coverage, and the
selection loop is stable enough to ship without elaborate anti-ping-pong machinery.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.