From 7168c65597ebabda1af826829202e922e8f39c60 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:07:43 +0300 Subject: [PATCH] =?UTF-8?q?cell=20M1:=20per-UE=20state=20in=20the=20DU=20l?= =?UTF-8?q?ibrary=20=E2=80=94=20UeRegistry's=20UeContext=20(TA=20loop,=20u?= =?UTF-8?q?plink-quality=20window)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last open M1 deliverable: UeRegistry keeps each scheduled UE's context, its timing-advance loop (TaLoop, moved from examples/cell to src/cell/TimingAdvance.h) and its latest uplink-quality window (RSSI, SNR, EVM of its own frames, attributed per transmitter by UeRxAttribution, M0), created with a bind or an admission and dropped with a release (an id reused by another UE starts fresh; the DU's old TA map kept the departed UE's TA). The DU keeps its TA there instead of its own map and closes each UE's uplink window on its TA cadence (cell.ue_rx). Rig: m1_cell_2ue, m3_cell_rach --leave-at and m6_handover pass; 20 frames per 2 s window per UE, RSSI/SNR/EVM logged. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/cell-m1.md | 30 +++++++++++ examples/cell/cell.h | 58 ++------------------ examples/cell/main.cpp | 50 ++++++++++++----- src/cell/TimingAdvance.h | 81 ++++++++++++++++++++++++++++ src/cell/UeRegistry.h | 76 ++++++++++++++++++++++++++ src/cell/UeRxAttribution.h | 16 ++++-- tests/cell_m3_selftest.cpp | 42 +++++++++++++++ tests/ue_rx_attribution_selftest.cpp | 17 ++++++ 8 files changed, 298 insertions(+), 72 deletions(-) create mode 100644 src/cell/TimingAdvance.h diff --git a/docs/cell-m1.md b/docs/cell-m1.md index 1b97753b..8e13179e 100644 --- a/docs/cell-m1.md +++ b/docs/cell-m1.md @@ -90,6 +90,36 @@ clock. On the rig it is driven by testrig `scripts/m1_cell_2ue.sh`. schedule (Kestrel's TBTT behaviour after a TSF step), which is at most one step-size off. +## Per-UE state (`UeRegistry`) + +Each UE id the cell schedules has a `UeContext` in the registry +(`src/cell/UeRegistry.h`). It holds the UE's timing-advance loop +(`src/cell/TimingAdvance.h`, moved from the example) and its latest +uplink-quality window: RSSI, SNR and EVM of its own frames, attributed per +transmitter (`UeRxAttribution`, M0), not the device-wide `GetRxQuality`. +- A context comes with a bind or an admission, and is dropped with a + release. An id reused by another UE starts fresh; before, the DU's TA map + kept a departed UE's TA for the next holder. +- The DU closes the uplink window on its TA cadence (`CELL_TA_EVERY_S`) and + logs it per UE (`cell.ue_rx`). Link adaptation still steers on the SNR the + UE reports for the downlink; the uplink window is the per-UE measurement + the DU has of each UE, for whoever needs it (#566: EVM over F1). + +**Rig** (one run each, on this code): +- `m1_cell_2ue` passes: both UEs in slot both ways, no overlaps. The DU + logged 60 uplink windows per UE (2 s each). Full windows held 20 frames, + the median for both UEs; the edge windows were partial (UE1 9 at the end, + UE2 4 at the start), from a UE starting or stopping mid-window. Last full + windows: RSSI −51 / −53 dBm, SNR 38 / 39 dB, EVM −33 / −31 dB. +- `m3_cell_rach --leave-at` passes: the joiner's context was created at its + join and dropped with its release, 51 superframes after its last uplink. +- `m6_handover` passes (arrivals, and an aborted arrival's reset). +- **A reused id:** its frames carry the same transmitter address + (02:ce:11:cell:ue) as the previous holder's, so the first window after a + context begins may mix both; it is skipped (`rx_fresh`), at the cost of a + new UE's first window. Not run on the rig: a UE leaving and another taking + its id within one window. + ## QoS classes (`CellScheduler`) M1's grid is fixed (one DL and one UL opportunity per UE per superframe), diff --git a/examples/cell/cell.h b/examples/cell/cell.h index 69a99df8..e073e74d 100644 --- a/examples/cell/cell.h +++ b/examples/cell/cell.h @@ -57,6 +57,7 @@ #include "cell/GrantMap.h" #include "cell/Harq.h" #include "cell/SlotGrid.h" +#include "cell/TimingAdvance.h" namespace cellnode { @@ -723,61 +724,8 @@ inline int64_t ue_tsf_step(int64_t ue_tsf, int64_t du_tsf, int64_t ul_off, int64 return (err > deadband_us || err < -deadband_us) ? -err : 0; } -// DU timing-advance controller for one UE: the measured arrival error (DU -// clock, arrival phase minus target) is averaged over a window; at each -// update() the TA moves by -gain * mean, rounded to whole us, clamped to -// +-max_ta. Positive TA = the UE fires earlier. -class TaLoop { -public: - TaLoop(double gain, int64_t max_ta_us) : gain_(gain), max_(max_ta_us) {} - /* The window keeps the newest kMaxWindow samples (a loop that is never - * updated, e.g. CELL_TA=0, must not grow without bound). */ - static constexpr size_t kMaxWindow = 256; - void sample(int64_t err_us) { - if (win_.size() == kMaxWindow) - win_.erase(win_.begin()); - win_.push_back(err_us); - } - size_t pending() const { return win_.size(); } - // Drop the window without moving the TA (samples taken under an old map). - void discard() { win_.clear(); } - // Returns true when the TA changed. min_samples guards a noisy window. - // The window's MEDIAN is the estimate: one wild arrival (a UE's beacon - // before its first TSF step, a TBTT on the old schedule after one) must - // not steer the TA — a mean let a single 39 ms sample push a joining UE - // out of its slot on the rig. - bool update(size_t min_samples) { - if (win_.size() < min_samples || win_.empty()) - return false; - std::vector v; - v.swap(win_); - std::nth_element(v.begin(), v.begin() + static_cast(v.size() / 2), v.end()); - const double mean = static_cast(v[v.size() / 2]); /* the median */ - last_mean_ = mean; - const int64_t step = static_cast(std::llround(gain_ * mean)); - if (step == 0) - return false; - int64_t t = ta_ + step; // arrival late (mean > 0) -> fire earlier -> TA up - if (t > max_) - t = max_; - if (t < -max_) - t = -max_; - const bool changed = t != ta_; - ta_ = t; - return changed; - } - int64_t ta() const { return ta_; } - // Restore a TA (a map that carried a moved TA failed to publish). - void set_ta(int64_t t) { ta_ = t; } - double last_mean() const { return last_mean_; } - -private: - double gain_; - int64_t max_; - int64_t ta_ = 0; - std::vector win_; - double last_mean_ = 0; -}; +// The DU's per-UE timing-advance loop is src/cell/TimingAdvance.h (TaLoop). +using devourer::cell::TaLoop; } // namespace cellnode diff --git a/examples/cell/main.cpp b/examples/cell/main.cpp index 88c68754..314346d5 100644 --- a/examples/cell/main.cpp +++ b/examples/cell/main.cpp @@ -899,6 +899,7 @@ int run_du(IRadio *dev, const Cfg &c) { positions.push_back({static_cast(*grid->find(SlotKind::Downlink, static_cast(i))), static_cast(*grid->find(SlotKind::Uplink, static_cast(i)))}); devourer::cell::UeRegistry reg(positions); + reg.set_ta_config(ta_gain, P / 4); /* each UE's TA loop (its UeContext) */ /* Each UE's home identity (src/cell/Keys.h), recorded at its admission — * static, a RACH joiner's MAC, or the home a handover command carries — * and reported to the CU (F1 Report.homes) for the UE's handover context. @@ -1232,11 +1233,10 @@ int run_du(IRadio *dev, const Cfg &c) { emit(l); } - std::mutex mu; // guards ta, ue_epoch - std::map ta; + std::mutex mu; // guards reg's per-UE contexts (TA, uplink window), ue_epoch std::map ue_epoch; for (uint16_t ue : ues) - ta.emplace(ue, TaLoop(ta_gain, P / 4)); + reg.context(ue); /* Per-UE DL reliability state (guarded by mu): HARQ sender, link * adaptation, the UE-reported DL SNR over the current window, and the * HARQ counts at the last adaptation window. */ @@ -1748,12 +1748,14 @@ int run_du(IRadio *dev, const Cfg &c) { int64_t ta_now = 0; { std::lock_guard lk(mu); - auto it = ta.find(ub->ue); - if (it != ta.end()) { + if (const auto *cx = reg.find_context(ub->ue)) { if (ta_on) /* CELL_TA=0: no loop to feed */ - it->second.sample(err); - ta_now = it->second.ta(); + reg.context(ub->ue).ta.sample(err); + ta_now = cx->ta.ta(); } + /* its uplink quality, attributed to it (UeRegistry's window) */ + reg.rx_frame(p.Data.data(), p.Data.size(), p.RxAtrib.rssi[0], p.RxAtrib.snr[0], p.RxAtrib.evm[0], + p.RxAtrib.tsfl); ue_epoch[ub->ue] = ub->epoch; last_ul_sf[ub->ue] = pos.superframe; heard_ul_sf[ub->ue] = pos.superframe; @@ -1824,6 +1826,7 @@ int run_du(IRadio *dev, const Cfg &c) { emit(l); } auto next_ta = std::chrono::steady_clock::now() + std::chrono::seconds(ta_every_s); + auto next_rxw = next_ta; /* the per-UE uplink windows' cadence */ auto next_sync = std::chrono::steady_clock::now() + std::chrono::milliseconds(sync_loop_ms); int64_t sf = grid->at(static_cast(dev->ReadTsf())).superframe + 1; bool cu_alive_prev = !f1_on; @@ -2671,7 +2674,7 @@ int run_du(IRadio *dev, const Cfg &c) { continue; /* not yet (or another instant is being published, or no clock) */ set_position(g, hi->second.pos); next.grants.push_back(g); - ta.emplace(u.ue, TaLoop(ta_gain, P / 4)); + reg.context(u.ue); /* Fresh DL state (an id reused after another UE left keeps * nothing of it); its DL queue is the DAPS buffer: kept. */ dlue.insert_or_assign(u.ue, make_dlue(u.ue)); @@ -2691,7 +2694,7 @@ int run_du(IRadio *dev, const Cfg &c) { break; } next.grants.push_back(g); - ta.emplace(u.ue, TaLoop(ta_gain, P / 4)); + reg.context(u.ue); /* Fresh DL state; a joiner has no DL stream yet (the CU starts * one once this DU reports it): a queue left under its id is * another UE's. */ @@ -2776,7 +2779,7 @@ int run_du(IRadio *dev, const Cfg &c) { { std::lock_guard lk(mu); for (Grant &g : next.grants) { - TaLoop &l = ta.at(g.ue); + TaLoop &l = reg.context(g.ue).ta; // Only judge a UE running the newest map: its arrivals reflect it. const bool current = ue_epoch.count(g.ue) && ue_epoch[g.ue] == map.epoch; if (!current) @@ -2825,6 +2828,27 @@ int run_du(IRadio *dev, const Cfg &c) { } } } + /* Each UE's uplink quality over the window, from its own frames + * (UeRegistry; the SA is 02:ce:11::): closed on the TA cadence, + * whether or not the TA loop runs (CELL_TA=0). */ + if (std::chrono::steady_clock::now() >= next_rxw) { + next_rxw += std::chrono::seconds(ta_every_s); + std::lock_guard lk(mu); + const auto wins = reg.close_rx_window([&](const std::array &ta) -> uint16_t { + const bool ours = ta[0] == 0x02 && ta[1] == 0xce && ta[2] == 0x11 && + static_cast(ta[3] << 8 | ta[4]) == c.cell; + return ours ? ta[5] : 0; + }); + for (const auto &[ue, w] : wins) { + char l[220]; + snprintf(l, sizeof l, + "{\"ev\":\"cell.ue_rx\",\"ue\":%u,\"frames\":%u,\"rssi_dbm\":%d,\"snr_db\":%.1f," + "\"snr_min_db\":%.1f,\"snr_ok\":%d,\"evm_db\":%.1f,\"evm_ok\":%d}", + ue, w.frames, w.rssi_mean_dbm, w.snr_mean_db, w.snr_min_db, w.snr_valid ? 1 : 0, w.evm_mean_db, + w.evm_valid ? 1 : 0); + emit(l); + } + } /* A due F1 schedule: every UE's position, on its network-wide instant. */ std::optional f1_eff; if (f1_due) { @@ -2878,7 +2902,7 @@ int run_du(IRadio *dev, const Cfg &c) { if (changed && static_cast(sf) < map.effective_sf) { std::lock_guard lk(mu); for (const Grant &g : map.grants) - ta.at(g.ue).set_ta(g.ta_us); + reg.context(g.ue).ta.set_ta(g.ta_us); next = map; changed = false; to_release.clear(); @@ -2903,7 +2927,7 @@ int run_du(IRadio *dev, const Cfg &c) { } { std::lock_guard lk(mu); - ta.erase(ho_eff_ue); + reg.reset_context(ho_eff_ue); dlue.erase(ho_eff_ue); last_ul_sf.erase(ho_eff_ue); } @@ -3062,7 +3086,7 @@ int run_du(IRadio *dev, const Cfg &c) { * next TA pass retries. */ std::lock_guard lk(mu); for (const Grant &g : map.grants) - ta.at(g.ue).set_ta(g.ta_us); + reg.context(g.ue).ta.set_ta(g.ta_us); } const GrantMap &shown = ok ? map : next; char l[320]; diff --git a/src/cell/TimingAdvance.h b/src/cell/TimingAdvance.h new file mode 100644 index 00000000..2710409f --- /dev/null +++ b/src/cell/TimingAdvance.h @@ -0,0 +1,81 @@ +/* TimingAdvance.h — M1 (#255) the DU's per-UE timing-advance loop. + * + * A UE's uplink is its hardware beacon, steered through its TSF to the slot + * its grant gives it (examples/cell); the DU measures where each uplink + * actually arrives against that slot and moves the UE's TA, which the grant + * map carries back. The TA corrects clock and actuation phase, not + * propagation (indoors that is ns, below this design's µs). + * + * Moved from examples/cell/cell.h: per-UE state belongs to the DU library + * (src/cell/UeRegistry.h UeContext holds one per UE). Pure header; the + * cell_node ctest covers it. */ +#ifndef DEVOURER_CELL_TIMINGADVANCE_H +#define DEVOURER_CELL_TIMINGADVANCE_H + +#include +#include +#include +#include +#include + +namespace devourer::cell { + +// DU timing-advance controller for one UE: the measured arrival error (DU +// clock, arrival phase minus target) is averaged over a window; at each +// update() the TA moves by -gain * mean, rounded to whole us, clamped to +// +-max_ta. Positive TA = the UE fires earlier. +class TaLoop { +public: + TaLoop(double gain, int64_t max_ta_us) : gain_(gain), max_(max_ta_us) {} + /* The window keeps the newest kMaxWindow samples (a loop that is never + * updated, e.g. CELL_TA=0, must not grow without bound). */ + static constexpr size_t kMaxWindow = 256; + void sample(int64_t err_us) { + if (win_.size() == kMaxWindow) + win_.erase(win_.begin()); + win_.push_back(err_us); + } + size_t pending() const { return win_.size(); } + // Drop the window without moving the TA (samples taken under an old map). + void discard() { win_.clear(); } + // Returns true when the TA changed. min_samples guards a noisy window. + // The window's MEDIAN is the estimate: one wild arrival (a UE's beacon + // before its first TSF step, a TBTT on the old schedule after one) must + // not steer the TA — a mean let a single 39 ms sample push a joining UE + // out of its slot on the rig. + bool update(size_t min_samples) { + if (win_.size() < min_samples || win_.empty()) + return false; + std::vector v; + v.swap(win_); + std::nth_element(v.begin(), v.begin() + static_cast(v.size() / 2), v.end()); + const double mean = static_cast(v[v.size() / 2]); /* the median */ + last_mean_ = mean; + const int64_t step = static_cast(std::llround(gain_ * mean)); + if (step == 0) + return false; + int64_t t = ta_ + step; // arrival late (mean > 0) -> fire earlier -> TA up + if (t > max_) + t = max_; + if (t < -max_) + t = -max_; + const bool changed = t != ta_; + ta_ = t; + return changed; + } + int64_t ta() const { return ta_; } + // Restore a TA (a map that carried a moved TA failed to publish). + void set_ta(int64_t t) { ta_ = t; } + double last_mean() const { return last_mean_; } + +private: + double gain_; + int64_t max_; + int64_t ta_ = 0; + std::vector win_; + double last_mean_ = 0; +}; + +} /* namespace devourer::cell */ + +#endif /* DEVOURER_CELL_TIMINGADVANCE_H */ diff --git a/src/cell/UeRegistry.h b/src/cell/UeRegistry.h index 18191f1b..4de33dda 100644 --- a/src/cell/UeRegistry.h +++ b/src/cell/UeRegistry.h @@ -9,17 +9,30 @@ * one MAC two ids. release() frees a position (departure; the policy of when * is the caller's). * + * Per-UE state (M1): each UE id the cell schedules has a UeContext — its + * timing-advance loop (TimingAdvance.h) and its latest uplink-quality window, + * attributed per transmitter from the frames' own RSSI/SNR/EVM + * (UeRxAttribution.h; not the device-wide GetRxQuality). A context comes with + * a bind or an admission, is dropped with a release (an id reused by another + * UE keeps nothing of it), and context() creates one for an id the caller + * schedules otherwise (a handover arrival before its admission). The first + * uplink window after a context begins is skipped: it may hold the previous + * holder's frames, whose transmitter address (the id's) is the same. + * * Pure header; cell_m3 ctest covers it. */ #ifndef DEVOURER_CELL_UEREGISTRY_H #define DEVOURER_CELL_UEREGISTRY_H #include #include +#include #include #include #include #include "cell/SlotGrid.h" +#include "cell/TimingAdvance.h" +#include "cell/UeRxAttribution.h" namespace devourer::cell { @@ -29,6 +42,16 @@ struct UeSlot { uint8_t ul_slot = 0; }; +/* A scheduled UE's per-UE state. */ +struct UeContext { + TaLoop ta; + UeRxWindow rx{}; /* the latest closed uplink-quality window */ + bool have_rx = false; + /* The first window closed after the context began may hold a previous + * holder's frames (the transmitter address is the id's): skipped. */ + bool rx_fresh = true; +}; + class UeRegistry { public: /* positions[i] = the DL/UL slot indices of grid position i + 1. At most @@ -42,6 +65,51 @@ class UeRegistry { owner_.resize(pos_.size()); } + /* The TA loop each new context starts with (gain, +-max us). */ + void set_ta_config(double gain, int64_t max_us) { + ta_gain_ = gain; + ta_max_ = max_us; + } + /* A UE's context; created (fresh) if it has none. */ + UeContext &context(uint16_t ue) { + auto it = ctx_.find(ue); + if (it == ctx_.end()) + it = ctx_.emplace(ue, fresh()).first; + return it->second; + } + const UeContext *find_context(uint16_t ue) const { + auto it = ctx_.find(ue); + return it == ctx_.end() ? nullptr : &it->second; + } + /* Start a UE's state over (a new holder of the id, or an aborted arrival). */ + void reset_context(uint16_t ue) { ctx_.insert_or_assign(ue, fresh()); } + /* Uplink frames: attribute each to its transmitter (addr2). */ + bool rx_frame(const uint8_t *mpdu, size_t len, int rssi_raw, int snr_raw, int evm_raw, uint32_t tsfl) { + return rx_.add_mpdu(mpdu, len, rssi_raw, snr_raw, evm_raw, tsfl); + } + /* Close the uplink-quality window: each transmitter `ue_of` maps to a UE id + * (0: not one of ours) that still has a context gets the window in it. + * Returns those (ue, window), in id order. */ + std::vector> close_rx_window( + const std::function &)> &ue_of) { + std::vector> out; + for (const UeRxWindow &w : rx_.snapshot().ues) { + const uint16_t ue = ue_of(w.ta); + auto c = ue ? ctx_.find(ue) : ctx_.end(); + if (c == ctx_.end()) + continue; /* not ours, or released meanwhile: never recreated by its old frames */ + if (c->second.rx_fresh) { + c->second.rx_fresh = false; + continue; + } + c->second.rx = w; + c->second.have_rx = true; + out.emplace_back(ue, w); + } + std::sort(out.begin(), out.end(), [](const auto &a, const auto &b) { return a.first < b.first; }); + return out; + } + size_t capacity() const { return pos_.size(); } size_t size() const { return by_mac_.size(); } @@ -55,6 +123,7 @@ class UeRegistry { if (!owner_[i]) { owner_[i] = mac; by_mac_[mac] = i; + reset_context(static_cast(i + 1)); return slot_of(i); } return std::nullopt; @@ -66,6 +135,7 @@ class UeRegistry { auto it = by_mac_.find(mac); if (it == by_mac_.end()) return false; + ctx_.erase(static_cast(it->second + 1)); owner_[it->second].reset(); by_mac_.erase(it); return true; @@ -77,6 +147,7 @@ class UeRegistry { return false; by_mac_.erase(*owner_[ue - 1]); owner_[ue - 1].reset(); + ctx_.erase(ue); return true; } @@ -102,6 +173,11 @@ class UeRegistry { std::vector> pos_; std::vector>> owner_; std::map, size_t> by_mac_; + UeContext fresh() const { return UeContext{TaLoop(ta_gain_, ta_max_), {}, false, true}; } + double ta_gain_ = 0.5; + int64_t ta_max_ = 25600; + std::map ctx_; + UeRxAttribution rx_; }; } /* namespace devourer::cell */ diff --git a/src/cell/UeRxAttribution.h b/src/cell/UeRxAttribution.h index 91c5c848..ee605a9e 100644 --- a/src/cell/UeRxAttribution.h +++ b/src/cell/UeRxAttribution.h @@ -53,6 +53,7 @@ struct UeRxWindow { int rssi_max_dbm = 0; double snr_mean_db = 0.0; double snr_min_db = 0.0; + bool snr_valid = false; /* the SNR fields, folded only from frames that carried one */ double evm_mean_db = 0.0; /* 0 when evm_valid is false */ bool evm_valid = false; double noise_floor_dbm = 0.0; /* passive: mean rssi_dbm - snr_db */ @@ -93,9 +94,12 @@ class UeRxAttribution { e->rssi_sum += rssi_raw; if (rssi_raw > e->rssi_max) e->rssi_max = rssi_raw; - e->snr_sum += snr_raw; - if (snr_raw < e->snr_min) - e->snr_min = snr_raw; + if (snr_raw != 0) { /* 0 = no SNR measured: not a sample */ + e->snr_sum += snr_raw; + ++e->snr_n; + if (snr_raw < e->snr_min) + e->snr_min = snr_raw; + } if (evm_raw != 0) { e->evm_sum += evm_raw; ++e->evm_n; @@ -130,8 +134,11 @@ class UeRxAttribution { if (e.n) { w.rssi_mean_dbm = static_cast(e.rssi_sum / static_cast(e.n)) - 110; w.rssi_max_dbm = e.rssi_max - 110; - w.snr_mean_db = (e.snr_sum / static_cast(e.n)) / 2.0; + } + if (e.snr_n) { + w.snr_mean_db = (e.snr_sum / static_cast(e.snr_n)) / 2.0; w.snr_min_db = e.snr_min / 2.0; + w.snr_valid = true; } if (e.evm_n) { w.evm_mean_db = (e.evm_sum / static_cast(e.evm_n)) / 2.0; @@ -157,6 +164,7 @@ class UeRxAttribution { int64_t rssi_sum = 0; int rssi_max = -128; int64_t snr_sum = 0; + uint32_t snr_n = 0; int snr_min = 127; int64_t evm_sum = 0; uint32_t evm_n = 0; diff --git a/tests/cell_m3_selftest.cpp b/tests/cell_m3_selftest.cpp index 861121d9..b216e164 100644 --- a/tests/cell_m3_selftest.cpp +++ b/tests/cell_m3_selftest.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -383,6 +384,47 @@ int main() { joined, denied, (unsigned long long)sf, (unsigned long long)collisions); } + /* --- M1 per-UE contexts: TA loop and uplink window, with the binding. --- */ + { + UeRegistry r({{1, 5}, {2, 6}}); + r.set_ta_config(0.5, 1000); + const std::array a{2, 0, 0, 0, 0, 1}, b{2, 0, 0, 0, 0, 2}; + CHECK(r.admit(a) && !r.find_context(2)); + UeContext &ca = r.context(1); + for (int i = 0; i < 3; ++i) + ca.ta.sample(400); /* arrivals 400 us late */ + CHECK(ca.ta.update(3) && r.context(1).ta.ta() == 200); /* 0.5 * median */ + CHECK(r.admit(a) && r.context(1).ta.ta() == 200); /* a re-join keeps it */ + CHECK(r.release(a) && !r.find_context(1)); /* a release drops it */ + CHECK(r.admit(b) && r.context(1).ta.ta() == 0); /* the id reused: fresh */ + r.context(7).ta.set_ta(5); /* scheduled otherwise: created */ + r.reset_context(7); + CHECK(r.context(7).ta.ta() == 0); + /* the uplink window, attributed per transmitter, mapped to UE ids */ + uint8_t f1[24] = {0x80}, f2[24] = {0x80}; + const uint8_t sa1[6] = {0x02, 0xce, 0x11, 0, 5, 1}, sa9[6] = {0x02, 0xce, 0x11, 0, 9, 3}; + std::memcpy(f1 + 10, sa1, 6); + std::memcpy(f2 + 10, sa9, 6); + /* the first window after the context began is skipped (it may mix holders) */ + CHECK(r.rx_frame(f1, sizeof f1, 50, 30, -30, 0)); + CHECK(r.close_rx_window([](const std::array &t) -> uint16_t { return t[4] == 5 ? t[5] : 0; }) + .empty() && + !r.find_context(1)->have_rx); + CHECK(r.rx_frame(f1, sizeof f1, 60, 40, -30, 1) && r.rx_frame(f1, sizeof f1, 70, 50, -30, 2)); + CHECK(r.rx_frame(f2, sizeof f2, 80, 50, 0, 3)); /* another cell's UE */ + auto wins = r.close_rx_window([](const std::array &t) -> uint16_t { + return t[4] == 5 ? t[5] : 0; /* cell 5's UEs only */ + }); + CHECK(wins.size() == 1 && wins[0].first == 1 && wins[0].second.frames == 2); + CHECK(r.find_context(1)->have_rx && r.find_context(1)->rx.rssi_mean_dbm == 65 - 110 && + r.find_context(1)->rx.evm_valid); + CHECK(r.close_rx_window([](const std::array &) -> uint16_t { return 1; }).empty()); /* drained */ + /* a UE released before the window closes is not recreated by its frames */ + CHECK(r.rx_frame(f1, sizeof f1, 60, 40, -30, 4) && r.release(b) && !r.find_context(1)); + CHECK(r.close_rx_window([](const std::array &t) -> uint16_t { return t[5]; }).empty() && + !r.find_context(1)); + } + if (failures == 0) std::printf("cell_m3_selftest: all checks passed\n"); return failures == 0 ? 0 : 1; diff --git a/tests/ue_rx_attribution_selftest.cpp b/tests/ue_rx_attribution_selftest.cpp index 19f2765e..d370848a 100644 --- a/tests/ue_rx_attribution_selftest.cpp +++ b/tests/ue_rx_attribution_selftest.cpp @@ -142,6 +142,23 @@ int main() { "evicted TA admitted in the next window"); } + // A frame without an SNR (0) is not an SNR sample: it neither drags the + // mean down nor sets the minimum to 0. + { + devourer::cell::UeRxAttribution attr; + const uint8_t ta[6] = {0x02, 0xce, 0x11, 0x00, 0x05, 0x01}; + attr.add(ta, 60, 40, 0, 1); // 20 dB + attr.add(ta, 60, 0, 0, 2); // no SNR + attr.add(ta, 60, 30, 0, 3); // 15 dB + auto s = attr.snapshot(); + CHECK(s.ues.size() == 1 && s.ues[0].frames == 3, "three frames"); + CHECK(s.ues[0].snr_valid && std::fabs(s.ues[0].snr_mean_db - 17.5) < 1e-9, "snr mean over the two that had one"); + CHECK(std::fabs(s.ues[0].snr_min_db - 15.0) < 1e-9, "snr min not 0"); + attr.add(ta, 60, 0, 0, 4); + auto s2 = attr.snapshot(); + CHECK(s2.ues.size() == 1 && !s2.ues[0].snr_valid && s2.ues[0].snr_min_db == 0.0, "no SNR at all: not valid"); + } + std::printf(g_fail ? "ue_rx_attribution_selftest: %d FAILURE(S)\n" : "ue_rx_attribution_selftest: all passed\n", g_fail);