Status: design (2026-06-23). The per-cell membership fix (PACT-lite, #69) scales members within one
cell; this note is the orthogonal axis #69 flags — one INFRA node running N full cells on N
radios/channels to multiply per-cell capacity. Sim-justified (capacity_scaling.py); implementation
gated on a cell() radio-abstraction refactor (scoped below) deliberately deferred from the per-link
session so it doesn't rush the freshly-merged timing-critical path.
PACT-lite makes idle membership ~free, so one cell serves K members at P·TELEM_W telemetry airtime for any
K — but the airtime ceiling is per-channel (~7.2 pkt/s MediumFast, TDMA ~98%). The only way past a
single channel's ceiling is more channels. A hub with R radios on R non-overlapping channels runs R
independent cells:
members_served ≈ R · K_per_cell aggregate_capacity ≈ R · per_cell_capacity
capacity_scaling.py (2026-06-23) confirms cells are independent under reuse: 5 MediumFast cells = ×16
per-node + aggregate vs one LongFast domain; the 500-node ceiling improves ×34 per-node; and aggregate
holds flat (32.2 pkt/s) even at close separation because capture protects the far-stronger intra-cell
links — the cost of insufficient separation is wasted decode attempts (contention), not lost throughput,
with clean reuse by ~1.5× the SF range. The dual-radio node is the co-located R=2 special case on two
channels: 2× members + 2× aggregate from one box, no separation needed (different channels don't contend).
This is orthogonal to membership: PACT-lite fixes idle-member airtime (the wrong-primitive bug); multi-radio multiplies cells (the active-throughput / total-density axis). Both are needed for a real Stone-Mountain-class hub (K≈29 heard + ~200 relayed).
The base INFRA HAT carries two radios — Nebra Duo (SX1262 spidev0.0 + LR1121 spidev0.1) on the bench,
and the production EByte HAT options ([[dual-radio-pi-hat-hardware]]). infrad already opens both
(backbone()/otaloop use AnyRadio = Sx|Lr; cell2/cell_b_beacons already beacon on the LR1121 at
903.3 SF7/BW250). The gap is that cell B is beacon-only — it advertises but doesn't serve (no roster,
no telemetry RX, no grants).
cell() is SX1262-specific: it threads &sx_p (LoraParams) through ~22 radio call-sites (transmit ×6,
set_modulation ×3, set_frequency ×3, init ×2, receive, standby, start_rx_continuous,
rssi_inst, + SX-only diagnostics irq_status_pub/device_errors/chip_status). To run cell B fully it
must become radio-agnostic:
lr11xx method parity — add the methods cell() needs that the LR1121 driver lacks (set_modulation
— added to sx126x for per-link; rssi_inst; the SX-only diagnostics get LR stubs/Option). set_frequency
already exists (added for DFS).impl AnyRadio dispatch methods (receive(timeout), transmit(frame,timeout), set_sf(sf) →
internal set_modulation, set_frequency, standby, rssi) over the inner (radio, params) — params
move inside the enum, so cell() drops the external &sx_p and mutates via radio.set_sf().cell() takes &mut AnyRadio + an operating channel; cell2 spawns cell(Sx, chanA) and
cell(Lr, chanB) as two threads. Identity seed is already shared (/var/lib/loomwave/identity.seed,
#67); epoch + roster are per-cell (separate PactTable, separate (epoch,seq) domain per channel —
a client only ever sees one channel, so no cross-channel nonce collision; use a per-channel epoch file).radio_sf + set_modulation path just merged + HW-verified — must
re-run the per-link bench (coord@SF9 + mixed-SF members) after, plus the per-cell regression, before merge.begin() re-init — cell B's per-frame TDMA TX hits this every frame; the firmware fix pattern (re-init
after TX) must port to the infra LR path (do_tx-equivalent in cell()), or cell B goes deaf after its
first beacon. This is the load-bearing risk — verify cell B keeps RXing across many frames.The co-location problem that blocks the backbone stagger does NOT apply here: the two cells are on
different channels, so a client on channel A only hears cell A. One bench node (Nebra Duo) runs cell2:
cell A @915 (SX1262) + cell B @903.3 SF7/BW250 (LR1121). A client flashed to channel A joins + is served by
cell A; a client flashed to channel B joins + is served by cell B — both served by one INFRA node,
simultaneously, proving the ×2. Cross-check on-air with the duomesh SX1301 concentrator retuned to 903.3.
lr11xx method parity + impl AnyRadio dispatch (no cell() change yet — build + a backbone regression).cell() → &mut AnyRadio (mechanical call-site swap); re-verify per-cell + per-link on the bench.cell2 → two full cells; port the #68 LR-TX-reinit fix into the LR cell path.Tracked as the multi-radio capacity layer of #69; large enough that the implementation warrants its own issue/branch (the per-cell membership half of #69 is already done + merged). This note is the contract.