Status: Stage-2 result. Date: 2026-06-22. Tag: sim-result-v6.
Reproduce: cd sim && PYTHONPATH=. python3 -m experiments.per_link_sf
Regression test: sim/tests/test_per_link_frame.py (11 tests).
Model: sim/loomwave_sim/sf_select.py — per_cell_frame_capacity / per_link_frame_capacity
(adds the beacon + guards to the v5 data-slot-only capacity).
This is the gating analysis before any per-link firmware. It answers ONE question with numbers: does the per-link capacity gain survive the real frame-level costs, and WHERE (member count N / SNR spread)?
sim-result-v5 (adaptive-sf-selection-result.md) measured the idealized data-slot-only
gap: per-link beats per-cell ×1.70 (N=10) → ×2.00 (N=200). But it counted only member data
slots — no beacon. Its own "Open follow-ups" flagged the missing piece: "charge the per-link
beacon/packing overhead against its capacity (close the gap to its net value)." That is exactly
this sim.
The catch (adaptive-modulation-design.md §6 Phase-2): per-link runs each member's DATA
slot at its own fast (close) SF, but the BEACON must TX at the SLOWEST member's SF so every
member can decode the schedule. A slow beacon (SF11 ≈ 1.2 s, SF12 ≈ 2.2 s at BW125) is a FIXED
per-frame overhead. At small N it can be a bigger fraction of the frame than all the data slots.
Per-cell pays no such penalty — its beacon runs at the cell SF everyone already decodes.
Everything below is a real run. The frame is
frame_t = beacon@beacon_sf + Σ member_slots@(their SF) + N·guard, with:
- beacon airtime from the repo's unit-tested ToA law (phy.time_on_air_ms);
- beacon size from the spec — 80 B fixed (header+SRC+EPOCH+CELL_DESC+FRAME_T+SLOTMAP_LEN+
NEXT_PTR+SIG) + 3 B/SLOTMAP entry, and +1 B/grant for per-link's modulation byte
(packet-format.md §8.1, design-note §4.2 option A);
- guard G = 3 ms/slot (mac-spec.md §2.1, as-built for ±20 ppm / FRAME_T);
- per-member SNR from the real path-loss model (phy.PathLossModel, n=2.7, 17 dBm), members
uniform-over-area in a 6 km cell so SNR spans the SF ladder (the regime the rules diverge).
Aggregate delivered NET capacity (pkt/s, 162 B frames), median path (no fading), averaged over 12 cell-geometry draws. "ideal gain" is the v5 data-slot-only gap, for the erosion comparison.
| N | static-SF9 net | per-cell net (SF) | per-link net (beacon SF) | NET gain | beacon-frac cell / link | ideal gain (v5) |
|---|---|---|---|---|---|---|
| 5 | 0.973 | 0.632 (SF9.8) | 1.023 (SF9.8) | ×1.62 | 11.7% / 20.0% | ×1.80 |
| 10 | 1.043 | 0.667 (SF9.8) | 1.127 (SF9.8) | ×1.69 | 6.9% / 12.6% | ×1.81 |
| 25 | 1.092 | 0.607 (SF10) | 1.185 (SF10) | ×1.95 | 3.7% / 8.3% | ×2.06 |
| 50 | 1.107 | 0.614 (SF10) | 1.186 (SF10) | ×1.93 | 2.6% / 6.1% | ×2.01 |
| 100 | 1.115 | 0.617 (SF10) | 1.223 (SF10) | ×1.98 | 2.1% / 5.1% | ×2.05 |
| 200 | 1.119 | 0.619 (SF10) | 1.229 (SF10) | ×1.98 | 1.8% / 4.6% | ×2.05 |
The per-link gain SURVIVES the frame-level costs. Net gain is ×1.62 (N=5) → ×1.98 (N=200) — within a few % of the idealized ×1.7–2.0. The slow beacon erodes the prize only at small N (net ×1.62 vs ideal ×1.80 at N=5, a ~10% haircut) and the erosion vanishes by N≥25 (×1.95 net vs ×2.06 ideal). The reason: the beacon is one frame shared across all members, while the data slots scale with N — so as N grows the beacon amortizes and the net gain converges to the idealized gap.
(static-SF9 net pps looks competitive in absolute terms only because it drops the ~27% of stragglers below SF9's floor and runs the survivors fast — the v5 reach/capacity trade is unchanged: per-cell and per-link reach 100% of members, static-SF9 ~73%.)
Fine N-scan of the net per-link gain (12 seeds):
| N | per-cell net | per-link net | NET gain | verdict |
|---|---|---|---|---|
| 2 | 0.769 | 0.950 | ×1.24 | marginal |
| 3 | 0.831 | 1.147 | ×1.38 | marginal |
| 4 | 0.759 | 1.096 | ×1.44 | marginal |
| 5 | 0.632 | 1.023 | ×1.62 | per-link wins |
| 10 | 0.667 | 1.127 | ×1.69 | per-link wins |
| 25 | 0.607 | 1.185 | ×1.95 | per-link wins |
| 50 | 0.614 | 1.186 | ×1.93 | per-link wins |
| 100 | 0.617 | 1.223 | ×1.98 | per-link wins |
The mechanism is the beacon fraction in §1: at N=5 the per-link beacon is 20% of the frame (vs per-cell's 11.7%); by N=50 it is 6.1% (vs 2.6%); by N=200 4.6% (vs 1.8%). The per-link beacon is always ~2–3× the per-cell fraction (slower SF + the +1 B/grant), but it shrinks as 1/N, so the absolute erosion is only material in the small-N corner.
Vary the cell radius (hence the SNR/SF spread), N=50, 12 seeds. Tight spread → members share an SF → per-link ≈ per-cell. Wide spread → per-link wins more on data, but the slowest-SF beacon also gets slower — so net gain is the resultant of both.
| cell radius | SF spread (min..max) | per-cell SF | per-cell net | per-link net | NET gain | ideal gain |
|---|---|---|---|---|---|---|
| 1.5 km | SF7..SF7 | SF7 | 3.571 | 3.553 | ×0.99 | ×1.00 |
| 3.0 km | SF7..SF7 | SF7 | 3.571 | 3.553 | ×0.99 | ×1.00 |
| 4.5 km | SF7..SF9 | SF9 | 1.111 | 2.334 | ×2.10 | ×2.20 |
| 6.0 km | SF7..SF10 | SF10 | 0.614 | 1.186 | ×1.93 | ×2.01 |
| 7.5 km | SF7..SF11 | SF11 | 0.285 | 0.601 | ×2.11 | ×2.20 |
| 9.0 km | SF7..SF12 | SF12 | 0.154 | 0.341 | ×2.22 | ×2.32 |
The gain is entirely a spread phenomenon. With NO spread (all members at SF7, radius ≤ 3 km) per-link is ×0.99 — a slight net LOSS: the data slots are identical, so the only difference is per-link's +1 B/grant larger beacon. The instant the cell spans even SF7..SF9 (radius ≥ 4.5 km), per-link jumps to ×2.1, and it stays ×1.9–2.2 across all wider spreads. The slow-beacon penalty does NOT cancel the spread win even at the SF12 extreme (×2.22 net vs ×2.32 ideal — only a ~4% beacon haircut) because the slow members whose presence forces the slow beacon are exactly the ones whose long data slots per-link is saving everyone else from.
Takeaway: per-link pays off iff the cell spans ≥ 2 SF steps; in a single-SF cell it is a small net loss and must not be enabled.
The recommendation §5 lever: cap the beacon SF and DON'T schedule members slower than the cap (they fall to the contention/fallback plane instead of dragging the beacon slow). Trades reach for a faster beacon + frame. Per-link, N=50, 12 seeds:
| beacon cap | reach | beacon SF | per-link net | vs uncapped |
|---|---|---|---|---|
| none | 50.0 | SF10 | 1.186 | ×1.00 |
| SF11 | 50.0 | SF10 | 1.186 | ×1.00 |
| SF10 | 50.0 | SF10 | 1.186 | ×1.00 |
| SF9 | 37.4 | SF9 | 1.772 | ×1.49 |
| SF8 | 24.2 | SF8 | 2.789 | ×2.35 |
At N=10 and N=200 the same shape holds (capping at SF9: ×1.36 and ×1.49 net throughput respectively, dropping ~25% of members). Capping at SF10 or SF11 is a no-op here because the worst member already lands at SF10 — the cap only bites once it is below the natural slowest SF.
The cap is a real, cheap throughput lever — but it is a policy trade (it deliberately sheds the bottom ~25% of members to the slower fallback plane to win ~50% throughput for the core). It belongs to the operator's reach-vs-capacity knob, not the default. Note the beacon fraction barely moves under the cap (6.1% → 5.6% at N=50): the cap's win is the faster DATA slots and a faster frame, not primarily a cheaper beacon. The slow beacon was never the dominant cost at N≥25 — the cap mostly buys speed by abandoning stragglers, which per-cell-auto and the fallback plane already let you do without per-link's machinery.
Build per-link — it clears the gate — but scope it by N and spread.
The per-link net gain survives the frame-level costs. Charging the slow beacon + guards + the +1 B/grant byte against per-link leaves ×1.6–2.0 net capacity over per-cell, essentially the idealized v5 gap. The slow beacon does not erode the prize except marginally at N<5. This is the number #66 Phase-2 needed and v5 deferred.
Gate per-link on TWO conditions, both cheap to evaluate at the coordinator:
LINK_REPORTS)
— in a single-SF cell per-link is a ×0.99 net loss (the larger beacon for no data win).
Only flip per-link on once max(member_sf) − min(member_sf) ≥ 2.These are a 2-line guard on the existing per-cell path; per-link is the spread+scale specialization, per-cell-auto remains the safe default and the small-cell / tight-spread choice.
The beacon-SF cap is a worthwhile OPERATOR knob, not a default. Capping at SF9 buys ~×1.5 throughput by shedding ~25% of stragglers to the fallback plane. It is a reach-vs-capacity policy lever; expose it, default it OFF (per-link already serves 100% with the slow beacon).
Sequencing unchanged from v5: Phase 1 (per-cell-auto) ships first — thrash-free, zero-wire-cost, the reach win. Phase 2 (per-link) is justified and now net-quantified: the ×1.6–2.0 prize is real at N≥5 / spread≥2, and v5's churn result still applies (per-link costs ~100× the SF-flip churn, hysteresis 2.5–3 dB).
Measured (real runs):
- Every capacity number is the repo's unit-tested ToA law over the real path-loss model. The
beacon airtime, the beacon size (spec field-level), the guard, the slowest-SF-beacon pinning,
the per-link variable-slot sum, the crossover, the spread sweep, and the cap trade are all
computed and seed-averaged, not asserted.
- The NET gain, the beacon fraction, the crossover N, and the spread sensitivity are direct run
outputs (experiments/per_link_sf.py).
Idealized (honest boundaries — same spirit as v5 §5):
- No intra-frame SF-retune guard modeled. A real radio must re-tune its modem between
heterogeneous per-link slots (a per-slot settle/PLL-lock the per-cell single-SF frame never
pays). We fold this into the single ~3 ms G guard rather than charging a separate per-slot
retune. If field retune is materially > 3 ms it would shave the small-N net gain further (the
guard is N·G, so it scales with members) — the safe direction is to re-measure G once per-link
firmware exists. The N≥25 results are insensitive to it (guards are <2% of the frame there).
- No intra-cell PHY collisions. TDMA gives each member a slot; v4 validated the collision-free
plane on the full SimPy model. This isolates the SF-selection + frame-geometry question.
- Median path, no fading for the structural net gap (so it is geometry, not a fading artifact);
v5's churn loop already characterized fading/thrash and is unchanged.
- Datasheet SNR floors, uncalibrated (SF7 −7.5 … SF12 −20 dB, design-note §3). The gap
depends on SF-ladder spacing (~2.5 dB/step), not the zero point; absolute reach moves with the
GL5712-UX calibration.
- One frame per member per superframe (no multi-slot members); the contention/fallback plane
the §4 cap sheds stragglers to is modeled only as "not scheduled here," not simulated for its
own delivery — its capacity is out of scope for this cell-level question.
Open follow-ups: charge a measured per-slot retune guard once per-link firmware can report it; BW as a second selection axis (BW250 ~2× rate for ~3 dB); correlated fading + a real mobility trace under the frame-level model; the fallback-plane delivery for stragglers the SF-cap sheds.