diff --git a/docs/cell-m6.md b/docs/cell-m6.md index fbff0bf7..3f603d7d 100644 --- a/docs/cell-m6.md +++ b/docs/cell-m6.md @@ -8,8 +8,8 @@ is no scan, no clock re-acquisition and no association exchange. | piece | where | what | |---|---|---| -| filtered measurements | `src/cell/Handover.h` `L3Filter`, `MeasTable` | 3GPP L3 filter per (cell, UE) over uplink RSSI/SNR. Stale = absent, never weak. | -| measurement reports | F1 `Report.meas` / `heard` | each DU's own UEs and the other cells' UEs it hears, with sample ages | +| filtered measurements | `src/cell/Handover.h` `L3Filter`, `MeasTable` | 3GPP L3 filter per (cell, UE) over uplink RSSI/SNR, and EVM from the frames that carry one. Stale = absent, never weak. | +| measurement reports | F1 `Report.meas` / `heard` | each DU's own UEs and the other cells' UEs it hears, with sample ages; EVM since F1 v16 (none = a sentinel) | | A3 (CHO) condition | `Handover.h` `A3Trigger`; `tools/ran-controller` | offset, hysteresis, per-neighbour time-to-trigger, dwell | | the command | `src/cell/HoCommand.h` `HoCommand`, F1 `Type::Handover` | UE, target id and position, switch instant, TSF delta | | the UE's copy | `HoIe` in the source DU's beacon | CRC and cell-key tag (forgery is inert); `HoReceiver` rejects a replay | @@ -1741,4 +1741,10 @@ replay checks caught it in the signed-list run: UE2 reported cell 1 in only unauthenticated development cell. Their tags were zeros and were not checked; F1 was keyed throughout. The testrig scripts now give every cell role one key. The suite re-run keyed is in the next section. -- **EVM:** not in the measurement reports. +- **EVM is reported, not used by the A3** (F1 v16; the CU logs it last in + each `ran.meas` tuple). The A3 compares cells: a neighbour's margin over + the serving cell, on RSSI or SNR, which both DUs measure on the same + frames. EVM tracks SNR at moderate levels and saturates at high SNR (the + rig's uplinks sit at −30 to −33 dB EVM with SNR 38–39 dB), so it adds no + ranking between cells there. It is per-link quality for the scheduler + and link adaptation, not a handover metric. diff --git a/examples/cell/main.cpp b/examples/cell/main.cpp index 314346d5..ca7eaf52 100644 --- a/examples/cell/main.cpp +++ b/examples/cell/main.cpp @@ -1544,6 +1544,13 @@ int run_du(IRadio *dev, const Cfg &c) { send_f1(devourer::cell::f1::encode_uplink(u, f1_mac)); }; const int64_t ho_release_sf = envl("CELL_HO_RELEASE_SF", 20, 3, 100000); + /* A frame's EVM (dB, negative), if the radio measured one (raw: dB*2, 0 = + * none; the same convention as UeRxAttribution). */ + auto frame_evm_db = [](const Packet &pk) -> std::optional { + if (pk.RxAtrib.evm[0] == 0) + return std::nullopt; + return static_cast(pk.RxAtrib.evm[0]) / 2.0; + }; auto now_ms = [] { return std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()) @@ -1633,7 +1640,8 @@ int run_du(IRadio *dev, const Cfg &c) { if (auto ai = arrival_of.find({oc, ou}); ai != arrival_of.end()) arrival = ai->second; meas_tab.update(oc, ou, static_cast(p.RxAtrib.rssi[0]) - 110.0, - static_cast(p.RxAtrib.snr[0]) / 2.0 /* raw dB*2 */, now_ms()); + static_cast(p.RxAtrib.snr[0]) / 2.0 /* raw dB*2 */, now_ms(), + frame_evm_db(p)); fwd = daps_on && daps_ids.count({oc, ou}) > 0; } if (arrival && ob->dl) { @@ -1705,7 +1713,8 @@ int run_du(IRadio *dev, const Cfg &c) { { std::lock_guard lk(f1_mu); meas_tab.update(c.cell, ub->ue, static_cast(p.RxAtrib.rssi[0]) - 110.0, - static_cast(p.RxAtrib.snr[0]) / 2.0 /* raw dB*2 */, now_ms()); + static_cast(p.RxAtrib.snr[0]) / 2.0 /* raw dB*2 */, now_ms(), + frame_evm_db(p)); } forward_ul(c.cell, ub->ue, p); /* the serving path: always (F1 on) */ if (auto mr = devourer::cell::find_mr_ie(p.Data.data(), p.Data.size(), c.cell, ub->ue, auth)) { diff --git a/src/cell/F1.h b/src/cell/F1.h index 0eb03dd0..ec63b813 100644 --- a/src/cell/F1.h +++ b/src/cell/F1.h @@ -51,6 +51,7 @@ #include #include #include +#include #include #include #include @@ -80,7 +81,10 @@ namespace devourer::cell::f1 { /* 12: Downlink (CU -> DU, the CU's DL PDUs; DAPS duplicates them). 13: * Report carries the DU's suspend-gap period. */ /* 14: Uplink carries its reception instant (net_us). */ -constexpr uint8_t kVersion = 15; /* v14: Uplink.net_us; v15: ChannelSwitch */ +/* v14: Uplink.net_us; v15: ChannelSwitch; v16: EVM in HeardUe and UeMeas */ +constexpr uint8_t kVersion = 16; +constexpr int8_t kNoEvm8 = INT8_MIN; /* HeardUe.evm_db: none measured */ +constexpr int16_t kNoEvmQ = INT16_MIN; /* UeMeas.evm_qdb: none measured */ constexpr size_t kTagLen = 8; constexpr size_t kMaxFixed = 8; constexpr size_t kMaxPositions = kMaxUesPerCell; @@ -120,6 +124,7 @@ struct HeardUe { int8_t rssi_dbm = 0; /* filtered (src/cell/Handover.h L3Filter), dBm */ int8_t snr_db = 0; /* filtered, dB */ uint16_t age_ms = 0; /* since its newest sample, at report time (saturates) */ + int8_t evm_db = kNoEvm8; /* filtered EVM, dB (negative); kNoEvm8: none */ bool operator==(const HeardUe &) const = default; }; /* What a UE measured itself (its MR IE, src/cell/MeasGap.h): `cell`'s @@ -138,6 +143,7 @@ struct UeMeas { int16_t rssi_qdbm = 0; int16_t snr_qdb = 0; uint16_t age_ms = 0; /* since its newest sample, at report time (saturates) */ + int16_t evm_qdb = kNoEvmQ; /* filtered EVM, quarter dB; kNoEvmQ: none */ bool operator==(const UeMeas &) const = default; }; @@ -340,9 +346,11 @@ inline void fill_report_measurements(Report &r, uint16_t own, const std::vector< for (const MeasEntry &e : snap) { if (e.cell == own) { if (r.meas.size() < kMaxUes) - r.meas.push_back({e.ue, clampq(e.rssi_dbm), clampq(e.snr_db), age16(e.age_ms)}); + r.meas.push_back({e.ue, clampq(e.rssi_dbm), clampq(e.snr_db), age16(e.age_ms), + e.evm_valid ? std::max(clampq(e.evm_db), kNoEvmQ + 1) : kNoEvmQ}); } else if (r.heard.size() < kMaxHeard) { - r.heard.push_back({e.cell, e.ue, clamp8(e.rssi_dbm), clamp8(e.snr_db), age16(e.age_ms)}); + r.heard.push_back({e.cell, e.ue, clamp8(e.rssi_dbm), clamp8(e.snr_db), age16(e.age_ms), + e.evm_valid ? std::max(clamp8(e.evm_db), kNoEvm8 + 1) : kNoEvm8}); } } } @@ -393,6 +401,7 @@ inline std::vector encode_report(const Report &r, const Mac &mac) { m.push_back(static_cast(h.rssi_dbm)); m.push_back(static_cast(h.snr_db)); put(m, h.age_ms, 2); + m.push_back(static_cast(h.evm_db)); } m.push_back(static_cast(r.meas.size())); for (const UeMeas &q : r.meas) { @@ -400,6 +409,7 @@ inline std::vector encode_report(const Report &r, const Mac &mac) { put(m, static_cast(q.rssi_qdbm), 2); put(m, static_cast(q.snr_qdb), 2); put(m, q.age_ms, 2); + put(m, static_cast(q.evm_qdb), 2); } m.push_back(static_cast(r.ue_meas.size())); for (const UeCellMeas &q : r.ue_meas) { @@ -469,6 +479,7 @@ inline std::optional decode_report(const uint8_t *p, size_t n, const Mac h.rssi_dbm = static_cast(r.get(1)); h.snr_db = static_cast(r.get(1)); h.age_ms = static_cast(r.get(2)); + h.evm_db = static_cast(r.get(1)); x.heard.push_back(h); } const size_t nm = r.get(1); @@ -480,6 +491,7 @@ inline std::optional decode_report(const uint8_t *p, size_t n, const Mac q.rssi_qdbm = static_cast(static_cast(r.get(2))); q.snr_qdb = static_cast(static_cast(r.get(2))); q.age_ms = static_cast(r.get(2)); + q.evm_qdb = static_cast(static_cast(r.get(2))); x.meas.push_back(q); } const size_t nu = r.get(1); diff --git a/src/cell/Handover.h b/src/cell/Handover.h index 9168247f..ec9a0c2a 100644 --- a/src/cell/Handover.h +++ b/src/cell/Handover.h @@ -163,21 +163,27 @@ class A3Trigger { /* The filtered uplink quality per (cell, UE) one DU measures: its own UEs * (cell = its own id) and the other cells' it hears. RSSI and SNR each go - * through an L3Filter; snapshot() lists only the valid (fresh) entries. */ + * through an L3Filter, and so does EVM, from the frames that carry one; + * snapshot() lists only the valid (fresh) entries. */ struct MeasEntry { uint16_t cell = 0, ue = 0; double rssi_dbm = 0, snr_db = 0; int64_t age_ms = 0; /* since the newest sample in it */ + double evm_db = 0; /* filtered EVM (dB, negative), when evm_valid */ + bool evm_valid = false; }; class MeasTable { public: explicit MeasTable(L3FilterConfig c = {}) : cfg_(c) {} - void update(uint16_t cell, uint16_t ue, double rssi_dbm, double snr_db, int64_t now_ms) { + void update(uint16_t cell, uint16_t ue, double rssi_dbm, double snr_db, int64_t now_ms, + std::optional evm_db = std::nullopt) { auto it = m_.find({cell, ue}); if (it == m_.end()) - it = m_.emplace(std::make_pair(cell, ue), Pair{L3Filter(cfg_), L3Filter(cfg_)}).first; + it = m_.emplace(std::make_pair(cell, ue), Pair{L3Filter(cfg_), L3Filter(cfg_), L3Filter(cfg_)}).first; it->second.rssi.update(rssi_dbm, now_ms); it->second.snr.update(snr_db, now_ms); + if (evm_db) + it->second.evm.update(*evm_db, now_ms); } /* Valid entries now; entries stale for 5x stale_ms are forgotten. */ std::vector snapshot(int64_t now_ms) { @@ -187,9 +193,15 @@ class MeasTable { it = m_.erase(it); continue; } - if (it->second.rssi.valid(now_ms)) - v.push_back({it->first.first, it->first.second, it->second.rssi.value(), - it->second.snr.value(), now_ms - it->second.rssi.last_ms()}); + if (it->second.rssi.valid(now_ms)) { + MeasEntry e{it->first.first, it->first.second, it->second.rssi.value(), it->second.snr.value(), + now_ms - it->second.rssi.last_ms()}; + if (it->second.evm.valid(now_ms)) { + e.evm_db = it->second.evm.value(); + e.evm_valid = true; + } + v.push_back(e); + } ++it; } return v; @@ -197,7 +209,7 @@ class MeasTable { private: struct Pair { - L3Filter rssi, snr; + L3Filter rssi, snr, evm; }; L3FilterConfig cfg_; std::map, Pair> m_; diff --git a/tests/cell_m6_selftest.cpp b/tests/cell_m6_selftest.cpp index 09caddcd..132213f3 100644 --- a/tests/cell_m6_selftest.cpp +++ b/tests/cell_m6_selftest.cpp @@ -278,6 +278,33 @@ int main() { CHECK(d && d->meas == r.meas && d->heard == r.heard); } + /* --- F1 v16: EVM, filtered, from the frames that carry one; "none" when + * none did, in both directions of the report. --- */ + { + MeasTable t({4, 2000}); + for (int i = 0; i < 20; ++i) { + t.update(2, 1, -55, 30, i * 100, -31.0); /* own UE: EVM -31 dB */ + t.update(1, 1, -72, 12, i * 100, i % 2 ? std::optional(-18.0) : std::nullopt); + t.update(3, 2, -80, 8, i * 100); /* heard, no EVM ever */ + } + f1::Report r; + r.du = 2; + f1::fill_report_measurements(r, 2, t.snapshot(1900)); + CHECK(r.meas.size() == 1 && r.meas[0].evm_qdb == -124); + const f1::HeardUe *h1 = nullptr, *h3 = nullptr; + for (const auto &h : r.heard) + (h.cell == 1 ? h1 : h3) = &h; + CHECK(h1 && h1->evm_db == -18 && h3 && h3->evm_db == f1::kNoEvm8); + const auto w = f1::encode_report(r, {}); + auto d = f1::decode_report(w.data(), w.size(), {}); + CHECK(d && d->meas == r.meas && d->heard == r.heard); + /* a real EVM never encodes as "none": heard (8-bit dB) clamps at -127 */ + MeasTable u({4, 2000}); + u.update(5, 1, -55, 30, 0, -200.0); + f1::fill_report_measurements(r, 2, u.snapshot(0)); + CHECK(r.heard.size() == 1 && r.heard[0].evm_db == f1::kNoEvm8 + 1); + } + /* --- F1 v2: the measurement fields round-trip. --- */ { f1::Report r; diff --git a/tools/ran-controller/main.cpp b/tools/ran-controller/main.cpp index 4c754814..1b85b4b1 100644 --- a/tools/ran-controller/main.cpp +++ b/tools/ran-controller/main.cpp @@ -37,6 +37,9 @@ // RAN_CS_LEAD_SF [20] a move's instant, this many superframes ahead // RAN_CS_BACKOFF_S [30] a DU whose move was refused or timed out is not // asked again before this +// ran.meas: per DU, own [ue, rssi, snr, evm] and heard [cell, ue, rssi, snr, +// evm] (filtered; evm null when the frames carried none). EVM is reported, +// not used by the A3. // Handover evaluation (M6, #260) — OBSERVE ONLY: for every UE the CU runs the // A3 (CHO) condition (src/cell/Handover.h) on the DUs' filtered reports — // the serving DU's own measurement against every other DU's heard one — and @@ -875,16 +878,22 @@ int main() { std::string m = "{\"ev\":\"ran.meas\",\"du\":" + std::to_string(kv.first) + ",\"own\":["; for (size_t i = 0; i < kv.second.rep.meas.size(); ++i) { const auto &q = kv.second.rep.meas[i]; - char b[64]; - snprintf(b, sizeof b, "%s[%u,%.2f,%.2f]", i ? "," : "", q.ue, q.rssi_qdbm / 4.0, - q.snr_qdb / 4.0); + char b[96]; + char ev[16] = "null"; /* EVM last (F1 v16): earlier fields keep their places */ + if (q.evm_qdb != f1::kNoEvmQ) + snprintf(ev, sizeof ev, "%.2f", q.evm_qdb / 4.0); + snprintf(b, sizeof b, "%s[%u,%.2f,%.2f,%s]", i ? "," : "", q.ue, q.rssi_qdbm / 4.0, + q.snr_qdb / 4.0, ev); m += b; } m += "],\"heard\":["; for (size_t i = 0; i < kv.second.rep.heard.size(); ++i) { const auto &h = kv.second.rep.heard[i]; - char b[64]; - snprintf(b, sizeof b, "%s[%u,%u,%d,%d]", i ? "," : "", h.cell, h.ue, h.rssi_dbm, h.snr_db); + char b[96]; + char ev[16] = "null"; + if (h.evm_db != f1::kNoEvm8) + snprintf(ev, sizeof ev, "%d", h.evm_db); + snprintf(b, sizeof b, "%s[%u,%u,%d,%d,%s]", i ? "," : "", h.cell, h.ue, h.rssi_dbm, h.snr_db, ev); m += b; } emit(m + "]}");