Skip to content

cell M6: DAPS across channels, downlink — the target answers each visit with its DL PDUs - #559

Merged
josephnef merged 3 commits into
masterfrom
feat/m6-daps-xch-dl
Nov 10, 2026
Merged

josephnef merged 3 commits into
masterfrom
feat/m6-daps-xch-dl

Conversation

@josephnef

Copy link
Copy Markdown
Collaborator

Part of #260 (epic #254). Follows #558 (sequential uplink visits): the downlink side across channels.

Why

Before the switch, the UE takes DL only from its serving cell. With the source dead, it had no DL from the kill to the switch (1.4–1.9 s), although the target held every PDU since the command (DAPS DL).

What (examples/cell)

  • The answer: when the target DU hears a visit copy from a UE it staged for an inter-frequency arrival, before the switch, it answers at once with the queued DL PDUs no answer has offered yet (up to 8).
    • The frame (build_visit_dl_frame, cell.h) is broadcast-addressed: no ACK, no CW escalation.
    • It is tagged with the target cell's key, and the body names the UE's target identity.
  • The UE: while still holding the target's channel, it takes the PDUs into its DL stream (parse_visit_dl_frame; cell.ue.visit_dl).
  • After the switch: the queue keeps everything (nothing ACKs an answer), and the target sends not-yet-offered PDUs first, then the offered ones. Without that ordering, the newest PDU waited behind ~5 frames the UE already had (0.53–0.63 s).
  • Sending: answers go out from a dedicated thread. Sending from inside the RX callback froze the DU on the rig (the RX loop recycles the TX pages).
  • Only across channels: the target's neighbour list must put the source on another channel. On one channel the target hears every uplink, and an answer would land in the source cell's schedule.
  • Selftests: the visit DL frame (broadcast RA, target identity, key, not a unicast DL frame, PDU sn under the tag). ctest 95/95.

Rig (testrig scripts/m6_daps_xch.sh, 2 runs per case, all pass)

longest DL silence at the UE runs
source killed at the accept, visits answered 205, 205 ms
source killed, no visits 1395, 1498 ms
no fault, visits answered 308, 205 ms
  • Answers: 19–21 per handover; DL DAPS lost no PDU.
  • Uplink: unchanged (0 lost before the switch with visits).
  • What remains: the switch's own pause (~2 superframes). That is next.
  • Regression: same-channel DL DAPS (no answers sent, silence unchanged), the three-cell crossing with a source failure, plain and fault handovers.

🤖 Generated with Claude Code

…it with its DL PDUs

Before the switch the UE takes DL only from its serving cell, so a dead
source left it without DL from the kill to the switch, though the target
held every PDU.

- The target DU answers a visit copy from a UE staged for an
  inter-frequency arrival, before the switch. The answer carries the queued
  DL PDUs no answer has offered yet (up to 8), in a broadcast-addressed frame
  (build_visit_dl_frame: no ACK, no CW escalation) tagged with the target
  cell's key.
- The UE, still on the target's channel, takes the PDUs into its stream
  (cell.ue.visit_dl). The queue keeps everything, and after the switch the
  not-yet-offered PDUs go first.
- Answers are sent by their own thread; sending from the RX callback froze
  the DU on the rig.
- Only across channels: on one channel an answer would land in the source
  cell's schedule.

Rig (testrig m6_daps_xch.sh, 2 runs per case, all pass): longest DL silence
with the source killed 205 ms with answers, 1395-1498 ms without. Uplink
results unchanged. Same-channel DL DAPS, the three-cell crossing with a
failure and the handovers pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Deliver target downlink PDUs during inter-frequency DAPS visits

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 20-40 Minutes

Grey Divider

AI Description

• Target DUs answer pre-switch, inter-frequency visits with queued downlink PDUs, reducing silence
 after source failure.
• UEs accept authenticated answers; targets retain offered PDUs and prioritize unoffered ones after
 the switch.
• A dedicated sender avoids RX callback stalls; selftests and rig results document the behavior.
Diagram

sequenceDiagram
    actor UE
    participant DU as Target DU
    participant Q as DL Queue
    participant Sender as Answer Thread
    participant Stream as UE DL Stream
    UE->>DU: Pre-switch visit copy
    DU->>Q: Select unoffered PDUs
    Q-->>DU: Up to eight PDUs
    DU->>Sender: Queue broadcast answer
    Sender-->>UE: Authenticated DL answer
    UE->>Stream: Accept and deduplicate
    Note over DU,Q: Retain offered PDUs
    DU->>Q: Prioritize unoffered after switch
    Q-->>UE: Scheduled DL frames
