Repository navigation
Description
Part of the 5G-NR RAN EPIC #254. Phase B, the payoff capability.
Goal: a moving UE's schedule migrates cell-to-cell with no scan / no re-association / no clock re-acquisition — make-before-break, riding the network-wide synced clock.
5G mapping
CHO (Conditional Handover — pre-staged A3-style condition) and DAPS (Dual Active Protocol Stack — dual-cell active slots + uplink duplication during the crossing).
Deliverables (tools/ran-controller/ + src/cell/)
- measurement aggregation — each DU tags UE-uplink
RxAtribRSSI/SNR/EVM (from M0's per-UE attribution, filtered — never raw RSSI) and reports to the CU over F1. - CHO — a pre-staged A3-style condition (offset margin + time-to-trigger hysteresis) evaluated at the DU/CU.
- make-before-break slot migration — the UE's grant appears in cell B's beacon and disappears from A; shared clock ⇒ no re-sync;
FastRetunefor the channel; security context pre-staged fromap_wpa2.cpp(802.11r-style). - DAPS soft handover — same-channel first (from the review). With one radio a UE cannot listen to A while tuned to B, so different-channel DAPS is sequential duplication (deaf during retune), not simultaneous dual connectivity. First demo = same-channel dual-cell duplication + sequence dedup over Xn/F1. The different-channel variant must budget retune time+variance, RF settling, missed beacons/ACKs, and a deadline to return to the serving channel.
- shared sequence handling + duplicate suppression for HARQ and DAPS, and state cleanup during handover.
Reuses
M5 CU + F1/Xn, M4 synced TSF (the enabler — TSF continuity across cells), FastRetune, ap_wpa2.cpp security context, M0 per-UE attribution.
Risks (from EPIC)
- A3/CHO tuning — raw RSSI ping-pongs at the edge; hysteresis + time-to-trigger is the classic pitfall (drive off filtered quality).
- Single-radio deaf-time — different-channel DAPS is sequential; ~ms
FastRetunebounds it.
Dependencies
M5 (#254 tracks).
Test — tests/ran_handover.sh
Move a UE A→B: the grant migrates after the time-to-trigger window with TSF continuity, no re-association, vs the ~100 ms roam "ghost bar." Kill one path mid-crossing → the duplicated UL still arrives via B (DAPS).
Status (2026-11-05)
- Done (cell M6 core: L3 filter, A3 (CHO) trigger, DAPS dedup, F1 v2 measurements #527, bed7fb0):
src/cell/Handover.h—L3Filter(3GPP layer-3 filter, stale = absent),A3Trigger(offset, hysteresis, per-neighbour time-to-trigger, dwell),MeasTable,SeqDedup(DAPS dedup, 2^32 wrap).- F1 v2: each DU reports its own UEs' and the heard UEs' filtered RSSI and SNR. On the rig the CU logs them (
ran.meas). - EVM is not reported yet.
- Selftest: a noisy edge crossing (6 dB shadowing, 100 draws). Raw-RSSI triggering averages 30.6 handovers; the filtered A3 makes exactly one in 100/100, always after the midpoint.
- Next:
- evaluating the A3 at the CU;
- make-before-break slot migration over F1;
- same-channel DAPS with dedup;
- the rig handover test (the crossing emulated with per-packet TX attenuation).
Status (2026-11-05, later)
- CHO: done.
- ran-controller: observe-only A3 (CHO) handover evaluation #528: the CU evaluates the A3 per UE from filtered reports.
- cell M6: handover command + beacon IE + TSF continuity (F1 v4) #529: the command, the tagged beacon IE, the TSF delta.
- cell M6: handover execution — two-phase command, TSF continuity, conditional fallback #530: execution — two-phase commit (target, then source), cancels, a UE switch with TSF continuity (
TsfSync::ShiftRemote), and a conditional fallback to the source. - Rig (testrig
m6_handover.sh, 8 runs, all pass): interruption 1/1/1 superframe, CU done latency 5/6/16, target-crash fallback 10/10/10.
- DAPS, same channel: done (cell M6: DAPS — same-channel uplink duplication + CU dedup #531). Uplinks are deduplicated at the CU on a network-time sequence. With the source DU killed mid-crossing (testrig
m6_daps.sh, 3 runs each), the crossing UE's stream misses 0/0/0 uplinks with DAPS, against 14/15/18 without. - Open:
- Slot migration: done on the same channel. Still missing:
FastRetunefor another channel, and pre-staging a per-cell security context (the rig cells share one key). - Sequence handling: DAPS dedup and handover state cleanup are done. HARQ sequence continuity across the switch is not: the target's DL HARQ starts fresh.
- Test realism: the rig's UEs are stationary, so the crossing is forced with a negative A3 offset.
- Slot migration: done on the same channel. Still missing:
Activity
cell M6 core: L3 filter, A3 (CHO) trigger, DAPS dedup, F1 v2 measurem…
ran-controller: observe-only A3 (CHO) handover evaluation (#528)
cell M6: handover execution — two-phase command, TSF continuity, cond…
cell M6: DAPS — same-channel uplink duplication + CU dedup (#531)
cell M6: inter-frequency handover (UE retunes at the switch) (#532)
cell M6: UE measurement gaps — the A3 on the UE's own measurements (i…
cell M6: CU phase planning — a joining DU placed so the cells can mea…
josephnef commentedon Nov 5, 2026
CU phase planning merged (#534, 75715f0).
The CU now places a joining DU's TBTT (
src/cell/PhasePlan.hplan_phase) so the cells' UEs can measure each other in gaps (#533):CELL_PHASE_AUTO=1asks before starting its beacon and gets a tagged F1Phase(v8).Rig (testrig
m6_phaseplan.sh): 6/6 pass.m6_measgap.shstill passes at the new bound.Found: a UE that locked to the auto-phase DU while it was still waiting for its phase (beacon already on) took a ~1.3 ms jump in that beacon's timing as skew. Its
TsfSyncfit is cumulative, and its gaps then missed. The DU now asks beforeStartBeacon, and the rig starts the UEs only once the DUs serve.Open:
cell M6: the UE's clock fit survives a jump of its DU's TSF (TsfSync …
josephnef commentedon Nov 6, 2026
UE clock-fit step detection merged (#535, 476641f).
The UE's
TsfSyncfit is cumulative, so one jump of the DU's TSF used to read as skew (seen in the #534 runs). Now:Six beacons outlast the DU's own 500 ms discipline pass, so a jump the DU undoes itself is never followed.
Rig (testrig
m6_fitstep.sh, 6/6 pass):Open:
cell M6: UE re-attachment after a DU restart (#536)
josephnef commentedon Nov 6, 2026
UE re-attachment after a DU restart merged (#536, 27e5928).
The UE's clock already followed a restarted DU (#535), but the DU's grant maps restart at a low epoch on a new superframe count. The UE refused them, and the restarted DU never heard it again. Now:
GrantMapReceiver::offer_after_restart);Rig (testrig
m6_fitstep.sh, DU1 killed and restarted under its UE, 3/3):No spurious re-attach in the regression runs (handover, fallback, measurement gaps, phase planning, a transient TSF jump).
Also fixed: the UE's DL in-slot log judged the slot matching its id, not its grant's. A CU move showed DL out of slot although the frames were right.
Open:
cell M6: handover command + beacon IE + TSF continuity (F1 v4) (OpenI…
cell M6: grant-map authentication (GM IE v2, the cell key's tag) (#537)
16 remaining items
josephnef commentedon Nov 8, 2026
DAPS downlink duplication — merged (#545).
The downlink now has a CU-anchored stream:
Downlink(v12). Each stream has an epoch, so replays are refused and a re-created stream starts cleanly.DlStreamRx).ran.ho_done, plus the stream's last 16 PDUs at the command (data forwarding), every PDU also goes to the target. The target buffers them and airs them after the switch.testrig
m6_daps_dl.sh, 16 runs, all pass. UE1's DL PDUs missing in the handover windows:Results: testrig
results/2026-11-08-m6-daps-dl.txt. Docs: the downlink duplication section indocs/cell-m6.md.cell M6: suspend gaps — phase planning beyond two cells (#546)
josephnef commentedon Nov 8, 2026
Phase planning beyond two cells: suspend gaps — merged (#546).
Why idle gaps cannot do it. A gap is 26 + 2 ms each side of the beacon (56.5 of 102.4 ms) and must clear the 20.5 ms active span. So an idle window exists both ways only for a phase difference in [48.5, 53.9] ms. Three cells cannot be pairwise half a superframe apart; no phase layout, and no live re-phasing, fixes that.
Suspend gaps (
CELL_GAP_SUSPEND_SF, the same value on the DU and its UEs; the 3GPP way):RAN_GAP_SUSPEND=1) counts a suspend-only direction at 0.25 per position. It still prefers idle windows and covers every pair of three cells (ctest).testrig
m6_suspend.sh, 2 runs per case, all pass. Two cells 20 ms apart on ch149/ch157, so no idle window either way:Still open: three cells on air (the rig has two DUs with good TX), and live re-phasing, which is no longer needed for measurability.
Results: testrig
results/2026-11-08-m6-suspend.txt. Docs: "Beyond two cells: suspend gaps" indocs/cell-m6.md.cell M6: three cells on the rig — suspend-gap scheduling fixes (#547)
josephnef commentedon Nov 9, 2026
Three cells on the rig — merged (#547).
Layout:
testrig
m6_three_cells.sh(m5_ran_coord.sh --du3/--ue3), 3 runs each way, all pass:What the first three-cell runs found: the UE loop's own timing was losing gaps.
Fixes:
cell.ue.suspend_missed): 0 in the final runs.Still open: handover among three cells, more than three cells, and live re-phasing.
Results: testrig
results/2026-11-09-m6-three-cells.txt.cell M6: handover among three cells — A3 strongest-only, no re-plan m…
josephnef commentedon Nov 9, 2026
Handover among three cells — merged (#555).
Three-cell layout, with the CU's A3 executing on the UEs' own measurements. Each UE is strongest to the DU on its own box. testrig
m6_three_ho.sh, 2 runs per case, all pass:ran.ho_done, no fallback, and no DL in a deaf window on either side.Bugs the first runs found:
Regression: the two-cell crossing, measurement-gap, plain and fault handovers, joiner keys and a CU outage run all pass.
Results: testrig
results/2026-11-09-m6-three-ho.txt.cell M6: a UE crossing three cells — suspend-aware measurement stalen…
josephnef commentedon Nov 10, 2026
A UE crossing three cells — merged (#557).
The emulated crossing (DU TX ramps, the A3 at its defaults, nothing forced) on the three-cell layout, with one UE: the j6412-3 radio, the only one that hears all three DUs within the DUs' 20 dB TX range.
testrig
m6_crossing3.sh, 3 crossings + 2 with a source failure, all pass:Found and fixed:
Results: testrig
results/2026-11-10-m6-crossing3.txt.cell M6: DAPS across channels — sequential uplink visits to the targe…
josephnef commentedon Nov 10, 2026
DAPS across channels: uplink (sequential visits) — merged (#558).
The problem: uplink DAPS needs the target to hear the UE's source identity, and across channels it can't: the UE has one radio.
The visits: between accepting an inter-frequency handover and its switch, the UE visits the target's channel once a superframe.
send_packetonly queues), and retunes back.The CU side (F1 v14
Uplink.net_us,Handover.hul_seq_for_copy): the CU maps every forwarded uplink to the UE's last own uplink slot, so a visit dedups with the source's copy, even next to a superframe boundary.testrig
m6_daps_xch.sh, 2 runs per case, all pass:Next: the DL side across channels, with the target answering each visit with its DL PDUs.
Results: testrig
results/2026-11-10-m6-daps-xch.txt.cell M6: DAPS across channels, downlink — the target answers each vis…
josephnef commentedon Nov 10, 2026
DAPS across channels, downlink — merged (#559).
The answer: when the handover target hears a UE's visit copy (sequential DAPS, #558), it answers at once with the queued DL PDUs no answer has offered yet.
The bookkeeping:
testrig
m6_daps_xch.sh, 4 runs per case (min / median / max), all pass:The handover is the A3 at its defaults with static UEs, not the emulated crossing.
Next: the switch's own interruption (~2 superframes). It traces to the UE loop's pass timing after the switch and to Kestrel's beacon timer firing the first beacon after a TSF step at the old phase. The fix is on the rig now.
cell M6: the switch's own interruption (inter-frequency DAPS) (#560)
josephnef commentedon Nov 11, 2026
The switch's own interruption (inter-frequency DAPS) — #560, merged.
After #559 the DL gap across the switch was ~225 ms (one run 327), and the first uplink after the switch fired at the source's phase. Changes:
pick_dl_pdusleads with not-yet-offered PDUs. Offered ones go first only when the fresh PDU would push them out of the UE's reorder window (answers can be lost).m6_three_hoshowed stale DU evidence raising a second candidate for the same move.Rig (
m6_daps_xch, cells 149→157; 24 visit runs across the branch's builds): 0 UL lost before the switch in every visit run (10–16 lost without visits). DL gap across the switch 122–123 ms in 18 of 24 (1.5–2.0 s without visits). The other 6 were 225 ms; in the 2 runs with the newcell.dllog, the cause is direct: no fresh PDU was queued yet when the target built its first frame (one CU PDU per 100 ms), so cadence, not queue order. One more showed the same from the UE side (first frame: only already-received PDUs); the other 3 were not instrumented — cause not established for them. Regression passes: measgap, three_ho, crossing3, handover, daps, daps_dl, crossing. Details:docs/cell-m6.md, The switch's own interruption.