Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/cell-m6.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 91 additions & 1 deletion examples/cell/cell.h
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,17 @@
#define DEVOURER_EXAMPLES_CELL_H

#include <algorithm>
#include <deque>
#include <array>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <deque>
#include <functional>
#include <map>
#include <optional>
#include <string>
#include <utility>
#include <vector>

Expand Down Expand Up @@ -431,6 +434,93 @@ inline std::optional<DlFrame> 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<uint16_t>(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<double> 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<double> 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<std::string> &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<int>(kv.second.target));
return o;
}
// The offset fixed for `cell` (logged), once calibrated.
std::optional<double> 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<double> seen;
std::optional<double> offset;
};
size_t calib_;
double spread_db_;
std::map<uint16_t, Cell> cells_;
std::vector<std::string> 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
Expand Down
33 changes: 30 additions & 3 deletions examples/cell/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -3283,6 +3288,18 @@ int run_ue(IRadio *dev, const Cfg &c) {
int64_t gap_retune_us = gap_retune_floor_us;
std::map<uint16_t, int64_t> 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<int>(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 */
Expand Down Expand Up @@ -3456,9 +3473,19 @@ int run_ue(IRadio *dev, const Cfg &c) {
}
}
}
if (nl) {
ue_meas.update(bc, 0, static_cast<double>(p.RxAtrib.rssi[0]) - 110.0,
static_cast<double>(p.RxAtrib.snr[0]) / 2.0 /* raw dB*2 */, ue_ms());
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());
/* 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)) {
Expand Down
18 changes: 18 additions & 0 deletions tests/cell_node_selftest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<uint32_t>({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<std::string>({"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. */
{
Expand Down
Loading