Loading
High-Level Assessment

The visit-answer approach fits the brief pre-switch target-channel window. Using a broadcast-addressed frame avoids ACK handling, while a separate sender avoids the observed RX callback stall. Direct callback transmission and ACK-dependent unicast are less suitable here.

Files changed (4) +266 / -9

Enhancement (2) +188 / -6
cell.hDefine authenticated broadcast visit DL frames +31/-0

Define authenticated broadcast visit DL frames

• Adds a builder and parser for broadcast-addressed target DL answers carrying the target UE identity and up to eight PDUs. Parsing reuses DL frame authentication while rejecting ordinary unicast DL frames.

examples/cell/cell.h

main.cppAnswer staged visits and deliver their downlink PDUs +157/-6

Answer staged visits and deliver their downlink PDUs

• The target identifies eligible inter-frequency visits, selects unoffered queued PDUs, and transmits answers through a dedicated thread. The UE accepts authenticated answers into its DL stream; after switching, the target sends unoffered PDUs before retained offered ones.

examples/cell/main.cpp

Tests (1) +16 / -0
cell_node_selftest.cppTest visit-answer framing and authentication +16/-0

Test visit-answer framing and authentication

• Checks broadcast addressing, target identity, epoch, and PDU sequence numbers. Verifies rejection with the wrong key or cell, rejection as an ordinary unicast frame, and detection of PDU tampering.

tests/cell_node_selftest.cpp

Documentation (1) +62 / -3
cell-m6.mdDocument inter-frequency downlink visit answers +62/-3

Document inter-frequency downlink visit answers

• Explains when the target answers visits, why it retains and reorders offered PDUs, and why answers use a separate sender. Records rig measurements, regressions, and the remaining switch pause.

docs/cell-m6.md

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Rig KPIs rest on two-run maxima ✓ Resolved
Description
The new rig table reports only the longest DL silence per case across two runs (for example `205,
205 and 1395, 1498`), with no distribution, percentiles or spread for handover interruption or DL
delivery. Two samples cannot show a distribution, so a reader cannot judge how much of the gap
between cases is run-to-run variation.

2. Rig results hide forced handovers ✓ Resolved
Description
The rig-result paragraph says the A3 hands UE1 over 1 → 2 and the source is killed at the accept,
but never states whether the handover trigger was forced on a stationary UE. The document's own
emulated-crossing section distinguishes runs with nothing forced, so these silence numbers could be
read as a moving-UE crossing when they are not.

3. Full answer queues skip downlink PDUs ✓ Resolved
Description
The visit callback advances q.offered before checking whether the bounded answer queue accepts the
frame. When that queue is full, later visits skip those PDUs even though no answer was queued; a
failed send_packet has the same effect because the marker is never restored.

View review recommended (2)
4. Lost answers can become downlink gaps ✓ Resolved
Description
The new two-pass scheduler drains every unoffered PDU before returning to offered ones, despite
visit answers having no acknowledgement. If an answer is lost and more than 32 newer PDUs accumulate
before the target serves the UE, the UE confirms the skipped sequence numbers missing before their
retained copies arrive.

5. Late answers miss the UE's visit ✓ Resolved
Description
The answer worker sends queued frames without a deadline or a check that the UE remains on the
target channel. If the worker is delayed behind other answers or radio sends, it can transmit after
the UE's four-millisecond hold and return retune, while the PDUs have already been marked offered.

Grey Divider

Tip of the day
💡 Did you know, you can move or hide resolved findings to focus on what still needs action

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

josephnef and others added 2 commits November 10, 2026 16:39
…, a deadline per answer, lost answers inside the reorder window

- An answer that is not queued (queue full), expires or fails gives its PDUs
  back: the offered mark rolls back unless a later answer moved it.
- Each queued answer carries a 3 ms deadline (the UE's hold on the target's
  channel); a stale one is dropped, not aired after the UE left.
- After the switch, pick_dl_pdus (cell.h) sends not-yet-offered PDUs first,
  but an offered PDU goes before it would fall 24 sns behind the fresh ones.
  A lost answer's PDU then reaches the UE inside its 32-sn reorder window.
  Selftests cover the orderings and a lost answer with a 60-PDU backlog.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rigger stated (A3 defaults, static UEs)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@josephnef

