Skip to content

examples/cell: M1 reference DU / UE / witness node + 2-UE on-air test - #518

Merged
josephnef merged 3 commits into
masterfrom
feat/m1-cell-node
Nov 4, 2026
Merged

josephnef merged 3 commits into
masterfrom
feat/m1-cell-node

Conversation

@josephnef

Copy link
Copy Markdown
Collaborator

Part of #255 (M1) / #254. Builds on #516 (SlotGrid, GrantMap).

What it is

examples/cell is one binary, CELL_ROLE=du|ue|witness, running one DU and N UEs on the fixed slot grid. Details are in docs/cell-m1.md.

  • DU:
    • beacons the grant map (CW=0 hardware beacon, fixed departure);
    • sends one hardware-ACKed unicast DL frame per UE, submitted at that UE's DL slot start on the DU's TSF;
    • measures each UE uplink's arrival on its own TSF and runs a per-UE timing-advance loop, republishing the map 3 superframes ahead (the M0 update latency is 1–2 beacons).
  • UE:
    • fits the DU's TSF from the DU beacons against its MAC free-running counter (TsfSync); TSF writes never move that counter, so the fit survives the UE's own steps;
    • keeps its hardware-beacon uplink in its UL slot minus its TA by stepping its TSF, with read-back write compensation;
    • ACKs DL in hardware (SetAckResponder);
    • applies maps by epoch, effective superframe and cell (GrantMapReceiver).
  • witness: logs every cell frame. tests/cell_2ue_analyze.py maps them onto the DU clock.

Measured on the epic-254 rig

Four RTL8852CE radios over PCIe (DU, two UEs on other boxes, a witness), ch149, 120 s, 20 s TA settle:

KPI UE1 UE2
UL superframes hit (DU) 962/962 970/970
UL arrival error rms / p99 / max 1.8 / 6 / 13 µs 1.4 / 3 / 7 µs
DL received / sent, in slot (UE) 1156/1156, 100 % 1156/1156, 100 %
DL air start after slot start p50 / p99 (witness) 31 / 41 µs 31 / 40 µs

The witness saw 0 overlaps in 4837 frames, with beacons, DL and UL 100 % in their own slots. The verdict is pass.

Found on the way

Kestrel's TX descriptor has no no-ACK path. An unACKed unicast counts as a failure, so the data queue's CW climbs to CWmax and the next frame waits a uniform 0–1023 slots (0–9.2 ms).

  • Signature, from a DL-spacing sweep: the second DL frame never aired later than ~9.55 ms after the first, at any spacing from 0.4 to 8 ms.
  • That is why DL is hardware-ACKed here, with a retry limit (2) that the DL slot budget covers.

Tests

  • ctest cell_node: frame build/parse and rejects (including the 16-bit UE field, which the selftest caught being truncated), counter extension, the UE step rule and its TA sign convention, the TA loop.
  • ctest cell_2ue_analyze --selftest: an in-slot run passes; a UE uplink moved into the other UE's slot is flagged and counted as an overlap; a missed uplink and a DL retransmission are counted.
  • ctest 82/82 on the rig.

Open (in the doc)

  • Each UE still re-steps its TSF by ~3 µs on most 100 ms passes. Its crystal skew accounts for only ~0.7 µs of that; the cause is not established, and UL timing is unaffected.
  • A DU keeps scheduling a UE that has stopped uplinking. That belongs to M3 (admission).

🤖 Generated with Claude Code

