Skip to content

cell M6: the UE's clock fit survives a jump of its DU's TSF (TsfSync step detection) - #535

Merged
josephnef merged 2 commits into
masterfrom
feat/m6-ue-fit-reset
Nov 6, 2026
Merged

josephnef merged 2 commits into
masterfrom
feat/m6-ue-fit-reset

Conversation

@josephnef

Copy link
Copy Markdown
Collaborator

M6 (#260, EPIC #254): the UE's clock fit now survives a jump of its serving DU's TSF. This closes the open item from #534.

Why

The UE fits the DU's TSF from DU beacons against its own free-running counter (src/TsfSync.h). The fit is cumulative, so a single jump in the DU's TSF is read as skew and keeps biasing every translation for a long time. In the #534 runs, one ~1.3 ms jump drove a UE's skew estimate to thousands of ppm, and its measurement gaps then missed.

What

  • TsfSync::SetStepDetect(threshold_us, confirm), opt-in and off by default, so other users are unchanged:
    • a beacon more than the threshold off the fit is held out of it;
    • confirm held beacons in a row that agree on one offset (within a quarter of the threshold) are treated as a step: the remote axis is re-anchored (a TSF step changes the offset, never the crystals' rate) and the held beacons join the fit;
    • held beacons that never agree are dropped, so a stray frame can't bend the line;
    • counters: Steps(), LastStepUs(), Rejected();
    • ShiftRemote (handover) also drops beacons held from the previous transmitter.
  • UE: detection is on by default with CELL_UE_FIT_STEP_US 200 and CELL_UE_FIT_CONFIRM 6, and is logged as cell.ue.fit_step. Six beacons outlast the DU's own discipline pass (500 ms), so a jump the DU undoes itself is never followed.
  • DU, test only: CELL_DU_TEST_TSF_STEP=US@SF adds a TSF jump after the discipline's sample, so it stays on air for one pass.
  • Selftests (tsf_sync):
    • the rig case: a +1300 µs step after 24 beacons is read as skew with detection off, and as one step with the skew kept with it on;
    • the step back is also confirmed;
    • a stray beacon is dropped;
    • a jump undone within the confirm window is dropped;
    • alternating outliers never confirm.
  • Docs: docs/cell-m6.md, a section on the UE's clock fit across a jump.

Rig (testrig scripts/m6_fitstep.sh, 3 runs per case, all pass)

case steps followed UE skew, max |ppm| DL frames in the DL area after uplinks off-slot, 20 sf after
DU restart under the attached UE, detection on the restart jump (−30.5…−31.1 s) and acquisition steps 8.0–10.1 100 % not judged (below)
same, detection off (1 run) — 996 125 11 % not judged
1300 µs jump the DU undoes ~500 ms later none (5 beacons held, then dropped) 8.4–11.9 100 % 0

With confirm 3 (an earlier iteration), the UE followed the transient out and back, 3 beacons late each way, which put 4–5 of 20 uplinks off-slot. That is why the default is 6.

Regression on the rig:

  • same-channel handover 2/2, with fallback 1/1;
  • measurement-gap handover 1/1;
  • inter-frequency handover 3 of 4. In the failure, the UE heard no target beacon within its fallback window. No beacon was held by the detection (no fit_step event). The cause is not established; DU2's discipline was converging from a −90 ppm acquisition estimate in that run.

Not in scope, now listed as open in the docs: after a DU restart, the UE's clock follows but its grant state does not. The restarted DU's grant maps restart at a low epoch on another superframe count, so the UE is not heard in the new grid.

🤖 Generated with Claude Code

…detection)

The UE's TsfSync fit is cumulative, so one jump of the DU's TSF read as skew
and biased every translation for a long time (in the phase-planning runs a
~1.3 ms jump ran a UE's skew to thousands of ppm). Opt-in step detection
(TsfSync::SetStepDetect): a beacon > threshold off the fit is held out of it;
`confirm` in a row agreeing on one offset are a step, re-anchored via the
remote axis (a TSF step is an offset change, never the crystals' rate); held
beacons that never agree are dropped. The UE enables it (CELL_UE_FIT_STEP_US
200, CELL_UE_FIT_CONFIRM 6 — longer than the DU's own discipline pass, so a
jump the DU undoes itself is never followed).

CELL_DU_TEST_TSF_STEP (test only): one TSF jump behind the discipline's back.

Rig (testrig scripts/m6_fitstep.sh, 3 runs per case, all pass):
- DU restart under an attached UE (a ~-31 s jump): followed, UE skew <= 10 ppm,
  DL 100 % in the DL area; without detection the skew reads ~10^6 ppm and the
  DL frames scatter.
- A 1300 us jump the DU undoes ~500 ms later: not followed (5 beacons held and
  dropped), 0 uplinks off-slot. With confirm 3 it was followed out and back,
  4-5 of 20 uplinks off-slot.

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

Keep the UE clock fit stable across serving DU TSF jumps

🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 20-40 Minutes

Grey Divider

AI Description

• Confirm persistent DU TSF jumps before re-anchoring the UE fit, preserving its skew estimate.
• Discard transient or inconsistent beacons so they cannot distort clock translations.
• Add a DU test jump, selftests, diagnostics, and rig findings.
Diagram

graph TD
  Beacon["DU beacons"] --> Residual["Fit residual"] --> Outlier{"Beyond threshold?"} -->|Yes| Held["Hold beacons"] --> Confirm{"Step confirmed?"} -->|Yes| Shift["Re-anchor axis"] --> Fit["Clock fit"]
  Outlier -->|No| Fit
  Confirm -->|Interrupted| Drop["Discard held"]
  Confirm -->|Pending| Held
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Reset and reacquire the fit after a confirmed step
  • ➕ Simpler separation of pre- and post-jump samples.
  • ➖ Loses the established crystal-rate estimate and requires new samples before the fit is ready.

Recommendation: Keep the PR's remote-axis re-anchoring: a TSF write changes the clock offset, not its crystal rate. Confirming the change before shifting also avoids following short-lived corrections; resetting the fit would unnecessarily interrupt clock translation.

Files changed (4) +224 / -12

Bug fix (2) +118 / -3
main.cppEnable UE step detection and add a DU test jump +53/-0

Enable UE step detection and add a DU test jump

• Configures UE step detection with a 200 µs threshold and six-beacon confirmation window, and emits fit-step diagnostics. Adds a test-only DU TSF jump after a discipline sample to exercise a transient discontinuity.

examples/cell/main.cpp

TsfSync.hConfirm clock steps without corrupting the fitted skew +65/-3

Confirm clock steps without corrupting the fitted skew

• Adds opt-in residual-based holding and confirmation, re-anchors the remote axis for confirmed steps, and counts rejected beacons. Handover shifts now clear beacons held from the previous transmitter.

src/TsfSync.h

Tests (1) +57 / -0
tsf_sync_selftest.cppCover persistent, transient, and inconsistent TSF jumps +57/-0

Cover persistent, transient, and inconsistent TSF jumps

• Tests skew corruption without detection, recovery across a persistent jump and step back, rejection of a stray beacon, and refusal to follow short-lived or alternating outliers.

tests/tsf_sync_selftest.cpp

Documentation (1) +49 / -9
cell-m6.mdDocument TSF-step behavior and rig results +49/-9

Document TSF-step behavior and rig results

• Explains how the UE holds, confirms, or drops divergent beacons and records restart and transient-jump rig outcomes. Replaces the resolved clock-fit open item with the remaining grant-state problem after a DU restart.

docs/cell-m6.md

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

qodo-free-for-open-source-projects Bot commented Nov 6, 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. Failed readbacks misdate test uplinks ✓ Resolved
Description
sync_once leaves clk.valid true when the test TSF write's ReadTsfAndFreeRun readback fails,
retaining the pre-write clock offset. If the write took effect, concurrent RX processing before the
next successful refresh uses that stale offset to attribute uplinks to slots and calculate forwarded
uplink sequences.

Grey Divider

Tip of the day
💡 Did you know, you can switch off images and animations for a plain-text comment

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

…idates the RX clock, as after any write

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

Copy link
Copy Markdown
Collaborator Author

Qodo review addressed in 0071ca3: failed read-back after the test TSF step — the RX clock snapshot is now invalidated under clk_mu when the read-back fails, exactly as the discipline's own write path does, and stays invalid until a fresh read publishes one. ctest 95/95.

@josephnef

Copy link
Copy Markdown
Collaborator Author

CI green; Qodo's bug is resolved (0071ca3). The remaining item is the high-level alternative 'reset and reacquire the fit after a confirmed step': that discards the crystals' rate the fit already holds and leaves the UE without a clock until 16 new beacons (~1.6 s); a TSF step changes only the offset, so re-anchoring the remote axis keeps the UE scheduled through it (rig: skew <= 10 ppm across a DU restart). Merging with skip-qodo-gate.

@josephnef josephnef added the skip-qodo-gate Bypass the Qodo review gate (outage / maintainer decision) label Nov 6, 2026
@josephnef
josephnef merged commit 476641f into master Nov 6, 2026
27 of 28 checks passed
@josephnef
josephnef deleted the feat/m6-ue-fit-reset branch November 6, 2026 06:50
josephnef added a commit that referenced this pull request Nov 6, 2026
M6 (#260, EPIC #254): a UE re-attaches to its DU after the DU restarts.
This closes the open item from #535.

## Why
#535 made the UE's clock follow a restarted DU. But the restarted DU's
grant maps begin again at a low epoch on a new superframe count, and the
UE's `GrantMapReceiver` refused them as old. The UE kept its old map and
was never heard in the new grid (0 uplinks on the rig).

## What
- **`GrantMapReceiver::offer_after_restart(m, beacon_sf, window =
16)`**: a map at a **strictly** lower epoch than the newest accepted,
but due within 16 superframes of the beacon carrying it, starts the
receiver over.
- A stale map from before the restart is due far from the new count and
is still refused.
- The same epoch again is the live map repeated, which is not a restart.
A first rig run found this case.
  - Also adds a `newest_epoch()` getter.
- **UE:**
- the restart path is asked only for `CELL_UE_REATTACH_SF` (200)
superframes after the UE's clock fit confirms a step of the DU's TSF
(#535);
- on a re-attach it resets what belonged to the old DU instance, as a
handover switch does: DL HARQ space, MR generation (the DU checks it
against its new count), neighbour list, gap parking, and pending
handover IEs;
  - it logs `cell.ue.reattach`.
- **Logging fix:** the UE's `cell.dl.rx` `in_slot` now judges against
the DL slot of its grant in force, not the position matching its id. Any
CU move showed as DL out of slot (0.05), although the frames were right.
- **Docs:** `docs/cell-m6.md` gains a "Re-attachment after a DU restart"
section and a fix for the doubled DAPS heading left by #535. RACH-joined
UEs across a DU restart are listed as open.
- **Selftest (cell_m6):** refused when not near the beacon's count, a
pre-restart stale map, another cell's map, accepted and then running on,
never back to a higher epoch, and the live map repeated.

## Rig (testrig `scripts/m6_fitstep.sh`: DU1 killed and restarted under
its UE, 3/3 pass)
| KPI | value |
|---|---|
| re-attachments per run | 1 (old epoch 7–11 → epoch 1) |
| uplink heard by the restarted DU, from 10 sf after its service start |
99.8–100 % of superframes |
| of them in slot | 99.8 % |
| first in-slot uplink after service start | superframe 0 |
| UE DL in its granted slot after the re-attach | 100 % |

- **Detection off (counterfactual):** no re-attach, and the restarted DU
never hears the UE.
- **Regression:** same-channel handover 2/2 plus fallback 1/1,
measurement-gap handover 1/1, phase planning 1/1, transient TSF jump
3/3. No spurious re-attach in any of them.

**Residual:** grant maps carry no tag (they never did), so the epoch
rule orders maps; it doesn't authenticate them.

🤖 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