Copy link
Copy Markdown
Collaborator Author

Review addressed:

  1. Two-run maxima: 4 runs per case. The doc table gives min / median / max of the DL gap across the switch, the longest DL silence, and uplinks lost before the switch:
    • source killed, answered: DL gap 225 / 225 / 226 ms, 0 uplinks lost;
    • source killed, no visits: 1497 / 1549 / 1805 ms, 10 / 10.5 / 14 uplinks lost;
    • no fault, answered: 225 / 225 / 226 ms, 0 lost.
  2. Forced or not: the doc now states the handover is triggered by the CU's A3 at its defaults on the UEs' own measurements, with static UEs, not the emulated crossing, and points to that section.
  3. Offered before queued: the offered mark moves only for an answer that gets queued. An answer that is not queued, expires or fails rolls it back, unless a later answer moved it.
  4. Lost answers past the reorder window: pick_dl_pdus (cell.h, pure) sends not-yet-offered PDUs first, but an offered PDU goes before it would fall 24 sns behind them, inside the UE's 32-sn window. Selftest: a lost answer with a 60-PDU backlog never puts an offered PDU more than 32 behind the newest sent.
  5. Late answers: each queued answer carries a 3 ms deadline (the UE's hold). A stale one is dropped and its PDUs given back. 0 expired in the 8 visit runs.

Rig on this code: m6_daps_xch.sh --runs 4, all 12 runs pass; the same-channel m6_daps_dl.sh passes (no answers sent).

@josephnef

Copy link
Copy Markdown
Collaborator Author

Applying skip-qodo-gate: Qodo posted its review as a PR comment (5 findings, two of them requirement gaps), not a review with threads, so the gate sees no review. All 5 are addressed and re-verified on the rig (m6_daps_xch.sh 4 runs per case, same-channel m6_daps_dl). All 28 builds pass (macOS gcc was cancelled by the runner once; it passed on re-run).

@josephnef josephnef added the skip-qodo-gate Bypass the Qodo review gate (outage / maintainer decision) label Nov 10, 2026
@josephnef
josephnef merged commit d9b5cfc into master Nov 10, 2026
57 of 59 checks passed
@josephnef
josephnef deleted the feat/m6-daps-xch-dl branch November 10, 2026 16:00
josephnef added a commit that referenced this pull request Nov 11, 2026
Part of #260 (M6), epic #254. Follows #559.

The DL gap across an inter-frequency DAPS switch was ~2 superframes (225
ms) after #559's review fixes; the first uplink after the switch fired
at the source's phase. This PR removes the switch's own share of the
interruption.

**UE (`examples/cell`)**
- The loop's pass after a switch is scheduled for the target's first
superframe (+1 ms), not a full period later (the uplink beacon used to
restart past that superframe's UL slot).
- The TSF is placed for the target's UL slot right after the switch's
retune (`cell.ue.switch_place`). 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 is held past
it.

**DU (`examples/cell`)**
- `pick_dl_pdus`: a DL frame leads with not-yet-offered PDUs; an offered
PDU goes only just before the fresh one that would push it out of the
UE's reorder window while still recoverable. #559's rule (offered first
once 24 sns behind) let a large offered backlog lead after the switch.
Selftests: plain order, offered/fresh split, the lost-answer backlog, a
40-PDU backlog with a fresh PDU in the first frame.

**CU (`tools/ran-controller`)**
- The target identity's UL slot is seeded from the handover command, so
the first uplinks after the switch map to the right network superframe.
- A UE's A3 is frozen while its handover is in flight: with a slow
conclusion the DUs' stale evidence cleared the candidate and raised a
second one for the same move (seen on the rig in `m6_three_ho`; no
second command).

**Rig** (testrig `m6_daps_xch.sh`, 4 runs per case, cells on 149/157):

| case | UL lost before the switch | DL gap across the switch |
|---|---|---|
| source dies, visits | 0 (all 4) | 122–123 ms |
| source dies, no visits | 10–16 | 1498–2010 ms |
| source alive, visits | 0 (all 4) | 123 ms |

Re-run on the final build (2 per case): 122–123 ms in 3 of 4 visit runs,
225 in one, where the first frame after the switch had no new PDU yet
(100 ms PDU cadence); details in `docs/cell-m6.md`. Regression on the
rig: measgap, three_ho (3 runs), crossing3, handover, daps, daps_dl,
crossing all pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-qodo-gate Bypass the Qodo review gate (outage / maintainer decision)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant