Status: Stage 2 design experiment, quantifying issue #65 (coordinator DFS). Date: 2026-06-22.
Reproduce: cd sim && PYTHONPATH=. python3 -m experiments.dfs_channel_selection
(writes docs/figures/dfs_channel_selection.png).
Occupancy data: sim/data/ng_channel_occupancy.json, derived from the live North-Georgia
Meshtastic firehose /exchange/mtnme-mqtt/packets_log.csv (see Occupancy source, below).

Loomwave coordinators currently use a fixed operating channel. Issue #65 reframes this as
coordinator DFS: scan the candidate band, pick the least-occupied channel, announce it in the
beacon (frequency-agility-multisector-note.md §3). We hit the cost of not doing this live —
two Loomwave coordinators both on 911.5 collided (co-channel contention). This experiment
quantifies, on the existing PHY/collision engine, the DFS benefit in two cases:
The per-channel busy fractions are anchored to real data, the North-Georgia Meshtastic firehose
at /exchange/mtnme-mqtt/packets_log.csv (753,979 rows, 9.65-day window 2026-06-12 → 06-22,
712k GA-region packets). Processing (sim/data/ng_channel_occupancy.json):
channel field is a Meshtastic modem preset (MediumFast / LongFast / ShortFast …),
which sets both the SF/BW and the default US915 frequency slot
(channel_num = xorHash(name) % numChannels, freq = 902 + bw/2 + ch·bw). Computed slots:
MediumFast → 909.375 MHz, LongFast → 904.625 MHz.phy.time_on_air_ms),
using the real payload_len. Packets de-duplicated by (mesh_packet_id, channel) so each
over-air transmission is counted once (the firehose is a multi-gateway aggregate).Measured per-channel occupancy (GA, busy-hour p90 of the deduped floor):
| Meshtastic channel | SF/BW | default freq | busy-frac median | mean | p90 | max |
|---|---|---|---|---|---|---|
| MediumFast (loud slot) | SF9/250 | 909.375 | 1.14% | 1.24% | 1.67% | 2.12% |
| LongFast (quiet slot) | SF11/250 | 904.625 | 0.34% | 0.36% | 0.49% | 0.74% |
| ShortFast | SF7/250 | 904.625 | ~0% | ~0% | ~0% | ~0% |
Key real ratio used: the MediumFast slot runs ≈3.4× the airtime of the LongFast slot — i.e.
the spectrum is genuinely not uniform, the premise the band survey asserted
(frequency-agility-multisector-note.md §2: 915.0 OCCUPIED, 911.5/902.5/921 CLEAR).
Caveat (and how we handle it): the firehose is gateway-deduped, so it is a floor of the
true on-channel occupancy a node senses — a single node hears more local transmissions than the
gateway-merged trace, and the NG validation gate measured real per-node channel_utilization at
median 5.7%, p90 14.8%, max 37.3% vs this deduped ~1.2%. We therefore do not treat the
deduped floor as the absolute busy level. We take the relative structure (loud vs quiet, the
3.4× ratio) as real, and sweep an interference scale that lifts the loud channel from the floor
(1.7%) up to ×20 (33%), which brackets the gate's measured busy-hour reality (≈37% max). All
results are reported across that range.
So: real data sets the channel structure and ratio; the absolute interference level is swept across the real-data-bracketed range rather than asserted from the deduped floor.
On the existing PHY/collision engine (phy.py / network.py), unchanged:
InfraTdmaMac) + K clients
(ClientTdmaMac, one slot each), MediumFast (SF9/BW250), 32 B uplinks, 30 s per-client cadence,
K ∈ {10, 20}. Clients are mutually hidden (cell geometry); the hub hears all.AmbientInterferer), sized so its airtime/run = the channel's busy fraction. It is audible to
the cell, so it genuinely collides with member uplinks via the same capture-aware rule (6 dB).| interf level | loud busy% | K | fixed ch | fixed deliv | DFS ch | DFS busy% | DFS deliv | deliv gain |
|---|---|---|---|---|---|---|---|---|
| floor | 1.7 | 10 | 915.0 | 0.966 | 921.0 | 0.24 | 0.997 | +0.030 |
| floor | 1.7 | 20 | 915.0 | 0.966 | 921.0 | 0.24 | 0.995 | +0.029 |
| ×5 | 8.3 | 10 | 915.0 | 0.882 | 921.0 | 0.24 | 0.997 | +0.115 |
| ×5 | 8.3 | 20 | 915.0 | 0.874 | 921.0 | 0.24 | 0.995 | +0.121 |
| ×10 | 16.7 | 10 | 915.0 | 0.750 | 921.0 | 0.24 | 0.997 | +0.247 |
| ×10 | 16.7 | 20 | 915.0 | 0.767 | 921.0 | 0.24 | 0.995 | +0.228 |
| ×20 | 33.4 | 10 | 915.0 | 0.542 | 921.0 | 0.24 | 0.997 | +0.455 |
| ×20 | 33.4 | 20 | 915.0 | 0.594 | 921.0 | 0.24 | 0.995 | +0.401 |
Reading it:
frequency-agility-multisector-note.md §3 open question).Two coordinators within RF range. Fixed: both configured to the same channel → their TDMA grids are referenced to different beacons, uncoordinated, so cell B's uplinks land in cell A's slots and the beacons collide (one shared medium). DFS: the two cells spread to different clear channels → frequency separation means they share no medium (modelled as each cell alone on its own quiet channel — the physically-correct consequence of different frequencies).
| interf | K | fixed deliv | fixed p50 (ms) | fixed p90 (ms) | DFS deliv | DFS p50 (ms) | DFS p90 (ms) | deliv gain |
|---|---|---|---|---|---|---|---|---|
| floor | 10 | 0.437 | 1580 | 2492 | 0.990 | 1597 | 2417 | +0.553 |
| floor | 20 | 0.464 | 3163 | 4970 | 0.988 | 3143 | 4853 | +0.524 |
| ×10 | 10 | 0.357 | 1646 | 2653 | 0.990 | 1597 | 2417 | +0.634 |
| ×10 | 20 | 0.367 | 3318 | 5680 | 0.988 | 3143 | 4853 | +0.621 |
Reading it:
channel_utilization (15–37%), because the
firehose is gateway-deduped (a floor).frequency-agility-multisector-note.md §3 and bound the upper end of the benefit.DFS's value is conditional and the real data tells us the condition is met: the North-Georgia spectrum is non-uniform (3.4× loud/quiet ratio), so a fixed coordinator that lands on the loud slot loses 23–45 points of delivery that DFS recovers — and two coordinators sharing a channel (the live 911.5 failure) lose >half their traffic, which DFS frequency-separation restores to ≈0.99 (+0.52–0.63 delivery). This supports the note's recommendation: the single-cell win is marginal on an already-clear default, so the strong case for DFS is multi-coordinator co-channel avoidance and contested-band sites — exactly the cases the live failure and band survey identified.
Files: experiment sim/experiments/dfs_channel_selection.py; occupancy data
sim/data/ng_channel_occupancy.json; figure docs/figures/dfs_channel_selection.png.