From 57050e4c6111d8b00c18ba7f725a426c836ef438 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Fri, 13 Nov 2026 04:18:58 +0300 Subject: [PATCH] cell M6 test benches: the UE's measured level per cell pinned (CELL_UE_MEAS_LEVEL) Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/cell-m6.md | 39 +++++++++++++++ examples/cell/cell.h | 92 +++++++++++++++++++++++++++++++++++- examples/cell/main.cpp | 33 +++++++++++-- tests/cell_node_selftest.cpp | 18 +++++++ 4 files changed, 178 insertions(+), 4 deletions(-) diff --git a/docs/cell-m6.md b/docs/cell-m6.md index 25bc7e6a..fbff0bf7 100644 --- a/docs/cell-m6.md +++ b/docs/cell-m6.md @@ -1621,6 +1621,45 @@ Regression with the checks on, 1 run of each, all pass: Apart from the replay runs, refusals were only the ~16 at the start of each UE (before its serving phase was known) plus the dead-serving case above. +## Rig suites independent of placement + +**What happened.** On 2026-11-12, between 11:20 and 16:00, the rig's RF +levels changed with no code change in between. UE1 now heard cell 1 at −50 +dBm (−63 before) and cell 2 at −51 (−53); UE2 heard cell 1 2 dB louder and +cell 2 5 dB quieter. The cause is not established. The suites that hand +over on a standing difference between cells lost their margin (+10 to +13 +dB at UE1): handover, measgap, three_ho, daps, daps_dl, daps_xch. They +failed alike on master and on a branch (an A/B run), correctly: with those +levels the A3 has nothing to fire on. The ramp-driven crossings still +passed. + +**The pin** (`CELL_UE_MEAS_LEVEL`, `MeasLevelPin` in `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, then shifts every measurement after +by (target − that median). The cell is held back from its measurement table +while it calibrates (`cell.ue.meas_pin`: the offset). Calibration settles +only on a steady window (8 samples within 6 dB), so a fade at the start +slides it on instead of fixing a wrong offset. A malformed spec stops the UE +at start, and the pinned levels are logged then (`cell.ue.meas_pin_cfg`). +**What the pin gives up:** a pinned suite tests the handover machinery on +its designed geometry, not the bench's actual levels; `M5_MEAS_LEVELS=""` +still runs those. The fluctuations and +any TX ramp still come through; only the standing level is the suite's. +The radio links themselves are untouched, so the bench still has to carry +the frames. + +**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: +- two cells: UE1 at −63 dBm (cell 1) and −52 dBm (cell 2); UE2 at −53 and + −38; +- three cells: UE1 −64/−51/−34, UE2 −53/−38/−38, UE3 −32/−50/−47; +- the crossings (TX ramps) are not pinned. `M5_MEAS_LEVELS=""` runs on the + bench's own levels. + +**Result:** on the changed rig every M6 suite passes again: handover, +measgap, three_ho, daps, daps_dl, daps_xch, crossing3, crossing. + ## Bugs the rig found **A DU with no UE did not pace its main loop.** Its superframe count ran away diff --git a/examples/cell/cell.h b/examples/cell/cell.h index ab26d4c8..69a99df8 100644 --- a/examples/cell/cell.h +++ b/examples/cell/cell.h @@ -40,14 +40,17 @@ #define DEVOURER_EXAMPLES_CELL_H #include -#include #include #include #include #include +#include #include +#include #include +#include #include +#include #include #include @@ -431,6 +434,93 @@ inline std::optional parse_dl_frame(const uint8_t *p, size_t len, uint1 return d; } +// A test bench's placement, emulated at the UE (CELL_UE_MEAS_LEVEL): for +// each listed cell, the median of its first `calib` beacon measurements is +// taken as where the bench put it, and every measurement after is shifted by +// (target - that median). The fluctuations and any TX ramp still come +// through; only the standing level is the bench's choice, so a suite's +// handover geometry does not depend on where the radios stand. The radio +// links themselves are not touched. A listed cell's measurements are held +// back while calibrating; an unlisted cell's pass unchanged. Calibration +// settles only on a steady window (its `calib` samples within `spread_db`): +// a fade or ramp in it slides the window on instead of fixing a wrong +// offset. Malformed items are kept in bad() (the node refuses to start). +// "2:-53,1:-63" cell 2 at -53 dBm, cell 1 at -63 dBm +class MeasLevelPin { +public: + explicit MeasLevelPin(const std::string &spec = {}, size_t calib = 8, double spread_db = 6.0) + : calib_(calib), spread_db_(spread_db) { + size_t i = 0; + while (i < spec.size()) { + const size_t comma = spec.find(',', i); + const std::string item = spec.substr(i, comma == std::string::npos ? std::string::npos : comma - i); + const size_t colon = item.find(':'); + if (colon != std::string::npos) { + char *e1 = nullptr, *e2 = nullptr; + const long c = std::strtol(item.c_str(), &e1, 10); + const double v = std::strtod(item.c_str() + colon + 1, &e2); + if (e1 == item.c_str() + colon && e2 && e2 != item.c_str() + colon + 1 && *e2 == 0 && c > 0 && c < 65536) + cells_[static_cast(c)] = Cell{v, {}, std::nullopt}; + else + bad_.push_back(item); + } else if (!item.empty()) { + bad_.push_back(item); + } + if (comma == std::string::npos) + break; + i = comma + 1; + } + } + bool active() const { return !cells_.empty(); } + // The measurement to use for `cell`: nullopt while that cell calibrates. + std::optional adjust(uint16_t cell, double rssi) { + auto it = cells_.find(cell); + if (it == cells_.end()) + return rssi; + Cell &c = it->second; + if (!c.offset) { + c.seen.push_back(rssi); + if (c.seen.size() > calib_) + c.seen.erase(c.seen.begin()); /* slide: the newest calib_ samples */ + if (c.seen.size() < calib_) + return std::nullopt; + const auto [lo, hi] = std::minmax_element(c.seen.begin(), c.seen.end()); + if (*hi - *lo > spread_db_) + return std::nullopt; /* not steady yet */ + std::vector v = c.seen; + std::nth_element(v.begin(), v.begin() + v.size() / 2, v.end()); + c.offset = c.target - v[v.size() / 2]; + c.seen.clear(); + } + return rssi + *c.offset; + } + // Items of the spec that did not parse ("cell:dBm"). + const std::vector &bad() const { return bad_; } + // The cells pinned and their targets (logged at start). + std::string describe() const { + std::string o; + for (const auto &kv : cells_) + o += (o.empty() ? "" : ",") + std::to_string(kv.first) + ":" + std::to_string(static_cast(kv.second.target)); + return o; + } + // The offset fixed for `cell` (logged), once calibrated. + std::optional offset(uint16_t cell) const { + auto it = cells_.find(cell); + return it == cells_.end() ? std::nullopt : it->second.offset; + } + +private: + struct Cell { + double target = 0; + std::vector seen; + std::optional offset; + }; + size_t calib_; + double spread_db_; + std::map cells_; + std::vector bad_; +}; + // A handover target's queue against the UE's DL status: an sn goes only if // the UE has it (its top, or within `window` below it and not pending) or it // is settled there (tracked, at or above `floor`, and older than its oldest diff --git a/examples/cell/main.cpp b/examples/cell/main.cpp index 4d23c8da..5101ad7e 100644 --- a/examples/cell/main.cpp +++ b/examples/cell/main.cpp @@ -100,6 +100,11 @@ // CELL_CS_RETUNE_US [26000] a channel move's retune budget (DU and UE): // the move needs it, + 2 ms, after every slot and // before the next beacon (ChannelSwitch.h) +// CELL_UE_MEAS_LEVEL [""] UE, test benches: "cell:dBm,..." — each listed +// cell's beacon measurements shifted so their +// median after calibration (8 beacons) is that +// level (MeasLevelPin): a suite's geometry +// independent of where the radios stand // CELL_RELEASE_SF [50] DU: release a JOINED UE whose uplink has been // absent this many superframes (static UEs stay) // CELL_GAP_SUSPEND_SF [0] DU and UEs alike: suspend gaps (MeasGap.h). A @@ -3283,6 +3288,18 @@ int run_ue(IRadio *dev, const Cfg &c) { int64_t gap_retune_us = gap_retune_floor_us; std::map gap_parked; int64_t no_step_until_sf = -1; /* hold TSF steps until this superframe (after a gap) */ + /* CELL_UE_MEAS_LEVEL: a test bench's placement, emulated (MeasLevelPin). */ + MeasLevelPin meas_pin(std::getenv("CELL_UE_MEAS_LEVEL") ? std::getenv("CELL_UE_MEAS_LEVEL") : ""); + if (!meas_pin.bad().empty()) { /* a typo must not leave a cell on its real level unnoticed */ + fprintf(stderr, "CELL_UE_MEAS_LEVEL: cannot parse \"%s\" (want cell:dBm,...)\n", meas_pin.bad().front().c_str()); + return 2; + } + if (meas_pin.active()) { /* everything this UE measures of these cells is shifted: say so */ + const std::string d = meas_pin.describe(); + char l[200]; + snprintf(l, sizeof l, "{\"ev\":\"cell.ue.meas_pin_cfg\",\"levels\":\"%.150s\"}", d.c_str()); + emit(l); + } devourer::cell::MeasTable ue_meas(devourer::cell::L3FilterConfig{ static_cast(envl("CELL_MEAS_L3_K", 4, 0, 19)), envl("CELL_MEAS_STALE_MS", 3000, 100, 60000)}); const int64_t gap_every_sf = envl("CELL_UE_GAP_SF", 10, 0, 100000); /* 0 = no gaps */ @@ -3456,9 +3473,19 @@ int run_ue(IRadio *dev, const Cfg &c) { } } } - if (nl) { - ue_meas.update(bc, 0, static_cast(p.RxAtrib.rssi[0]) - 110.0, - static_cast(p.RxAtrib.snr[0]) / 2.0 /* raw dB*2 */, ue_ms()); + std::optional lvl = static_cast(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(p.RxAtrib.snr[0]) / 2.0 /* raw dB*2 */, ue_ms()); /* The serving cell's list: only a generation not older than the * one accepted (a replayed older list is refused). */ if (bc == cell_a.load() && (!have_nbrs_gen || nl->gen >= nbrs_gen)) { diff --git a/tests/cell_node_selftest.cpp b/tests/cell_node_selftest.cpp index 5da67e84..b2eaf9ce 100644 --- a/tests/cell_node_selftest.cpp +++ b/tests/cell_node_selftest.cpp @@ -275,6 +275,24 @@ int main() { CHECK(gf.size() == 32 && std::find(gf.begin(), gf.end(), 101u) != gf.end() && gf.front() == 1 && gq == std::deque({32})); } + /* A bench's placement emulated (CELL_UE_MEAS_LEVEL): a listed cell held + * back for its calibration, then shifted to the target median; others + * unchanged; a malformed item ignored. */ + { + MeasLevelPin pin("2:-53,1:-63,x:5,3:-40", 3); + CHECK(pin.active() && !pin.offset(3).has_value()); + CHECK(pin.adjust(7, -70.0) == -70.0); /* unlisted */ + CHECK(!pin.adjust(2, -50.0) && !pin.adjust(2, -52.0)); /* calibrating */ + CHECK(pin.adjust(2, -48.0) == -53.0 + 2.0); /* median -50 -> offset -3 */ + CHECK(pin.offset(2) && *pin.offset(2) == -3.0 && pin.adjust(2, -60.0) == -63.0); + CHECK(!MeasLevelPin("").active() && !MeasLevelPin("2:abc").active()); + /* malformed items are reported, not silently dropped */ + CHECK(MeasLevelPin("2:-53x,1:-63,3,4:").bad() == std::vector({"2:-53x", "3", "4:"})); + /* a fade in the calibration window slides it on (spread > 6 dB) */ + MeasLevelPin fp("2:-50", 3); + CHECK(!fp.adjust(2, -40.0) && !fp.adjust(2, -60.0) && !fp.adjust(2, -61.0)); /* 21 dB spread */ + CHECK(fp.adjust(2, -60.0) == -50.0 && fp.adjust(2, -62.0) == -52.0); /* -60,-61,-60 steady: offset +10 */ + } /* A DAPS-visit DL answer: broadcast RA, the target identity in the body, * tagged with the target cell's key; not a unicast DL frame. */ {