Skip to content

cell M6: UE measurement gaps — the A3 on the UE's own measurements (inter-frequency trigger) - #533

Merged
josephnef merged 2 commits into
masterfrom
feat/m6-measgap
Nov 5, 2026
Merged

josephnef merged 2 commits into
masterfrom
feat/m6-measgap

Conversation

@josephnef

Copy link
Copy Markdown
Collaborator

Part of M6 (#260), epic #254: UE measurement gaps, the inter-frequency handover trigger, on top of #532. With one radio per DU, no DU hears a UE on another channel, so the A3 needs the UE's own measurements.

  • src/cell/MeasGap.h:
    • NL IE: the neighbour list as offsets on the serving grid, (phase_n − phase_s) mod P, so the UE knows when a neighbour's beacon airs from its serving clock alone.
    • MR IE: the UE's filtered beacon RSSI/SNR per cell.
    • Both carry a CRC and the cell key's tag.
    • gap_window(): retune + beacon + retune, clear of the serving beacon and the UE's own DL/UL slots; otherwise the gap is skipped.
  • F1 v7: Neighbours (CU → DU, every round) and Report.ue_meas.
  • DU: airs the NL IE and relays its UEs' MR reports.
  • UE: filters every cell beacon it hears. Every CELL_UE_GAP_SF it measures an other-channel neighbour in a gap, if one fits, and reports every second.
  • CU: the A3 on UE reports compares serving and neighbour beacons (metric ue_rssi).

Rig (testrig scripts/m6_measgap.sh, 5 runs; ch149 and ch157, cell 2 at phase 60000 µs; default A3 thresholds, nothing forced): all pass.

min median max
gap deaf time (14 gaps) 53.7 ms 53.9 ms 56.6 ms
neighbour in gaps vs serving −46 vs −55..−57 dBm
switch after the network instant 21 ms 21 ms 28 ms
interruption (sf) 3 3 3
  • Every run: the A3 fired on UE-reported values, the inter-frequency handover executed, and the gaps cost no uplink.
  • TSF continuity: now asserted exactly. The switch is at the network instant eff_target·P − ((phase_t − phase_s) mod P) plus the UE's poll latency. All earlier rig sets re-verify under it.

ctest 92/92. Limit: a gap goes only where it clears the UE's slots, so in this layout cell 2's UE cannot measure cell 1 (logged gap_skip).

🤖 Generated with Claude Code

…ents, the inter-frequency trigger

src/cell/MeasGap.h:
- the NL IE: the neighbour list as offsets on the serving grid, (phase_n -
  phase_s) mod P on the shared timebase;
- the MR IE: the UE's filtered beacon RSSI/SNR per cell;
- both carry a CRC and the cell key's tag;
- gap_window(): retune + beacon + retune, clear of the serving beacon and
  the UE's own DL/UL slots, or none.

F1 v7: Neighbours (CU -> DU, every round) and Report.ue_meas.

DU: airs the NL IE (from the CU's list, on its own grid) and relays its
UEs' MR reports with ages.

UE: L3-filters every cell beacon it hears and parses the NL IE. Every
CELL_UE_GAP_SF it measures an other-channel neighbour in a gap if the
window fits (cell.ue.gap / gap_skip), and reports in the MR IE every
CELL_UE_MR_MS.

CU: for a UE that reports, the A3 compares serving and neighbour beacons
(metric ue_rssi).

Rig (testrig m6_measgap.sh, 5 runs, ch149 / ch157 at phase 60000, DEFAULT
A3 thresholds, nothing forced): all pass.
- gaps 53.9 ms median, the neighbour heard at -46 dBm vs a serving -56 dBm;
- the A3 fired on UE reports, and the inter-frequency handover executed;
- the gaps cost no uplink.
The TSF-continuity assertion is now exact: the switch is at the network
instant eff_target * P - ((phase_t - phase_s) mod P) plus the poll latency
(21 ms median). ctest 92/92. docs/cell-m6.md.

Refs #260

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

Trigger inter-frequency handovers from UE measurement gaps

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Add slot-safe measurement gaps so UEs can hear beacons on neighbouring channels.
• Relay authenticated UE beacon measurements to the CU for A3 handover decisions.
• Test the new wire formats and gap calculations; document rig results and scheduling limits.
Diagram

sequenceDiagram
    participant CU as Controller
    participant DU as Serving DU
    participant UE as UE
    participant Gap as Gap planner
    participant NB as Neighbour DU
    CU->>DU: F1 neighbour phases
    DU->>UE: Beacon with NL IE
    UE->>Gap: Check slot-safe window
    Gap-->>UE: Gap or skip
    NB-->>UE: Beacon during gap
    UE->>DU: Beacon with MR IE
    DU->>CU: F1 UE measurements
    Note over CU: A3 compares UE-reported beacons
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Scan neighbouring channels without phase scheduling
  • ➕ Does not require the CU to distribute shared-timebase beacon phases.
  • ➖ Requires longer or less predictable deaf periods and risks missing serving-cell slots.
2. Use DU-reported uplink measurements only
  • ➕ Avoids UE retunes and new on-air measurement reports.
  • ➖ Cannot provide neighbour evidence across channels when each DU has one radio.

Recommendation: Use the proposed phase-based, slot-safe gaps: they provide the cross-channel evidence A3 lacks without deliberately sacrificing UE slots. The remaining limitation—phase layouts in which no gap fits—needs CU-side phase planning if bidirectional measurements are required.

Files changed (6) +707 / -18

Enhancement (4) +566 / -12
main.cppConnect DU and UE measurement-gap behaviour +180/-2

Connect DU and UE measurement-gap behaviour

• The DU receives CU neighbour lists, advertises them in beacons, and relays authenticated UE measurement reports. The UE filters received beacons, schedules other-channel gaps clear of its slots, and periodically advertises its measurements.

examples/cell/main.cpp

F1.hExtend F1 v7 with neighbours and UE measurements +95/-5

Extend F1 v7 with neighbours and UE measurements

• Adds a CU-to-DU Neighbours message carrying channels and phases, plus aged UE beacon measurements in Report. Updates versioned encoding and decoding for both.

src/cell/F1.h

MeasGap.hDefine authenticated measurement IEs and gap eligibility +250/-0

Define authenticated measurement IEs and gap eligibility

• Introduces neighbour-list and measurement-report IEs with CRC and cell-key tags, along with serving-grid offset calculation. Its gap-window function rejects retune windows overlapping the serving beacon or the UE's DL and UL slots.

src/cell/MeasGap.h

main.cppDrive A3 with UE-reported beacon measurements +41/-5

Drive A3 with UE-reported beacon measurements

• Sends fresh neighbour channels and phases to each DU every planning round. When a UE supplies live serving measurements, A3 compares its serving and neighbour beacon reports instead of mixing in DU-heard uplink values; otherwise it retains the existing fallback.

tools/ran-controller/main.cpp

Tests (1) +62 / -0
cell_m6_selftest.cppTest measurement formats and gap-window decisions +62/-0

Test measurement formats and gap-window decisions

• Covers offset wrapping, NL and MR parsing and authentication, feasible and conflicting gaps, and F1 v7 round trips and truncated Neighbours messages.

tests/cell_m6_selftest.cpp

Documentation (1) +79 / -6
cell-m6.mdDocument measurement-gap operation and rig results +79/-6

Document measurement-gap operation and rig results

• Explains neighbour offsets, gap eligibility, UE reporting, and the resulting A3 trigger. Records five reported rig runs, the precise TSF-continuity expectation, and phase layouts that prevent a measurement.

docs/cell-m6.md

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

qodo-free-for-open-source-projects Bot commented Nov 5, 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


Action required

1. The first gap can cover serving slots ✓ Resolved
Description
run_ue initially budgets 25 ms per retune in gap_window() before measuring any retune, although
a band-changing FastRetune() can take the approximately 90 ms full channel-switch path. For that
hop, the first accepted gap can keep the radio away through a serving beacon or the UE’s DL or UL
slot; subsequent gap calculations cannot protect that first attempt.


Remediation recommended

2. Crossing handover is never tested ✓ Resolved
Description
The measurement-gap rig in docs/cell-m6.md (5 runs) validates the A3 trigger and switch timing
only with stationary UEs, and the doc states the rig's UEs are stationary. No A→B crossing,
source-path failure, or DAPS duplicate-uplink gain is measured, so interruption and TSF continuity
are shown only for a stationary UE.

3. A late wake-up can miss serving traffic ✓ Resolved
Description
run_ue sleeps until its planned gap start and immediately retunes without checking how late the
thread woke. When scheduler delay moves the actual gap beyond the interval checked by
gap_window(), the radio can remain off-channel during a serving slot or beacon.

4. A slow return retune can miss slots ✓ Resolved
Description
run_ue replaces last_retune_us with the outbound retune duration but uses the already-planned
window length to time the return, without measuring or reserving for that return. If either retune
exceeds the estimate used by gap_window(), the actual off-channel interval can extend beyond the
checked window and into serving activity.

View review recommended (7)
5. Old signal samples appear fresh ✓ Resolved
Description
run_ue copies RSSI and SNR from MeasTable::snapshot() into an MR but discards each entry’s
age_ms; the DU then timestamps the MR at receipt. A neighbour sample near the UE’s staleness limit
can consequently pass the controller’s freshness check for another report-age window and influence
A3 after the measurement has expired.

6. Partial reports hide heard neighbours ✓ Resolved
Description
main treats a fresh UE measurement of its serving cell as grounds to skip aggregation from every
neighbour DU, even when the UE report contains no measurement for a particular neighbour. For a
same-channel neighbour heard by its DU but absent from that MR, A3 receives no neighbour value and
resets its trigger timer.

7. Replay can restore stale gap directions ✓ Resolved
Description
find_nl_ie verifies the NL tag but neither the IE nor run_ue checks a list sequence or expiry
before replacing nbrs. Replaying a previously valid serving-cell NL beacon can therefore restore
an obsolete channel and offset that the UE later passes to FastRetune().

8. A failed retune back leaves the UE stuck on the neighbour's channel ✓ Resolved
Description
In run_ue's measurement-gap block, a thrown FastRetune(cur_ch) only sets ok = false and logs
cell.ue.gap, leaving cur_ch naming the serving channel without confirming that the radio
returned. If the return fails after the radio reaches the neighbour channel, the UE continues its
normal loop without hearing serving beacons or downlinks, its uplink beacon can keep transmitting on
the neighbour channel, and later gaps filter neighbours using the mismatched cur_ch.

9. UEs keep measuring a stale neighbour list ✓ Resolved
Description
run_du omits the NL IE when nb_snap is empty or the DU is not synced, while run_ue replaces
nbrs only when it parses an NL IE and does not clear it on handover. When the controller withdraws
the last neighbour, synchronization is lost, or the UE changes serving cells, gap scheduling can
keep using the old list’s channels and offsets against the current grid, causing roughly 54 ms of
deafness every 10 superframes for an absent beacon or repeated gap_skips.

10. With three or more neighbours, measurements expire before they are refreshed ✓ Resolved
Description
The gap round-robin (gap_rr) measures one other-channel neighbour every CELL_UE_GAP_SF (10)
superframes. With N neighbours, each one is re-sampled only every 10·N superframes, about 3.07 s for
N=3 at roughly 102 ms per superframe, which is longer than the default CELL_MEAS_STALE_MS of 3000
ms. Each new sample then reseeds the L3 filter, so the A3 runs on raw single-beacon RSSI, and the
neighbour is invalid (absent from the MR) for part of every cycle. Neighbours that always gap_skip
still take turns in the rotation and make this worse.

11. A spoofed beacon can trigger a handover ✓ Resolved
Description
run_ue calls ue_meas.update with RSSI and SNR from any beacon whose source address matches
02:ce:11::00, without authenticating the beacon or checking the claimed cell against the neighbour
list. A transmitter spoofing that address can put its signal quality into a valid UE-tagged MR; the
CU then prefers those values over DU-heard evidence for A3, potentially handing the UE to a cell it
cannot reach despite the header’s claim that a forged report cannot steer a handover.


Informational

12. A replayed measurement report is accepted as fresh ✓ Resolved
Description
The MR IE body ('M' 'R' ver cell ue n entries) has no sequence number, generation or time, and the
DU stamps ue_reports[ue].at_ms = now_ms() on every one it verifies. A captured, validly tagged UE
beacon replayed later is relayed to the CU with a fresh age. It can therefore keep or steer an A3
decision with old measurements, for example values recorded when the UE was near a neighbour.

13. Reports from later UEs are silently cut off in the F1 report ✓ Resolved
Description
The DU caps r.ue_meas at f1::kMaxHeard (64) across all UEs, iterating ue_reports in UE-id
order. With up to 24 UEs per cell and up to 9 entries each, the later UEs' entries are silently
dropped, sometimes including their serving-cell entry. The CU then treats those UEs as non-reporting
(or as reporting partially) and falls back to DU-heard evidence, which cannot see neighbours on
other channels.

Grey Divider

Tip of the day
💡 Did you know, you can turn these tips off under Display > Comment parts

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

UE gaps:
- planned with a conservative retune bound for both retunes
  (CELL_UE_GAP_RETUNE_US floor, raised by any slower retune measured);
- no gap across bands;
- skipped on a late wake-up;
- listening cut short when the outbound retune overruns;
- a failed return is retried, then the beacon stops and the normal retune
  path recovers (cell.ue.gap_return_fail);
- neighbours that never fit are parked;
- each measurable neighbour is revisited every CELL_UE_GAP_SF;
- TSF steps wait 4 superframes after a gap: the only uplink losses seen
  with gaps followed one with a step; the mechanism is not established.

Freshness, IE v2:
- NL and MR carry a tagged generation; UEs refuse an older NL, and DUs an
  MR not newer or far from their superframe;
- MR entries carry their sample age, added to the report age.
- A synced DU airs an empty NL too. UEs drop a list not heard for 3 s, and
  clear it on a handover.

Provenance: a beacon is measured only if it carries that cell's valid NL
IE. The residual risk (a replayed genuine beacon) is stated.

CU: per neighbour, one comparable pair (the UE's or the DUs'), with the A3
run on margins.

F1: ue_meas cap kMaxUeMeas = 216, serving entry first, whole UEs only,
truncation logged.

Rig (m6_measgap.sh, 8 runs, default A3): all pass, 21 gaps with no uplink
lost. The same-channel m6_handover regression passes. ctest 92/92. The
docs state the stationary-UE limit.

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

Copy link
Copy Markdown
Collaborator Author

Qodo review addressed in 7581772:

  1. First gap over serving slots: gaps are planned with a conservative bound for both retunes (CELL_UE_GAP_RETUNE_US floor, only raised by measured retunes), and never across bands.
  2. Crossing: the docs' limits now state that every handover result comes from stationary UEs and that no crossing has been validated; an emulated crossing (the source DU ramping its TX power) is the way to get one.
  3. Late wake-up: the serving clock is re-read after waking, and the gap is skipped if late (cell.ue.gap_late).
  4. Slow return retune: the return budget is reserved; an outbound overrun shortens the listening.
  5. Stale samples: MR entries carry their sample age, added to the report age.
  6. Partial reports: per neighbour, one comparable pair (the UE's or the DUs'), with the A3 run on margins.
  7. NL replay: the NL carries a tagged generation, and UEs refuse older ones.
  8. Failed return retune: retried; if it still fails, the beacon stops and the normal retune path recovers (cell.ue.gap_return_fail).
  9. Stale list: synced DUs air empty NLs too; UEs drop lists unheard for 3 s and clear them on a handover.
  10. Revisit interval: each measurable neighbour is revisited every CELL_UE_GAP_SF; non-fitting ones are parked.
  11. Spoofed beacons: only a beacon carrying that cell's valid NL IE is measured; the residual (a replayed genuine beacon) is stated.
  12. MR replay: the MR carries a tagged generation; the DU refuses anything not newer or far from its superframe.
  13. Truncation: kMaxUeMeas = 216, serving entry first, whole UEs only, truncation logged.

Also from the rig: TSF steps wait 4 superframes after a gap. The only uplink losses seen with gaps followed one with a step; the mechanism is not established, and 8 runs with 21 gaps lost none with the hold. Rig: m6_measgap.sh 8/8 and the same-channel regression pass; ctest 92/92.

@josephnef

Copy link
Copy Markdown
Collaborator Author

CI green. Qodo shows no bugs, rule violations or requirement gaps; the two remaining items are the high-level 'alternative approaches' (blind neighbour scanning; DU-reported uplink only), and Qodo's own recommendation is the phase-based, slot-safe gaps this PR implements. The limitation it names (layouts where no gap fits) is the CU phase planning that follows in the next PR. Merging with skip-qodo-gate.

@josephnef josephnef added the skip-qodo-gate Bypass the Qodo review gate (outage / maintainer decision) label Nov 5, 2026
@josephnef
josephnef merged commit 61089e3 into master Nov 5, 2026
27 of 28 checks passed
@josephnef
josephnef deleted the feat/m6-measgap branch November 5, 2026 21:56
josephnef added a commit that referenced this pull request Nov 5, 2026
…sure each other (#534)

M6 (#260, EPIC #254): the CU places a **joining** DU's TBTT on the
shared PTP timebase so that its UEs and the running cells' UEs can
measure each other in gaps (#533). Running cells keep their phases.

## What
- **`src/cell/PhasePlan.h`** (new) — `plan_phase(joining, existing,
params)`:
- another channel: per candidate phase, the share of each cell's
capacity positions that fit a `gap_window` for the other's beacon, both
ways (pair score up to 2);
- same channel: 2 for disjoint active spans, 1 if only the beacons are
clear;
- best total over a 100 µs grid, then the middle of the widest run of
equal scores;
  - `phase_cell()` builds a cell from a Report's layout.
- **F1 v8 `Type::Phase`** (`PhaseAssign{du, phase_us, seq}`), tagged,
with the request's seq echoed back.
- **DU** (`CELL_PHASE_AUTO=1`):
- before starting its beacon, it reports `phase_us = -1` with its grid,
every 500 ms;
- it takes only a CU-addressed, tagged answer to its own run's requests
(`cell.phase.assigned`);
- otherwise, after `CELL_PHASE_WAIT_S`, it keeps `CELL_PHASE_US`
(`cell.phase.default`);
  - `TsfDiscipline::set_phase()`.
- **CU** (`tools/ran-controller`):
- joining DUs are kept apart from the running ones and are in no
neighbour list until they report a phase;
- they are planned one at a time in id order, each waiting up to
`RAN_PHASE_WAIT_S` for expected DUs not yet heard;
  - assignments are sticky; `ran.phase` logs the score per pair;
  - it plans with `RAN_GAP_RETUNE_US` / `RAN_GAP_GUARD_US`.
- **UE gap bound defaults are now 26000 / 2000 µs**, down from 30000 /
3000. The 8852CE's retune measured 23.5–23.7 ms, so this is that plus
about 10 %. At 30 + 3 ms, no phase is measurable both ways on the rig
grid.
- Selftests (cell_m6):
- plan_phase on the rig grid: the both-ways window is [48500, 53900] and
the plan picks 51200;
  - the 30/3 bound has no both-ways phase;
  - same channel gives disjoint spans;
  - a third cell gets a partial plan;
  - F1 Phase round-trip, forgery and truncation;
  - `set_phase`.
- Docs: `docs/cell-m6.md` gains a "Phase planning" section.

## Rig (testrig `scripts/m6_phaseplan.sh`, 5/5 pass)
Cell 1 is on ch149 at phase 0. Cell 2 is on ch157 with
`CELL_PHASE_AUTO=1`.
- **Phase:** DU2 was assigned **51200 µs, score 2/2, in every run**.
- **Gaps:** both UEs heard the other cell in **568 of 570 gaps**, with
no gap skipped and no uplink lost.
- **Gap deaf time:** 54.1 ms median, down from 60.1 ms at 30/3.
- **Regression:** `m6_measgap.sh` (inter-frequency handover on the UE's
own measurements) passes 2/2 at the new bound.

**Found on the way.** When the UEs were started a fixed 8 s after the
DUs, the auto-phase DU was still waiting for its phase with its beacon
on (the request then came after `StartBeacon`). The UE that locked early
then saw one jump of about 1.3 ms in its beacon's timing. Its cumulative
`TsfSync` fit took that as skew (hundreds to thousands of ppm), and its
gaps missed (3 of 3 runs). This PR changes two things:
- the DU now asks before its beacon starts;
- the rig starts the UEs only once both DUs log `cell.du.start`.

The source of the jump is not established. A UE-side reset of the fit on
a detected discontinuity is listed as open in the docs.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@josephnef josephnef mentioned this pull request Nov 5, 2026
3 of 5 tasks
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