One gNB-DU and N UEs on the src/cell fixed slot grid (#255):

- DU: beacons the grant map (CW=0 hardware beacon), sends one hardware-ACKed
  unicast DL frame per UE in its DL slot (submitted at the slot start on its
  TSF), measures each UE uplink's arrival on its TSF and closes a per-UE TA
  loop by republishing the map 3 superframes ahead.
- UE: fits the DU TSF from the DU beacons against its free-running counter
  (TsfSync; TSF writes never move that counter), keeps its own hardware-beacon
  uplink in its UL slot minus its TA by stepping its TSF (with read-back
  write compensation), ACKs DL in hardware (SetAckResponder), applies maps by
  epoch/effective superframe/cell (GrantMapReceiver).
- witness: logs every cell frame for tests/cell_2ue_analyze.py, which maps
  them onto the DU clock and reports in-slot ratios, overlaps, DL lateness and
  retransmissions alongside the DU's UL errors and the UEs' DL phases.

Rig (RTL8852CE PCIe x4 radios, ch149, 120 s): UL 962/962 and 970/970
superframes, arrival error 1.8 / 1.4 us rms, 100% in slot; DL 1156/1156 per
UE, 100% in slot, air start 31 us p50 / 41 us p99 after the slot start;
witness 0 overlaps over 4837 frames, every frame kind 100% in its own slot.
Found on the way: Kestrel has no no-ACK TX path, so an unACKed unicast
escalates the data queue's CW to CWmax (next frame waits 0-9.2 ms) — hence
hardware-ACKed DL. docs/cell-m1.md.

ctest: cell_node (cell.h: frames, counter extension, step rule, TA loop) and
cell_2ue_analyze (--selftest).

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

Add M1 reference cell node and two-UE on-air validation

✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Add a role-configurable DU, UE, and witness node for scheduled downlink and uplink.
Diagram

sequenceDiagram
    participant DU as DU node
    participant UE as UE nodes
    participant W as Witness
    participant A as Analyzer
    DU->>UE: Beacon grant map
    DU->>UE: Scheduled DL
    UE-->>DU: Beacon UL
    DU->>UE: Updated TA grant
    DU-->>A: UL and DL logs
    UE-->>A: DL phase logs
    W-->>A: Airframe logs
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Advertise the full grid in grant maps
  • ➕ UEs would not need matching local slot-duration configuration.
  • ➕ Grid changes could be distributed with the schedule.
  • ➖ Expands the on-air format and receiver validation.
  • ➖ Adds flexibility beyond this M1 fixed-grid demonstration.

Recommendation: Keep the existing fixed-grid approach for M1: it reuses SlotGrid and GrantMap without expanding their wire contract. Consider advertising slot dimensions if later milestones require runtime grid changes or independently configured UEs.

Files changed (6) +1464 / -0

Enhancement (2) +877 / -0
cell.hDefine cell frames and timing-control logic +299/-0

Define cell frames and timing-control logic

• Adds cell identities, beacon and downlink frame builders and parsers, counter extension, UE TSF-step calculations, and a per-UE timing-advance controller. Keeps these functions independent of radio hardware for headless testing.

examples/cell/cell.h

main.cppImplement DU, UE, and witness radio roles +578/-0

Implement DU, UE, and witness radio roles

• Adds one environment-configured executable that schedules hardware-ACKed downlink, places UE hardware-beacon uplink, and records witness frames. The DU republishes timing-advance grants; UEs synchronize to DU beacons and apply effective grant epochs.

examples/cell/main.cpp

Tests (2) +450 / -0
cell_2ue_analyze.pyAnalyze two-UE slot timing and on-air overlaps +297/-0

Analyze two-UE slot timing and on-air overlaps

• Combines DU, UE, and optional witness JSONL logs to report uplink and downlink KPIs, retransmissions, slot placement, and overlaps. Includes synthetic passing and failure-case selftests.

tests/cell_2ue_analyze.py

cell_node_selftest.cppCover cell wire formats and clock-control rules +153/-0

Cover cell wire formats and clock-control rules

• Tests frame round trips and malformed-frame rejection, including the full 16-bit on-air UE field. Also checks counter wrapping, TSF-step and timing-advance sign conventions, and controller behavior.

tests/cell_node_selftest.cpp

Documentation (1) +107 / -0
cell-m1.mdDocument the M1 grid, clock loops, and rig results +107/-0

Document the M1 grid, clock loops, and rig results

• Explains frame placement, synchronization, timing advance, operating requirements, and example commands. Records the two-UE measurements and outstanding limitations.

docs/cell-m1.md

Other (1) +30 / -0
CMakeLists.txtBuild the cell node and register headless tests +30/-0

Build the cell node and register headless tests

• Adds a non-Windows cell executable and registers C++ logic and Python analyzer selftests with CTest.

CMakeLists.txt

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

qodo-free-for-open-source-projects Bot commented Nov 4, 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. A failed update stops timing corrections ✓ Resolved
Description
run_du changes the in-memory grants and epoch before checking whether UpdateBeaconPayload
published them. If publication fails, UEs keep reporting the old epoch, so later TA samples are
discarded against the unpublished epoch and the failed update is never retried.

2. Short downlink slots can overrun ✓ Resolved
Description
grid_for checks whether configured slot lengths fit the superframe but never checks whether a
padded, hardware-ACKed DL frame and its retries fit a DL slot. A configuration with a 100 µs DL slot
and a large CELL_DL_PAD is accepted even though the frame's airtime alone exceeds that slot.

3. Boundary-crossing frames pass slot checks ✓ Resolved
Description
analyze assigns each witness frame to a slot using only its mid-air instant, even though it has
calculated the frame's airtime. A frame beginning before its DL slot or finishing after it is marked
in-slot whenever its midpoint is inside, allowing an otherwise clean run to pass that check.

View action required (1)
4. A silent UE can disappear from results ✓ Resolved
Description
analyze skips a supplied UE stream when it contains no cell.dl.rx records. If another UE has
valid receptions, the DL verdict checks only that UE and can pass while the silent UE has no DL
result at all.


Remediation recommended

5. Every DL frame costs two extra register reads ✓ Resolved
Description
run_du calls dev->ReadTsf() immediately before and after dev->send_packet(...) for every DL
frame, purely to fill the late_us and call_us log fields. These reads run once per UE per
superframe on the TX send path, so they add register-read latency to every downlink frame and grow
with each UE added to CELL_UES.

6. An uplink target can leave its slot ✓ Resolved
Description
cfg_from_env permits CELL_UL_TARGET_US up to 100,000 without comparing it with
CELL_UL_SLOT_US. When the target exceeds the UL slot length, run_du measures TA error against a
point in another slot or the idle region and can steer the UE toward that point.

7. Repeated UE IDs waste scheduled slots ✓ Resolved
Description
env_ues validates each CELL_UES entry's range but accepts repeated IDs. With CELL_UES=1,1, the
DU allocates and transmits in two DL slots for one UE while SlotGrid::find assigns both grant
entries the first matching DL and UL slots.

View review recommended (4)
8. Failed clock writes go unnoticed ✓ Resolved
Description
run_ue ignores the boolean result of WriteTsf and increments its step count regardless. A
transport failure can leave the counter unchanged or partially updated, yet the UE reports a step
and waits until its next 100 ms pass to attempt another correction.

9. An unusable witness log can pass ✓ Resolved
Description
analyze leaves its witness result unset when a supplied stream has fewer than eight usable DU
beacon calibration points. The verdict treats that unset result as success, so frames in the
supplied witness log are neither mapped nor checked for overlaps.

10. A final lost downlink is hidden ✓ Resolved
Description
analyze sets the DL submission window's end to the last frame the UE received, not the last frame
sent while it was listening. If the final submitted frame is lost, that submission is excluded and
the report can show equal sent and received counts despite the loss.

11. Witness overlap totals undercount pairs ✓ Resolved
Description
analyze compares each sorted air interval only with the immediately preceding interval. When one
long frame overlaps two later frames that do not overlap each other, only its overlap with the first
is counted, so the reported overlap total is too low.

Grey Divider

Tip of the day
💡 Did you know, you can commit Qodo's fix in one click with committable suggestions (GitHub & GitLab)

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

DU:
- The next grant map is staged and replaces the live one only once
  UpdateBeaconPayload succeeded; on failure the old epoch stays (UEs keep
  reporting it, sampling continues) and the TA loops are put back on its TAs
  (TaLoop::set_ta), so the next TA pass retries.
- Slot budgets are validated before the radio is opened: DU beacon airtime +
  22 us in the beacon slot, the DL frame's full budget (attempts from
  DEVOURER_TX_RETRY_LIMIT x (airtime + SIFS + ACK) + retry gaps + the
  CELL_DL_GUARD_US guard, default 500 us per M0) in a DL slot, and the UL
  target + uplink beacon airtime inside a UL slot.
