Skip to content

cell M6 test benches: placement-independent rig suites (CELL_UE_MEAS_LEVEL) - #568

Merged
josephnef merged 1 commit into
masterfrom
feat/m6-meas-pin
Nov 13, 2026
Merged

josephnef merged 1 commit into
masterfrom
feat/m6-meas-pin

Conversation

@josephnef

Copy link
Copy Markdown
Collaborator

Part of #254 (M6 regression).

Why. On 2026-11-12, between 11:20 and 16:00, the rig's RF levels changed with no code change in between. UE1 now hears cell 1 at −50 dBm (−63 before) and cell 2 at −51 (−53); UE2's levels moved by 2–5 dB. The cause is not established. The M6 suites that hand over on a standing difference lost their margin (+10 to +13 dB): handover, measgap, three_ho, daps, daps_dl and daps_xch failed, identically on master (A/B on the rig). With those levels the A3 correctly has nothing to fire on.

What. CELL_UE_MEAS_LEVEL="cell:dBm,..." (MeasLevelPin, examples/cell/cell.h). For each listed cell, the UE takes the median of its first 8 beacon measurements as where the bench put it, and shifts every later measurement by (target − median). The offset is logged (cell.ue.meas_pin); the cell is held out of the measurement table while it calibrates.

  • Fluctuations and TX ramps still come through; only the standing level is the suite's.
  • The radio links themselves are untouched: the bench still carries the frames.
  • It's a test-bench emulation in the example node, like CELL_ATTEN_* and CELL_DU_TX_RAMP; not library behaviour.
  • Selftest in cell_node.

testrig (committed there): m5_ran_coord.sh takes M5_MEAS_LEVELS ("UE=cell:dBm,...;UE=..."). Each standing-difference M6 suite defaults it to the medians of its passing runs: two cells UE1 −63/−52, UE2 −53/−38; three cells per UE. The ramp-driven crossings are not pinned, and M5_MEAS_LEVELS="" runs on the bench's own levels.

Rig, on the changed levels: all 8 M6 suites pass (handover, measgap, three_ho, daps, daps_dl, daps_xch, crossing3, crossing). On this exact build (master with #567, plus this), handover and measgap passed again. The calibrated offsets show the change: UE1's cell 1 is pulled −13 dB, the others by −2 to +6.

🤖 Generated with Claude Code

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

Copy link
Copy Markdown

PR Summary by Qodo

Pin UE beacon measurements for placement-independent M6 rig tests

🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation 🕐 10-20 Minutes

Grey Divider

AI Description

• Add optional per-cell measurement targets so rig placement changes do not remove handover margins.
• Calibrate against initial beacons while preserving later fluctuations and leaving radio links
 unchanged.
• Test the pinning behavior and document the M6 rig results and suite settings.
Diagram

graph TD
  Beacon["Received beacon"] --> Valid{"Valid cell list?"} -->|yes| Pin["Measurement pin"] -->|calibrated| Table["UE measurement table"] --> Handover["Handover decisions"]
  Config["CELL_UE_MEAS_LEVEL"] --> Pin
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Control levels through rig RF attenuation
  • ➕ Exercises the physical links at the desired levels.
  • ➖ Requires hardware control and calibration; remains sensitive to placement changes.
2. Replace measurements with fixed targets
  • ➕ Simpler, deterministic target levels.
  • ➖ Suppresses fluctuations and TX ramps that the M6 tests need to observe.

Recommendation: Keep calibration in the example UE: it stabilizes the standing level without changing radio links or masking ramps. The other approaches either depend on rig hardware or discard useful measurement variation.

Files changed (4) +132 / -4

Enhancement (2) +87 / -4
cell.hAdd per-cell beacon measurement calibration +67/-1

Add per-cell beacon measurement calibration

• Introduces MeasLevelPin to parse target levels, withhold configured cells during initial calibration, and apply a median-derived offset thereafter. Unlisted cells pass through unchanged.

examples/cell/cell.h

main.cppApply optional measurement pin before UE table updates +20/-3

Apply optional measurement pin before UE table updates

• Reads CELL_UE_MEAS_LEVEL in the example UE and applies the pin to accepted beacon measurements before updating the measurement table. Emits a cell.ue.meas_pin event when each configured cell finishes calibration.

examples/cell/main.cpp

Tests (1) +12 / -0
cell_node_selftest.cppCover calibration, passthrough, and invalid specifications +12/-0

Cover calibration, passthrough, and invalid specifications

• Adds selftest assertions for withheld calibration samples, the resulting offset, unchanged unlisted cells, and ignored malformed input.

tests/cell_node_selftest.cpp

Documentation (1) +33 / -0
cell-m6.mdDocument placement drift and M6 measurement targets +33/-0

Document placement drift and M6 measurement targets

• Records the RF-level shift, its effect on standing-difference handovers, and how measurement pinning works. Describes testrig defaults, the unpinned crossing suites, and reported rig results; the testrig scripts themselves are not in this diff.

docs/cell-m6.md

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

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

Copy link
Copy Markdown

Code Review by Qodo

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

Grey Divider


Remediation recommended

1. M6 suites pass by construction on tuned levels 🐞 Bug ≡ Correctness
Description
The suite defaults for M5_MEAS_LEVELS are the medians of each suite's own passing runs, so the
pinned inputs are chosen from the outputs the suites are meant to check. Because the pin overrides
the RF levels the handover logic sees, a regression in the A3 or handover path that only shows up at
the real levels is hidden, and the claimed all-8-suites-pass result does not establish that the
handover logic works.
Code

docs/cell-m6.md[R1645-1647]

+**The suites** (testrig): `m5_ran_coord.sh` passes `M5_MEAS_LEVELS`
+("UE=cell:dBm,...;UE=...") to each UE, and every M6 suite whose geometry is
+a standing difference defaults it to the medians of its passing runs:
Evidence
The PR says each standing-difference suite defaults M5_MEAS_LEVELS to the medians of its passing
runs, then presents the passing suites as the result. Those inputs are fitted to the expected
outputs, so a pass only shows the pin reproduces the fitted levels. The real-level failures
described in the PR (handover, measgap, three_ho, daps and related suites failing on master and on
the branch) are no longer detectable under the default configuration.

docs/cell-m6.md[1645-1655]
examples/cell/main.cpp[3291-3293]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The M6 suites default `M5_MEAS_LEVELS` to medians taken from their own passing runs, and the docs present the resulting passes as validation. This makes the pass results circular and hides regressions that depend on the real levels.
## Fix Focus Areas
- docs/cell-m6.md[1645-1655]
- examples/cell/main.cpp[3291-3293]
## Recommended Fix
Do not default the pins in the suites that gate the build. Run the pinned configuration as a separate, labelled check. Keep at least one run on the bench's own levels in the reported results, and state in the doc that pinned passes validate the handover code path only under the assumed placement.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Malformed pin entries are silently dropped 🐞 Bug ◔ Observability
Description
In the MeasLevelPin constructor, an entry whose dBm value does not parse fully (for example
2:-53x) or that has no colon is skipped without any log or error. A typo in CELL_UE_MEAS_LEVEL
leaves that cell on its real levels while the bench is believed to be pinned, and the only sign is
the absence of a cell.ue.meas_pin event.
Code

examples/cell/cell.h[R458-459]

+        if (e1 == item.c_str() + colon && e2 && *e2 == 0 && c > 0 && c < 65536)
+          cells_[static_cast<uint16_t>(c)] = Cell{v, {}, std::nullopt};
Evidence
The validity check e2 && *e2 == 0 && c > 0 gates the insert into cells_, and the failing path
does nothing. The constructor has no logging or return signal, so a rejected entry leaves the cell
unpinned and the run looks valid until someone notices the missing meas_pin event.

examples/cell/cell.h[450-464]
examples/cell/main.cpp[3291-3293]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`MeasLevelPin` silently ignores entries it cannot parse, so a bad `CELL_UE_MEAS_LEVEL` leaves that cell on its real levels without any warning.
## Fix Focus Areas
- examples/cell/cell.h[450-464]
## Recommended Fix
When an entry fails to parse, emit a diagnostic naming the bad entry, or make the constructor report an error so `run_ue` refuses to start when `CELL_UE_MEAS_LEVEL` is set but has invalid entries.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

3. Pinned RSSI looks like a real measurement downstream 🐞 Bug ◔ Observability
Description
run_ue feeds the shifted value into ue_meas.update and the measurement reports with no marker,
so the CU and handover logic receive offset values that look like real RF measurements. The offset
is logged only once at calibration, and nothing in the measurement report or the A3 path records
that a value was pinned.
Code

examples/cell/main.cpp[R3466-3478]

+          std::optional<double> lvl = static_cast<double>(p.RxAtrib.rssi[0]) - 110.0;
+          if (nl && meas_pin.active()) {
+            const bool was = meas_pin.offset(bc).has_value();
+            lvl = meas_pin.adjust(bc, *lvl);
+            if (!was && meas_pin.offset(bc)) {
+              char l[120];
+              snprintf(l, sizeof l, "{\"ev\":\"cell.ue.meas_pin\",\"ue\":%d,\"cell\":%u,\"offset_db\":%.1f}",
+                       me_a.load(), bc, *meas_pin.offset(bc));
+              emit(l);
+            }
+          }
+          if (nl && lvl) {
+            ue_meas.update(bc, 0, *lvl, static_cast<double>(p.RxAtrib.snr[0]) / 2.0 /* raw dB*2 */, ue_ms());
Evidence
The shifted lvl goes straight into ue_meas.update, and the measurement report carries only RSSI,
SNR and age. A consumer of a report or a handover decision cannot tell a pinned value from a real
one, so logs from a pinned run cannot show which measurements were synthetic.

examples/cell/main.cpp[3466-3478]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Pinned RSSI values reach the measurement table and the CU's measurement reports without any flag, so downstream consumers cannot tell synthetic levels from real ones.
## Fix Focus Areas
- examples/cell/main.cpp[3466-3478]
## Recommended Fix
Add a `pinned` field or the offset to the measurement report entries for pinned cells, or write a per-run summary event listing the active pins, so logs from a pinned run cannot be mistaken for a bench run.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Calibration can lock in a transient offset ✓ Resolved
Description
MeasLevelPin::adjust takes the median of the first 8 beacon measurements and stores that offset
for the whole run with no recalibration. A TX ramp or fade during those 8 beacons shifts every later
reported RSSI for that cell by the same wrong amount.

Grey Divider

Tip of the day
💡 Did you know, you can start a comment with 'qodo' or '@qodo' to chat about any finding

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

…E_MEAS_LEVEL)

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

Copy link
Copy Markdown
Collaborator Author

Review fixes pushed:

  1. "Pass by construction": not changed in code, but stated in the docs. The pin sets each suite's input geometry, the levels it was designed on. The A3, the two-phase command, the switch, DAPS and the KPIs still have to work for a suite to pass, so a regression in any of them still fails it. What the pinned suites give up is coverage of other levels, which the docs now say outright. M5_MEAS_LEVELS="" runs any suite on the bench's own levels.
  2. Malformed entries dropped silently: fixed. Unparsable items are collected (bad()) and the UE refuses to start, naming the first one. Test: 2:-53x,1:-63,3,4: → 3 bad items.
  3. Pinned RSSI looks real downstream: the UE now logs its whole pin configuration at start (cell.ue.meas_pin_cfg), and each cell's offset at calibration (cell.ue.meas_pin). A wire marker in the MR isn't added: this is a test-bench emulation in the example node, like CELL_ATTEN_* / CELL_DU_TX_RAMP, and never set outside a rig suite.
  4. Calibration locking in a transient: fixed. It settles only on a steady window (8 samples within 6 dB, sliding), so a fade or ramp at the start delays calibration instead of fixing a wrong offset. Test: a 21 dB spread is refused, then a steady window calibrates.

Rig on this build: measgap and handover pass (config logged in every run).

@josephnef

Copy link
Copy Markdown
Collaborator Author

Qodo's re-review resolves #4. Of the rest:

Adding skip-qodo-gate.

@josephnef josephnef added the skip-qodo-gate Bypass the Qodo review gate (outage / maintainer decision) label Nov 13, 2026
@josephnef
josephnef merged commit c87af7d into master Nov 13, 2026
30 of 31 checks passed
@josephnef
josephnef deleted the feat/m6-meas-pin branch November 13, 2026 01:30
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