Skip to content

M6 — Mobility: CHO + DAPS handover #260

Open

Description

@josephnef

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 RxAtrib RSSI/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; FastRetune for the channel; security context pre-staged from ap_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 FastRetune bounds 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)

Activity

  1. josephnef commented on Nov 5, 2026

    @josephnef
    CollaboratorAuthor

    CU phase planning merged (#534, 75715f0).

    The CU now places a joining DU's TBTT (src/cell/PhasePlan.h plan_phase) so the cells' UEs can measure each other in gaps (#533):

    • Another channel: both ways, every capacity position.
    • Same channel: disjoint active spans.
    • Running cells keep their phases.
    • The exchange: a DU with CELL_PHASE_AUTO=1 asks before starting its beacon and gets a tagged F1 Phase (v8).
    • UE gap bound: now 26 + 2 ms, the measured 23.5–23.7 ms retune plus ~10 %. At 30 + 3 ms no phase works both ways on the rig grid.

    Rig (testrig m6_phaseplan.sh): 6/6 pass.

    • DU2 (ch157) placed at 51200 µs, score 2/2, inside the [48500, 53900] window.
    • Both UEs heard the other cell in nearly every gap (≈57 per UE per 60 s run), with no gap skipped and no uplink lost.
    • Gap deaf time: 54.1 ms.
    • m6_measgap.sh still 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 TsfSync fit is cumulative, and its gaps then missed. The DU now asks before StartBeacon, and the rig starts the UEs only once the DUs serve.

    Open:

    • the jump's source is not established;
    • a UE-side fit reset on a discontinuity;
    • phase planning for 3+ cells on different channels (no layout works for every pair at 26 ms).
  2. josephnef commented on Nov 6, 2026

    @josephnef
    CollaboratorAuthor

    UE clock-fit step detection merged (#535, 476641f).

    The UE's TsfSync fit is cumulative, so one jump of the DU's TSF used to read as skew (seen in the #534 runs). Now:

    • a beacon more than 200 µs off the fit is held out of it;
    • 6 in a row that agree are a step, and the remote axis is re-anchored by it;
    • held beacons that never agree are dropped.

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

    • DU restart under an attached UE (~−31 s jump): followed. UE skew stayed ≤ 10 ppm and DL arrival was 100 % in the DL area. With detection off, skew read ~10⁶ ppm and the DL frames scattered.
    • 1300 µs jump the DU undoes: ignored, with 0 uplinks off-slot. With confirm 3 it was followed out and back, putting 4–5 of 20 uplinks off-slot.

    Open:

    • after a DU restart the UE's grant state doesn't follow (the new maps restart epoch and superframe count);
    • one inter-frequency handover fell back in regression (no target beacon heard within 1 s; detection not involved; cause not established; 3 re-runs pass).
  3. josephnef commented on Nov 6, 2026

    @josephnef
    CollaboratorAuthor

    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:

    • for a while after the UE's clock fit confirms a step of the DU's TSF, a map at a strictly lower epoch that is due near its beacon's own superframe starts the UE's grant state over (GrantMapReceiver::offer_after_restart);
    • the UE then resets what belonged to the old DU instance, as on a handover switch.

    Rig (testrig m6_fitstep.sh, DU1 killed and restarted under its UE, 3/3):

    • one re-attach per run;
    • the restarted DU hears the UE in 99.8–100 % of superframes from its service start, 99.8 % in slot;
    • UE DL 100 % in its granted slot.

    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:

    • grant maps carry no tag, so a forged map could already redirect slots, and within the re-attach window it can also reset the UE's per-DU state; authentication is a wire change;
    • RACH-joined UEs across a DU restart.
  4. 16 remaining items

  5. josephnef commented on Nov 8, 2026

    @josephnef
    CollaboratorAuthor

    DAPS downlink duplication — merged (#545).

    The downlink now has a CU-anchored stream:

    • CU: originates each UE's DL PDUs over F1 Downlink (v12). Each stream has an epoch, so replays are refused and a re-created stream starts cleanly.
    • DU: airs up to 8 PDU sns per DL frame, under the tag (cell protocol v4).
    • UE: delivers each sn once, whichever DU it came through (DlStreamRx).
    • Duplication: from the handover command until 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:

    case DAPS no DAPS
    source DU killed when the UE accepts (3) 0 / 0 / 0 21 / 23 / 33
    plain handover (3) 0 / 0 / 0 8 / 18 / 19
    emulated crossing, A3 at defaults, 2 handovers (2) 0 / 0 57 / 62
    • DL silence: DAPS recovers the PDUs, not the time; the silence while the source fades or is dead remains (crossing 3.6–4.3 s).
    • Cost: 58–104 duplicates per run (36–52 without).
    • Every run: TSF continuity at each switch, the target hearing the UE in slot ≥ 99 %, no re-association, and with DAPS 0 uplinks lost through the target.
    • What the crossing found: duplicating only from the command lost 6 and 9 PDUs to a source link fading before the A3 fired; hence the backlog. One crossing still lost 6 PDUs more than 1.6 s before its command. This is documented, not tuned away.

    Results: testrig results/2026-11-08-m6-daps-dl.txt. Docs: the downlink duplication section in docs/cell-m6.md.

  6. josephnef commented on Nov 8, 2026

    @josephnef
    CollaboratorAuthor

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

    • When: in its suspend superframes (one in N, staggered by id), a UE may take a gap over its own slots. The gap starts in S and ends before S + 2.
    • The beacon: the UE stops its uplink beacon around the gap, so it never fires on the neighbour's channel.
    • The DL: the DU airs that UE no DL frame in S or S + 1.
    • Agreement: whether a UE needs suspend gaps at all is decided at the floor gap bound on both sides.
    • The planner (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:

    case reports carrying the other cell suspend gaps per UE (heard) uplink heard
    suspend gaps (idle gaps off) 100 % 43 (100 %) 90 / 95 %
    none (counterfactual) 0 % 0 100 %
    suspend + A3 executing 100 % 42–44 (100 %) 91–95 %
    • Safety checks: no DL frame inside any deaf interval, no stray beacon on the other channel, and uplinks in slot 100 % from the first gap on.
    • Handover: both A3 handovers ran on suspend-gap measurements alone.
    • Cost: 1–2 uplinks in 20. The first uplink after a gap is lost too, because the restarted beacon airs a superframe later. Why is not established.

    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" in docs/cell-m6.md.

  7. josephnef commented on Nov 9, 2026

    @josephnef
    CollaboratorAuthor

    Three cells on the rig — merged (#547).

    Layout:

    • DU1 on ch149 (j6412-1) at phase 0.
    • DU2 on ch157 (j6412-3) and DU3 on ch36 (j6412-2, on the usual witness radio). Both ask the CU for a phase and get 51200.
    • One UE per cell; UE3 is the weak-TX module, which is fine for measuring.

    testrig m6_three_cells.sh (m5_ran_coord.sh --du3/--ue3), 3 runs each way, all pass:

    direction (UE c measuring cell o) with suspend gaps measured by without
    1→2, 1→3, 2→1, 3→1 100 % idle gaps (86 per run) 100 %
    2→3, 3→2 100 % suspend gaps (43 per run) 0 %
    • The geometric limit is real on air: without suspend gaps, cells 2 and 3 never measure each other.
    • Safety: no DL frame inside a deaf window, and uplinks in slot 100 % after the first gap.
    • Cost: DU2 and DU3 heard their UEs' uplinks 90 % of the time (DU1 100 %).

    What the first three-cell runs found: the UE loop's own timing was losing gaps.

    • An idle gap just before a suspend superframe pushed the 100 ms loop past the suspend gap's start, silently and every time for that alignment. One run never measured 2→3.
    • A pass that slept through a suspend gap consumed an idle gap from a stale superframe count, halving the idle rate.

    Fixes:

    • such an idle gap is deferred, and missed idle starts are retried;
    • suspend timing comes from a fresh clock reading;
    • only a gap that ran consumes its window;
    • every missed suspend window is logged (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.

  8. josephnef commented on Nov 9, 2026

    @josephnef
    CollaboratorAuthor

    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:

    case moves (each to the strongest neighbour in its last report) target in slot
    A3 defaults UE1 1→3 (−32 vs −51), UE3 3→1 (−31 vs −51), a swap; UE2 stays 99.9 %
    A3 −2 dB the same, plus UE2 2→3, measured only in suspend gaps (9 and 13 of them, no idle one) 99.9–100 %
    • Every switch: TSF continuity, ran.ho_done, no fallback, and no DL in a deaf window on either side.
    • From the new cell: each moved UE measures both other cells in 100 % of its reports.
    • The misses behind 99.9 %: one uplink per switch between cells 51.2 ms apart in phase, about the phase difference off, in the second superframe after the switch. None for the same-phase switch. Cause not established.

    Bugs the first runs found:

    • The A3 picked a weaker neighbour: it fired for whichever neighbour first held its time-to-trigger, and sent UE1 to cell 2 at −51 dBm with cell 3 at −32. Now only the strongest neighbour meeting the condition can fire.
    • A re-plan cost an arrival: the CU re-planned a DU with a schedule instant right before another UE's arrival there. The arrival expired and the UE fell back. Now there is no new schedule for a handover's source or target until it concludes. A refused schedule stays the keepalive meanwhile (from the review), so contact is never lost.

    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.

  9. josephnef commented on Nov 10, 2026

    @josephnef
    CollaboratorAuthor

    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.

    • Route: it crosses 1 → 2 → 3, the second crossing on the pair it can measure only in suspend gaps.
    • Not reachable: entering cell 1 from either other cell is beyond the 20 dB range on this rig, since each radio hears its own box's DU far above the others.

    testrig m6_crossing3.sh, 3 crossings + 2 with a source failure, all pass:

    1 → 2 (idle gaps) 2 → 3 (suspend gaps only)
    trigger margin 5–8 dB 5–9 dB
    crossing → switch 49–69 sf 39–52 sf
    • Every switch: TSF continuity and the target in slot ≥ 99 %.
    • Safety: no DL frame inside a suspend gap.
    • DL DAPS: 0 PDUs lost in the handover windows.
    • Uplinks beyond the expected cost (suspend gaps, inter-frequency switch superframes): 0–4 per run.
    • Source killed mid-crossing: DL DAPS still lost 0 PDUs, with DL silence 1.3–1.5 s. 12–14 uplinks were lost from the kill to the switch, because uplink DAPS needs one channel. This measures the open different-channel DAPS variant.

    Found and fixed:

    • The bug: a neighbour measured only in suspend gaps (every 2.05 s) flickered stale at the CU's 2 s bound. The A3 kept restarting, and 2 → 3 fired 112–120 superframes late.
    • The fix: the CU now decides per UE which neighbours are suspend-only, from the reports. Those reports count for k suspend periods + 1.5 s, using each DU's reported period (F1 v13).

    Results: testrig results/2026-11-10-m6-crossing3.txt.

  10. josephnef commented on Nov 10, 2026

    @josephnef
    CollaboratorAuthor

    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.

    • It goes in its idle time, after its own uplink slot.
    • It airs a copy of its uplink beacon there, clear of the target cell's active span, holds 4 ms (send_packet only queues), and retunes back.

    The CU side (F1 v14 Uplink.net_us, Handover.h ul_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:

    source killed at the accept uplinks lost before the switch
    with visits 0, 0
    without 15, 15
    • Each switch still costs its own superframe and the next (the first uplink after the beacon restart).
    • Without a fault, the visits cost the source nothing: every uplink was heard, in slot 100 %.
    • The step hold: a visit right before the switch left a TSF-step hold from the source's count, which kept the uplink 40 ms off on the target; the switch now clears it.
    • Regression: same-channel DAPS, handovers, both crossings with source failures, and measgap 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.

  11. josephnef commented on Nov 10, 2026

    @josephnef
    CollaboratorAuthor

    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 frame is broadcast-addressed, so no ACK is expected and there is no CW escalation.
    • It is tagged with the target cell's key, and the UE takes the PDUs while it still holds the target's channel.
    • Answers go out from a dedicated thread: sending from the RX callback froze the DU.
    • Only across channels: on one channel an answer would land in the source cell's schedule.

    The bookkeeping:

    • An answer that is not aired gives its PDUs back.
    • After the switch, not-yet-offered PDUs go first, within the UE's reorder window.

    testrig m6_daps_xch.sh, 4 runs per case (min / median / max), all pass:

    DL gap across the switch uplinks lost before the switch
    source killed at the accept, answered 225 / 225 / 226 ms 0 / 0 / 0
    source killed, no visits 1497 / 1549 / 1805 ms 10 / 10.5 / 14
    no fault, answered 225 / 225 / 226 ms 0

    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.

  12. josephnef commented on Nov 11, 2026

    @josephnef
    CollaboratorAuthor

    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:

    • UE: the pass after a switch runs at the target's first superframe (+1 ms), not 100 ms later. The TSF is placed for the target's UL slot right after the retune, while the beacon is stopped. Kestrel's first beacon after a TSF step fires at the OLD phase (predicted 57.2 ms, observed 57.3). If that stale TBTT would land in the target's active span, the restart waits it out: one uplink given up rather than a collision. That happened in 5 of 27 switches on the last runs, all in three-cell layouts.
    • DU: pick_dl_pdus leads 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).
    • CU: the target identity's UL slot is seeded from the handover command. A UE's A3 is frozen while its handover is in flight; m6_three_ho showed 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 new cell.dl log, 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions