← RFC

Dual-radio concurrent-cell hub — design (#69 capacity, orthogonal layer)

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.

Why (the capacity math)

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).

Hardware (already in hand)

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).

What's missing — make cell B a FULL cell

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:

  1. 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).
  2. 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().
  3. 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).

Risks / care points

Bench test (achievable — different channels sidestep co-location)

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.

Implementation order

  1. lr11xx method parity + impl AnyRadio dispatch (no cell() change yet — build + a backbone regression).
  2. cell()&mut AnyRadio (mechanical call-site swap); re-verify per-cell + per-link on the bench.
  3. cell2 → two full cells; port the #68 LR-TX-reinit fix into the LR cell path.
  4. Bench: client-per-channel both served; concentrator confirms cell B on 903.3.
  5. Generalize R>2 (EByte multi-radio HAT) as a follow-on.

Scope note

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.