- CELL_UES refuses duplicate ids.
- No register reads on the DL send path beyond the one that ends the slot
  wait (it is the submit stamp); call_us dropped.
UE:
- A failed WriteTsf is not counted as a step (cell.ue.step_fail; the next
  pass re-measures).
- cell.ue.done reports the DU superframes the UE was listening.
Analyzer:
- A witness frame is in its own slot only if its whole air interval is.
- Every UE the DU's grid schedules must have a DL result (a missing stream
  fails the DL verdict); DL sent is counted over the UE's own listening
  window, so a lost final frame shows.
- A supplied witness that cannot be mapped (< 8 DU beacons) is an error.
- --selftest covers each.

Rig re-run (120 s, ch149): UL 960/961 + 972/972, 1.7 / 1.4 us rms, in slot
100%; DL 1155/1155 per UE, in slot 100%; witness 0 overlaps, all kinds 100%
in their own slot, DL air start 23-24 us p50 after the slot start.

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

Copy link
Copy Markdown
Collaborator Author

Addressed all ten Qodo findings in 3b6440c:

  1. Failed update stops TA. The next map is staged and committed only after UpdateBeaconPayload succeeds. On failure the live epoch stays, so UEs keep reporting it and sampling continues. The TA loops are reset to the live TAs (TaLoop::set_ta), and the next TA pass retries.
  2. Short DL slots. Slot budgets are validated before the radio is opened:
    • a DL slot must hold the frame's full SlotBudget: DEVOURER_TX_RETRY_LIMIT+1 attempts × (airtime + SIFS + ACK), plus retry gaps, plus the CELL_DL_GUARD_US guard (500 µs default, from M0);
    • the beacon slot must hold the beacon's airtime + 22 µs;
    • a UL slot must hold the target + the uplink's airtime.
  3. Boundary-crossing frames. A witness frame is in its own slot only if its whole air interval is.
  4. Silent UE. The expected UEs come from the DU's grid. A missing or empty UE stream is an explicit error and fails the DL verdict.
  5. Per-frame register reads. The extra ReadTsfs around send_packet are gone. The read that ends the slot wait is the submit stamp. (DL air start improved from 31 to 23–24 µs p50.)
  6. UL target outside its slot. Covered by the budget check in (2): target + airtime ≤ UL slot.
  7. Repeated UE IDs. CELL_UES=1,1 is refused.
  8. Failed clock writes. A failed WriteTsf is not counted as a step. It emits cell.ue.step_fail, and the next pass re-measures.
  9. Unusable witness. A supplied witness with fewer than 8 DU beacons is an error and fails the verdict.
  10. Hidden final DL loss. cell.ue.done reports the UE's listening superframes, and DL sent is counted over that window.

