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);