The analyser --selftest covers 3, 4, 9 and 10. Rig re-run (120 s, ch149):

  • UL: 960/961 and 972/972 superframes, 1.7 / 1.4 µs RMS, 100% in slot.
  • DL: 1155/1155 per UE, 100% in slot.
  • Witness: 0 overlaps, all frame kinds 100% in their own slot.

ctest passes on all three boxes.

The witness overlap total compared each sorted air interval only with its
predecessor, so a long frame overlapping two later frames counted once. A
sweep over the frames still on air counts each pair; --selftest covers the
one-over-two, touching and three-way cases. The rig run still reads 0.

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

Copy link
Copy Markdown
Collaborator Author

Finding 11, overlap totals undercount pairs, is fixed in 2055b76. The witness overlap count is now a sweep over the frames still on air, so a long frame that overlaps two later frames counts both pairs. --selftest adds the one-over-two, touching and three-way cases. The 120 s rig run still reads 0 overlaps under the corrected count. (The macOS clang job on the previous push was cancelled at 15 min, not failed; it re-runs on this push.)

@josephnef

Copy link
Copy Markdown
Collaborator Author

Applying skip-qodo-gate. Qodo reviewed via issue comment, not a review object: all 11 findings are ✓ Resolved, with 0 new bugs, rule violations or requirement gaps. All other checks pass, including the macOS builds, which needed re-runs: GitHub hosted macOS arm64 capacity left them unassigned for hours ("The job was not acquired by Runner of type hosted").

@josephnef josephnef added the skip-qodo-gate Bypass the Qodo review gate (outage / maintainer decision) label Nov 4, 2026
@josephnef
josephnef merged commit 4ad09d6 into master Nov 4, 2026
144 of 153 checks passed
@josephnef
josephnef deleted the feat/m1-cell-node branch November 4, 2026 17:19
josephnef added a commit that referenced this pull request Nov 4, 2026
…on (#519)

Part of #256 (M2) / #254. This is the headless core. Wiring it into
`examples/cell` and the attenuated-UE rig test come next, on top of
#518.

**Why software HARQ.** Kestrel has no per-frame TX report (no CCX path),
so the DU cannot see a hardware ACK's outcome. On any chip, a no-ACK "TX
complete" proves only that the frame aired. So delivery is what the UE
reports.

- **`src/cell/Harq.h`:**
  - **`HarqFeedback`:** a cumulative `base` plus a 32-seq bitmap.
  - **`HarqSender`:**
    - each frame gets a 32-bit seq and a process id;
    - ACK means below base or a set bitmap bit;
- NACK means a later seq was received. It applies only to attempts at
least `nack_min_age_sf` older than the report, because a UE beacon
update lands 1–2 beacons late, so a fresh retransmission isn't NACKed by
a stale report;
    - timeout, attempt limit and drop;
    - due retransmissions go before new data, oldest first;
- the in-flight seq span stays below 32, so a report always covers every
in-flight frame.

Each frame carries the window low edge, so the receiver can abandon gaps
the sender dropped.
- **`HarqReceiver`:** duplicate suppression, base/bitmap upkeep,
catch-up after missing a low-edge move, and 32-bit wrap.
- **`src/cell/LinkAdapt.h`:** sets the grant's DL rate (6–54 Mb/s).
- It steps down one rate when the retransmission ratio reaches 20%, then
holds for 5 windows. If the UE-reported DL SNR no longer supports the
current rate, it drops straight to the best supported one.
- It steps up one rate after 3 clean windows, if the SNR reaches the
next rate's threshold + 2 dB.
- The thresholds are starting points; the loss rule corrects them on
air.
- **`tests/cell_m2_selftest.cpp`** (ctest `cell_m2`) covers the rules
above, plus a **30%-loss channel simulation**: HARQ delivers 400/400
exactly once, against 280/400 for the one-attempt baseline.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
josephnef added a commit that referenced this pull request Nov 4, 2026
… (rig: attenuated UE recovered) (#521)

Part of #256 (M2) / #254. This wires the merged M2 core (#519, `Harq.h`
+ `LinkAdapt.h`) into the M1 cell (#518). Details are in
`docs/cell-m2.md`.

**Downlink (DL) frames** carry the HARQ seq, process id, attempt number
and the window low edge.

**UE uplink beacon IE** carries the UE's `HarqFeedback` (base, bitmap,
and `gen_sf`, the superframe it was generated in) plus its mean DL SNR.
The UE republishes it whenever any of these change.

**DU (the cell):**
- retransmits from that feedback (`CELL_HARQ`, plus attempt and timeout
knobs);
- adapts each UE's DL rate (`CELL_LA`), publishes it in the grant, and
retries a rate whose map failed to publish;
- judges a link-adaptation window only when the UE was present for ≥ 75
% of its superframes, and for the window before too. Without this gate,
drops at the edges of a UE's presence stepped the control UE down on the
rig.

**Test knobs:** `CELL_ATTEN_UE` / `CELL_ATTEN_DB` apply a per-packet
radiotap power delta on one UE's DL, standing in for path loss.
`CELL_DL_RATE` sets the starting rate.

**Analyser:** `cell_2ue_analyze` gains a `harq` section: originated vs
delivered-as-new per UE, retransmissions, duplicates and rate steps. It
has `--selftest` cases.

## Measured on the rig
RTL8852CE over PCIe, ch149, 120 s per arm. DL starts at 54 Mb/s. UE1's
DL is sent 10 dB down; UE2 is the control.

| arm | UE1 delivered | UE1 rate | UE2 delivered | UE2 rate |
|---|---|---|---|---|
| baseline | 560 / 1156 (48 %) | 54 | 1155 / 1155 | 54 |
| HARQ | 481 / 547 (88 %) | 54 | 1156 / 1156 | 54 |
| HARQ + LA | **1136 / 1136 (100 %)** | 54 → 6 | **1154 / 1154** |
**54** |

The UL was 100 % in slot in every arm.

**Caveat found:** Kestrel's per-packet power is a global BB target, so
the next hardware beacon follows it (as `AdapterCaps.h` documents). The
test therefore attenuates the UE whose DL is *not* the last frame before
the DU's TBTT.

ctest passes 87/87 on the rig.

🤖 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