From f696fe3976c0ee799652f45938014d78bb5cb826 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:28:58 +0300 Subject: [PATCH 1/2] =?UTF-8?q?cell=20M6:=20UE=20measurement=20gaps=20?= =?UTF-8?q?=E2=80=94=20the=20A3=20on=20the=20UE's=20own=20beacon=20measure?= =?UTF-8?q?ments,=20the=20inter-frequency=20trigger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/cell/MeasGap.h: - the NL IE: the neighbour list as offsets on the serving grid, (phase_n - phase_s) mod P on the shared timebase; - the MR IE: the UE's filtered beacon RSSI/SNR per cell; - both carry a CRC and the cell key's tag; - gap_window(): retune + beacon + retune, clear of the serving beacon and the UE's own DL/UL slots, or none. F1 v7: Neighbours (CU -> DU, every round) and Report.ue_meas. DU: airs the NL IE (from the CU's list, on its own grid) and relays its UEs' MR reports with ages. UE: L3-filters every cell beacon it hears and parses the NL IE. Every CELL_UE_GAP_SF it measures an other-channel neighbour in a gap if the window fits (cell.ue.gap / gap_skip), and reports in the MR IE every CELL_UE_MR_MS. CU: for a UE that reports, the A3 compares serving and neighbour beacons (metric ue_rssi). Rig (testrig m6_measgap.sh, 5 runs, ch149 / ch157 at phase 60000, DEFAULT A3 thresholds, nothing forced): all pass. - gaps 53.9 ms median, the neighbour heard at -46 dBm vs a serving -56 dBm; - the A3 fired on UE reports, and the inter-frequency handover executed; - the gaps cost no uplink. The TSF-continuity assertion is now exact: the switch is at the network instant eff_target * P - ((phase_t - phase_s) mod P) plus the poll latency (21 ms median). ctest 92/92. docs/cell-m6.md. Refs #260 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/cell-m6.md | 85 +++++++++++- examples/cell/main.cpp | 182 ++++++++++++++++++++++++- src/cell/F1.h | 100 +++++++++++++- src/cell/MeasGap.h | 250 ++++++++++++++++++++++++++++++++++ tests/cell_m6_selftest.cpp | 62 +++++++++ tools/ran-controller/main.cpp | 46 ++++++- 6 files changed, 707 insertions(+), 18 deletions(-) create mode 100644 src/cell/MeasGap.h diff --git a/docs/cell-m6.md b/docs/cell-m6.md index 046c3743..5f759dc3 100644 --- a/docs/cell-m6.md +++ b/docs/cell-m6.md @@ -150,10 +150,9 @@ the source, which still holds it. This bounds the deaf time of a failed handover. **Trigger.** With one radio per DU, no DU hears a UE on another channel, so -the A3 has no neighbour measurement to compare. UE measurement gaps (the UE -briefly visits the other channel and reports what it hears) are the next -step. Until then, the test forces the trigger: `RAN_HO_FORCE=SRC:UE:TGT@SECONDS` -issues the command at that time through the normal execution path. +the A3 needs the UE's own measurements: see "Measurement gaps" below. +`RAN_HO_FORCE=SRC:UE:TGT@SECONDS` remains for tests: it issues one command +at that time through the normal execution path. **Rig result** (testrig `scripts/m6_handover.sh --channel2 157`). Cell 1 is on ch149 and cell 2 on ch157, both UNII-3. There were 5 normal runs and 3 @@ -181,6 +180,79 @@ with the target killed after staging; all 8 pass. the retune, before its own retune back. Its cause is not established. The rig script does not count it as the post-return beacon. +## Measurement gaps (the inter-frequency trigger) + +The UE measures the cells' beacons (downlink): its serving DU's all the +time, and a neighbour's on another channel in a measurement gap +(`src/cell/MeasGap.h`). + +**Where to listen.** The CU sends each DU its neighbours' channels and TBTT +phases (F1 `Neighbours`, every round). The DU airs them as offsets on its +own grid, in a tagged NL IE: + + offset = (phase_neighbour − phase_serving) mod P + +On the shared timebase a neighbour's beacon always falls at that offset of +the UE's serving superframe, so the UE needs nothing beyond its serving +clock. + +**The gap.** Every `CELL_UE_GAP_SF` (10) superframes, for one other-channel +neighbour in turn, the UE computes `gap_window()`: + + [offset − retune − guard, offset + beacon + retune + guard) + +The window must clear the serving beacon and the UE's own DL and UL slots. +The retune budget is the last measured retune. If the window fits, the UE +retunes there (`FastRetune`), its RX path hears the neighbour's beacon, and +it retunes back. If it does not fit, the UE skips that neighbour +(`cell.ue.gap_skip`): no slot is ever sacrificed. + +**Reports.** The UE L3-filters every cell beacon it hears, serving or +neighbour, and reports the filtered RSSI/SNR in a tagged MR IE in its uplink +beacon, refreshed each `CELL_UE_MR_MS` (1 s). The serving DU relays it over +F1 (`Report.ue_meas`, with ages). + +**The A3 on UE reports.** For a UE that reports, the CU's A3 compares +downlink with downlink: the serving beacon against the neighbours' (metric +`ue_rssi`). Otherwise it falls back to what the DUs hear. + +**Rig result** (testrig `scripts/m6_measgap.sh`, 5 runs). Cell 1 is on +ch149 at phase 0; cell 2 is on ch157 at phase 60000 µs, so its beacon falls +in an idle part of cell 1's grid. The A3 runs at its **defaults** (offset +3 dB, hysteresis 1 dB, time-to-trigger 640 ms), with nothing forced. All 5 +runs pass. + +| KPI | min | median | max | +|---|---|---|---| +| gap deaf time (14 gaps) | 53.7 ms | 53.9 ms | 56.6 ms | +| neighbour beacon in gaps | −46 dBm | −46 dBm | −46 dBm | +| A3 held before firing | 1002 ms | 1024 ms | 1024 ms | +| switch after the network instant (the UE's poll latency) | 21 ms | 21 ms | 28 ms | +| interruption (sf, first in-slot uplink at the target after the switch) | 3 | 3 | 3 | + +- **The trigger:** the serving beacon read −55 to −57 dBm, and the + neighbour's −46 dBm in the gaps. That beat the 3 dB offset plus hysteresis, + so the A3 fired on UE-reported values in every run. +- **The gaps cost no uplink:** the source heard at least 98 % of the + superframes before the switch. +- **The extra interruption superframe** compared with the forced runs is + the phase layout. The UE switches at the source's boundary of network + superframe N, and cell 2's superframe N starts 60 ms later. The UE's grant + there takes effect at that boundary. +- **A limit of this layout:** cell 2's UE cannot measure cell 1. Cell 1's + beacon falls at offset 42400 µs of cell 2's grid, too close to that UE's + own uplink slot, so it logs `gap_skip`. + +**TSF continuity, stated precisely.** The UE switches at the network +instant, the source's boundary of network superframe N. On the target's +clock that instant is: + + eff_target · P − ((phase_t − phase_s) mod P) + +The UE's shifted clock reads it plus its poll latency. The rig scripts +assert exactly this, across every same-channel, inter-frequency and +measurement-gap run. + ## DAPS: the duplicated uplink (same channel) **Common sequence.** A UE uplinks once per superframe. Every DU that hears @@ -277,8 +349,9 @@ superframe when there is nothing to serve. budget. - **A moving UE:** the rig's UEs are stationary, so the crossing is forced with a negative A3 offset. -- **The inter-frequency trigger:** UE measurement gaps. The test forces - the handover (`RAN_HO_FORCE`). +- **Measurement-gap scheduling:** a gap goes only where it clears the UE's + own slots, so some phase layouts leave a neighbour unmeasurable. Placing + cells' phases so every pair can measure each other is a CU planning task. - **Security context:** the cells share one key on the rig (`CELL_PSK`). Pre-staging a per-cell context (802.11r-style) is open. - **EVM:** not in the measurement reports. diff --git a/examples/cell/main.cpp b/examples/cell/main.cpp index 40c8d28a..882b6246 100644 --- a/examples/cell/main.cpp +++ b/examples/cell/main.cpp @@ -902,6 +902,17 @@ int run_du(IRadio *dev, const Cfg &c) { std::map ho_in; /* by target UE id */ std::set ho_cancel; /* published arrivals the CU cancelled */ std::map ho_published; /* published arrivals: UE id -> handover id */ + /* M6 measurement gaps (f1_mu): the CU's neighbour list as offsets on this + * grid (aired in the NL IE), and what each own UE reported measuring (its + * MR IE), relayed in Report.ue_meas. */ + std::vector nb_list; + bool have_nb_seq = false; + uint64_t last_nb_seq = 0; + struct UeReport { + std::vector m; + int64_t at_ms = 0; + }; + std::map ue_reports; std::map ho_out; /* by (source) UE id */ bool have_ho_seq = false; uint64_t last_ho_seq = 0; @@ -1021,6 +1032,10 @@ int run_du(IRadio *dev, const Cfg &c) { static_cast(p.RxAtrib.snr[0]) / 2.0 /* raw dB*2 */, now_ms()); } 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)) { + std::lock_guard lk(f1_mu); + ue_reports[ub->ue] = {*mr, now_ms()}; + } } /* The UL slot of the grant the UE runs (by the epoch it reports). */ std::optional slot; @@ -1138,6 +1153,20 @@ int run_du(IRadio *dev, const Cfg &c) { r.positions = layout.positions; r.ues = f1_view.ues; f1::fill_report_measurements(r, c.cell, meas_tab.snapshot(now_ms())); + /* The UEs' own measurements (their MR IEs), with the report age. */ + for (auto it = ue_reports.begin(); it != ue_reports.end();) { + const int64_t age = now_ms() - it->second.at_ms; + if (age > 10000) { + it = ue_reports.erase(it); + continue; + } + for (const auto &e : it->second.m) + if (r.ue_meas.size() < f1::kMaxHeard) + r.ue_meas.push_back({it->first, e.cell, static_cast(e.rssi_dbm * 4), + static_cast(e.snr_db * 4), + static_cast(std::min(age, 65535))}); + ++it; + } if (f1_view.net_sf < 0) /* no network superframe yet: nothing to plan on */ r.ues.clear(); } @@ -1171,6 +1200,23 @@ int run_du(IRadio *dev, const Cfg &c) { }; if (!same_peer()) continue; + if (f1::peek_type(buf, static_cast(n)) == f1::Type::Neighbours) { + auto nb = f1::decode_neighbours(buf, static_cast(n), f1_mac); + if (!nb || nb->du != c.cell) + continue; + std::vector v; + for (const auto &e : nb->cells) + v.push_back({e.cell, e.channel, + devourer::cell::neighbour_offset_us(e.phase_us, + static_cast(dc.phase_us), P)}); + std::lock_guard lk(f1_mu); + if (have_nb_seq && nb->seq <= last_nb_seq) + continue; /* a replay or reordering */ + have_nb_seq = true; + last_nb_seq = nb->seq; + nb_list = std::move(v); + continue; + } if (f1::peek_type(buf, static_cast(n)) == f1::Type::Handover) { auto h = f1::decode_handover(buf, static_cast(n), f1_mac); if (!h || h->first != c.cell) @@ -1474,6 +1520,7 @@ int run_du(IRadio *dev, const Cfg &c) { * contact, and a staged schedule that is due. */ bool cu_alive = true; std::optional f1_due; + std::vector nb_snap; std::map ho_in_snap; std::map ho_out_snap; std::set ho_cancel_snap; @@ -1486,6 +1533,7 @@ int run_du(IRadio *dev, const Cfg &c) { f1_view.coordinated = coordinated; for (auto it = daps_ids.begin(); it != daps_ids.end();) it = it->second < sf ? daps_ids.erase(it) : std::next(it); + nb_snap = nb_list; ho_in_snap = ho_in; ho_out_snap = ho_out; /* Kept until the live map no longer has the UE (a deferred or failed @@ -1811,6 +1859,12 @@ int run_du(IRadio *dev, const Cfg &c) { emit(l); } } + /* The neighbour list rides the beacon too (only on the shared timebase: + * its offsets are network-time facts). */ + if (synced && !nb_snap.empty()) { + const auto nl = devourer::cell::encode_nl_ie(c.cell, nb_snap, auth); + ho_ies.insert(ho_ies.end(), nl.begin(), nl.end()); + } if (changed || rar_now != rar_on_air || ho_ies != ho_on_air) { if (changed) { ++next.epoch; @@ -2017,6 +2071,20 @@ int run_ue(IRadio *dev, const Cfg &c) { uint8_t tgt_du_mac[6] = {0}; bool force_beacon = false; /* After a switch, until the target grants us: the way back (mu). */ + /* M6 measurement gaps (src/cell/MeasGap.h), under mu: the neighbour list + * the serving DU airs, and the UE's own filtered beacon measurements + * (serving and neighbours), reported in its uplink beacon (MR IE). */ + std::vector nbrs; + 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 */ + const int64_t gap_guard_us = envl("CELL_UE_GAP_GUARD_US", 3000, 0, 50000); + const int64_t mr_every_ms = envl("CELL_UE_MR_MS", 1000, 100, 60000); + auto ue_ms = [] { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + }; /* Inter-frequency: the channel the UE runs on, a retune the switch (or * a fallback) asks the control thread for, and the first beacon of the new * serving DU after it (the deaf time). */ @@ -2071,6 +2139,13 @@ int run_ue(IRadio *dev, const Cfg &c) { return; if (p.Data[0] == 0x80) { std::lock_guard lk(mu); + /* Any cell DU's beacon (02:ce:11::00): the UE's measurement of + * that cell — its serving one, a same-channel neighbour, or one heard + * in a measurement gap. */ + if (p.Data[10] == 0x02 && p.Data[11] == 0xce && p.Data[12] == 0x11 && p.Data[15] == 0) + ue_meas.update(static_cast(p.Data[13] << 8 | p.Data[14]), 0, + static_cast(p.RxAtrib.rssi[0]) - 110.0, + static_cast(p.RxAtrib.snr[0]) / 2.0 /* raw dB*2 */, ue_ms()); if (std::memcmp(p.Data.data() + 10, du_mac, 6) != 0) { /* The handover target's beacons: its grant maps, buffered for the * switch (they do not feed the clock: that is the source's until @@ -2094,6 +2169,8 @@ int run_ue(IRadio *dev, const Cfg &c) { emit(l); } sync.Add(*eg, p.RxAtrib.tsfl); + if (auto nl = devourer::cell::find_nl_ie(p.Data.data(), p.Data.size(), cell_a.load(), auth)) + nbrs = *nl; if (me_a > 0) if (auto h = devourer::cell::find_ho_ie(p.Data.data(), p.Data.size(), cell_a.load(), static_cast(me_a.load()), auth)) @@ -2168,6 +2245,10 @@ int run_ue(IRadio *dev, const Cfg &c) { * DL accounting: frames sent outside it were never expected. */ int64_t listen_from = -1, listen_to = -1; bool rearm_ack = false; + int64_t next_mr_ms = 0; + int64_t next_gap_sf = -1; /* the superframe of the next measurement gap */ + size_t gap_rr = 0; /* round-robin over the other-channel neighbours */ + int64_t last_retune_us = 25000; /* the gap budget: the last measured retune */ while (std::chrono::steady_clock::now() < deadline) { std::this_thread::sleep_for(std::chrono::milliseconds(100)); uint64_t t = 0, f = 0; @@ -2488,12 +2569,30 @@ int run_ue(IRadio *dev, const Cfg &c) { * SNR measurement) — not on the report's generation time alone, which * moves every superframe: each re-download can cost a beacon on some * generations. The published gen_sf is when this content was generated. */ + /* The measurement report rides the uplink beacon, refreshed this often. */ + if (ue_ms() >= next_mr_ms) { + next_mr_ms = ue_ms() + mr_every_ms; + force_beacon = true; + } const bool content_moved = force_beacon || !beaconing || running_epoch != mcopy.epoch || fb.base != last_fb.base || fb.bitmap != last_fb.bitmap || snr_gen != last_snr_gen; if (content_moved) { - const auto b = - build_ue_beacon(cell_a.load(), me, c.tu, mcopy.epoch, fb, snr_half, snr_gen, auth); + auto b = build_ue_beacon(cell_a.load(), me, c.tu, mcopy.epoch, fb, snr_half, snr_gen, auth); + { + std::vector mr; + { + std::lock_guard lk(mu); + for (const auto &e : ue_meas.snapshot(ue_ms())) + if (mr.size() < devourer::cell::kMaxNeighbours + 1) + mr.push_back({e.cell, static_cast(std::lround(std::clamp(e.rssi_dbm, -128.0, 127.0))), + static_cast(std::lround(std::clamp(e.snr_db, -128.0, 127.0)))}); + } + if (!mr.empty()) { + const auto ie = devourer::cell::encode_mr_ie(cell_a.load(), static_cast(me), mr, auth); + b.insert(b.end(), ie.begin(), ie.end()); + } + } const bool ok = beaconing ? dev->UpdateBeaconPayload(b.data(), b.size()) : dev->StartBeacon(b.data(), b.size(), c.tu); if (!ok) { @@ -2553,6 +2652,85 @@ int run_ue(IRadio *dev, const Cfg &c) { me, (long long)step, (long long)resid, comp_us, skew); emit(l); } + /* A measurement gap (MeasGap.h): for a neighbour on ANOTHER channel, + * every gap_every_sf superframes, if a window around its beacon fits + * clear of the serving beacon and this UE's own DL/UL slots. The UE + * retunes there, hears the beacon (the RX path measures it), retunes + * back. Never during a handover. */ + if (gap_every_sf > 0 && !ho_pending && !ho_back) { + std::vector other; + { + std::lock_guard lk(mu); + for (const auto &n : nbrs) + if (n.channel != 0 && n.channel != cur_ch) + other.push_back(n); + } + const int64_t sfn = du_now / P; + if (next_gap_sf < 0) + next_gap_sf = sfn + gap_every_sf; + if (!other.empty() && sfn >= next_gap_sf) { + next_gap_sf = sfn + gap_every_sf; + const auto &n = other[gap_rr++ % other.size()]; + const auto &sl = grid->slots(); + const std::vector busy = { + {0, c.bcn_us}, + {sl[g->dl_slot].start_us, sl[g->dl_slot].len_us}, + {sl[g->ul_slot].start_us, sl[g->ul_slot].len_us}}; + const auto w = devourer::cell::gap_window(n.offset_us, 500, last_retune_us, gap_guard_us, P, busy); + char l[240]; + if (!w) { + snprintf(l, sizeof l, + "{\"ev\":\"cell.ue.gap_skip\",\"ue\":%d,\"cell\":%u,\"offset_us\":%d}", + me, n.cell, n.offset_us); + emit(l); + } else { + /* The window in the NEXT superframe, on the serving clock. */ + const int64_t start = (sfn + 1) * P + w->start; + /* The serving clock NOW (this pass has spent time since du_now). */ + int64_t now_du = du_now; + if (uint64_t t3 = 0, f3 = 0; dev->ReadTsfAndFreeRun(t3, f3)) { + std::lock_guard lk(mu); + now_du = sync.RemoteForLocal(static_cast(wrap.extend(static_cast(f3)))); + } + const int64_t wait = start - now_du; + if (wait > 0 && wait < 2 * P) { + std::this_thread::sleep_for(std::chrono::microseconds(wait)); + const auto g0 = std::chrono::steady_clock::now(); + bool ok = true; + try { + dev->FastRetune(n.channel); + } catch (const std::exception &) { + ok = false; + } + const auto g1 = std::chrono::steady_clock::now(); + if (ok) + last_retune_us = std::chrono::duration_cast(g1 - g0).count(); + /* Listen until the beacon has passed (+ the guard). */ + std::this_thread::sleep_until(g0 + std::chrono::microseconds(w->len - last_retune_us)); + try { + dev->FastRetune(cur_ch); + } catch (const std::exception &) { + ok = false; + } + const auto g2 = std::chrono::steady_clock::now(); + std::optional got; + { + std::lock_guard lk(mu); + for (const auto &e : ue_meas.snapshot(ue_ms())) + if (e.cell == n.cell && e.age_ms <= (g2 - g0) / std::chrono::milliseconds(1) + 1) + got = e; + } + snprintf(l, sizeof l, + "{\"ev\":\"cell.ue.gap\",\"ue\":%d,\"cell\":%u,\"channel\":%u," + "\"heard\":%d,\"rssi\":%.1f,\"deaf_us\":%lld,\"ok\":%d}", + me, n.cell, n.channel, got ? 1 : 0, got ? got->rssi_dbm : 0.0, + (long long)std::chrono::duration_cast(g2 - g0).count(), + ok ? 1 : 0); + emit(l); + } + } + } + } } if (beaconing) dev->StopBeacon(); diff --git a/src/cell/F1.h b/src/cell/F1.h index a7b886cd..9e217d54 100644 --- a/src/cell/F1.h +++ b/src/cell/F1.h @@ -59,6 +59,7 @@ #include "cell/Coord.h" #include "cell/HoCommand.h" #include "cell/Handover.h" +#include "cell/MeasGap.h" #include "cell/Rach.h" namespace devourer::cell::f1 { @@ -69,15 +70,17 @@ namespace devourer::cell::f1 { /* 5: Uplink (DAPS: a DU forwarding a UE uplink it heard). 4: Handover * messages; Report carries the DU's network-superframe offset * (for the TSF delta, HoCommand.h); Ack names what it acknowledges. */ -/* 6: Handover carries the target's channel (inter-frequency). */ -constexpr uint8_t kVersion = 6; +/* 7: Neighbours (CU -> DU, the neighbour list for UE measurement gaps, + * src/cell/MeasGap.h); Report carries the UEs' own measurements (ue_meas). + * 6: Handover carries the target's channel (inter-frequency). */ +constexpr uint8_t kVersion = 7; constexpr size_t kTagLen = 8; constexpr size_t kMaxFixed = 8; constexpr size_t kMaxPositions = kMaxUesPerCell; constexpr size_t kMaxUes = kMaxUesPerCell; constexpr size_t kMaxHeard = 64; -enum class Type : uint8_t { Report = 1, Schedule = 2, Ack = 3, Handover = 4, Uplink = 5 }; +enum class Type : uint8_t { Report = 1, Schedule = 2, Ack = 3, Handover = 4, Uplink = 5, Neighbours = 6 }; enum class AckStatus : uint8_t { Staged = 0, Applied = 1, Stale = 2, Late = 3, Invalid = 4 }; inline const char *ack_status_name(AckStatus s) { @@ -97,6 +100,15 @@ struct HeardUe { uint16_t age_ms = 0; /* since its newest sample, at report time (saturates) */ bool operator==(const HeardUe &) const = default; }; +/* What a UE measured itself (its MR IE, src/cell/MeasGap.h): `cell`'s + * beacons — its serving cell's, and in gaps a neighbour's — relayed by the + * serving DU. Quarter dB, with the report's age at the DU. */ +struct UeCellMeas { + uint16_t ue = 0, cell = 0; + int16_t rssi_qdbm = 0, snr_qdb = 0; + uint16_t age_ms = 0; + bool operator==(const UeCellMeas &) const = default; +}; /* The DU's filtered uplink measurement of one of its own UEs (M6), quarter * dB, so the CU compares serving and neighbour cells on one scale. */ struct UeMeas { @@ -130,6 +142,7 @@ struct Report { std::vector ues; std::vector heard; std::vector meas; + std::vector ue_meas; bool operator==(const Report &) const = default; }; @@ -280,7 +293,7 @@ inline void fill_report_measurements(Report &r, uint16_t own, const std::vector< inline std::optional peek_type(const uint8_t *p, size_t n) { if (p == nullptr || n < 4 + kTagLen || p[0] != 'F' || p[1] != '1' || p[2] != kVersion) return std::nullopt; - if (p[3] < 1 || p[3] > 5) + if (p[3] < 1 || p[3] > 6) return std::nullopt; return static_cast(p[3]); } @@ -289,7 +302,8 @@ inline std::optional peek_type(const uint8_t *p, size_t n) { inline std::vector encode_report(const Report &r, const Mac &mac) { using namespace detail; if (r.fixed.size() > kMaxFixed || r.positions.size() > kMaxPositions || - r.ues.size() > kMaxUes || r.heard.size() > kMaxHeard || r.meas.size() > kMaxUes) + r.ues.size() > kMaxUes || r.heard.size() > kMaxHeard || r.meas.size() > kMaxUes || + r.ue_meas.size() > kMaxHeard) return {}; auto m = head(Type::Report); put(m, r.du, 2); @@ -328,6 +342,14 @@ inline std::vector encode_report(const Report &r, const Mac &mac) { put(m, static_cast(q.snr_qdb), 2); put(m, q.age_ms, 2); } + m.push_back(static_cast(r.ue_meas.size())); + for (const UeCellMeas &q : r.ue_meas) { + put(m, q.ue, 2); + put(m, q.cell, 2); + put(m, static_cast(q.rssi_qdbm), 2); + put(m, static_cast(q.snr_qdb), 2); + put(m, q.age_ms, 2); + } return seal(std::move(m), mac); } @@ -389,6 +411,18 @@ inline std::optional decode_report(const uint8_t *p, size_t n, const Mac q.age_ms = static_cast(r.get(2)); x.meas.push_back(q); } + const size_t nu = r.get(1); + if (nu > kMaxHeard) + return std::nullopt; + for (size_t i = 0; i < nu && r.ok; ++i) { + UeCellMeas q; + q.ue = static_cast(r.get(2)); + q.cell = static_cast(r.get(2)); + 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)); + x.ue_meas.push_back(q); + } if (!r.ok || r.i != r.n) return std::nullopt; return x; @@ -538,6 +572,62 @@ inline std::optional decode_uplink(const uint8_t *p, size_t n, const return u; } +/* CU -> DU: the cells a DU's UEs should measure (their channel and TBTT + * phase); the DU airs them as offsets on its own grid (MeasGap.h NL IE). + * Resent each round; `seq` is freshness (as Schedule::seq). */ +struct NeighbourEntry { + uint16_t cell = 0; + uint8_t channel = 0; + int64_t phase_us = 0; + bool operator==(const NeighbourEntry &) const = default; +}; +struct Neighbours { + uint16_t du = 0; + uint64_t seq = 0; + std::vector cells; + bool operator==(const Neighbours &) const = default; +}; + +inline std::vector encode_neighbours(const Neighbours &n, const Mac &mac) { + using namespace detail; + if (n.cells.size() > kMaxNeighbours) + return {}; + auto m = head(Type::Neighbours); + put(m, n.du, 2); + put(m, n.seq, 8); + m.push_back(static_cast(n.cells.size())); + for (const NeighbourEntry &c : n.cells) { + put(m, c.cell, 2); + m.push_back(c.channel); + put(m, static_cast(c.phase_us), 8); + } + return seal(std::move(m), mac); +} + +inline std::optional decode_neighbours(const uint8_t *p, size_t n, const Mac &mac) { + using namespace detail; + auto o = open(p, n, Type::Neighbours, mac); + if (!o) + return std::nullopt; + Rd &r = *o; + Neighbours x; + x.du = static_cast(r.get(2)); + x.seq = r.get(8); + const size_t cnt = r.get(1); + if (cnt > kMaxNeighbours) + return std::nullopt; + for (size_t i = 0; i < cnt && r.ok; ++i) { + NeighbourEntry c; + c.cell = static_cast(r.get(2)); + c.channel = static_cast(r.get(1)); + c.phase_us = static_cast(r.get(8)); + x.cells.push_back(c); + } + if (!r.ok || r.i != r.n) + return std::nullopt; + return x; +} + struct StagerConfig { uint64_t min_lead_sf = 6; /* a schedule must arrive this far ahead */ uint64_t publish_lead_sf = 3; /* the DU's grant map goes out this far ahead */ diff --git a/src/cell/MeasGap.h b/src/cell/MeasGap.h new file mode 100644 index 00000000..d46c2633 --- /dev/null +++ b/src/cell/MeasGap.h @@ -0,0 +1,250 @@ +/* MeasGap.h — M6 (#260) inter-frequency measurements: what lets the A3 + * compare a UE's serving cell with a neighbour on ANOTHER channel when no + * DU can hear the UE there (one radio per DU). + * + * The UE measures the cells' BEACONS (downlink): its serving DU's all the + * time, a neighbour's in a measurement gap — it retunes to the neighbour's + * channel just before that beacon airs and back right after. When to listen + * comes from the shared PTP timebase: a neighbour's TBTT sits at a fixed + * offset on the serving grid, + * offset = (phase_neighbour - phase_serving) mod P, + * so the UE needs nothing beyond its serving clock. + * + * NL IE (the serving DU's beacon; the CU's neighbour list, F1 Neighbours): + * 'N' 'L' ver(1)=1 cell(2) n(1) n x {cell(2) channel(1) offset_us(4)} + * crc16(2) tag(8) + * MR IE (the UE's uplink beacon; relayed by the serving DU over F1): + * 'M' 'R' ver(1)=1 cell(2) ue(2) n(1) n x {cell(2) rssi_dbm(1, signed) + * snr_db(1, signed)} crc16(2) tag(8) + * Both carry the CRC (a torn body) and the cell key's tag (labels 'N' / 'M', + * as the other scheduling frames), so a forged list cannot send UEs deaf, and + * a forged report cannot steer a handover. + * + * gap_window(): where in its superframe a UE may go deaf for one neighbour + * beacon — [offset - retune - guard, offset + beacon + retune + guard) — or + * nullopt when that overlaps anything the UE must be on its channel for + * (the serving beacon, its own DL and UL slots). A UE that cannot fit a gap + * simply does not measure that neighbour (no uplink is ever sacrificed). + * + * Pure header; the cell_m6 ctest covers it. */ +#ifndef DEVOURER_CELL_MEASGAP_H +#define DEVOURER_CELL_MEASGAP_H + +#include +#include +#include +#include +#include + +#include "cell/GrantMap.h" +#include "cell/Rach.h" + +namespace devourer::cell { + +constexpr uint8_t kNlVersion = 1; +constexpr uint8_t kMrVersion = 1; +constexpr size_t kMgTagLen = 8; +constexpr size_t kMaxNeighbours = 8; + +struct Neighbour { + uint16_t cell = 0; + uint8_t channel = 0; + int32_t offset_us = 0; /* its TBTT on the serving grid, [0, P) */ + bool operator==(const Neighbour &) const = default; +}; + +struct MeasEntryUe { + uint16_t cell = 0; + int8_t rssi_dbm = 0; + int8_t snr_db = 0; + bool operator==(const MeasEntryUe &) const = default; +}; + +/* A neighbour's TBTT on the serving grid. */ +inline int32_t neighbour_offset_us(int64_t phase_neighbour, int64_t phase_serving, int64_t period_us) { + int64_t o = (phase_neighbour - phase_serving) % period_us; + if (o < 0) + o += period_us; + return static_cast(o); +} + +namespace mg_detail { +inline void put(std::vector &o, uint64_t v, int n) { + for (int i = 0; i < n; ++i) + o.push_back(static_cast(v >> (8 * i))); +} +inline uint64_t get(const uint8_t *p, int n) { + uint64_t v = 0; + for (int i = 0; i < n; ++i) + v |= static_cast(p[i]) << (8 * i); + return v; +} +inline bool tag(const Mac &mac, char label, const uint8_t *body, size_t n, uint8_t out[kMgTagLen]) { + if (!mac) { + std::memset(out, 0, kMgTagLen); + return true; + } + std::vector m = {static_cast(label)}; + m.insert(m.end(), body, body + n); + uint8_t full[20]; + if (!mac(m.data(), m.size(), full)) + return false; + std::memcpy(out, full, kMgTagLen); + return true; +} +/* body + crc + tag -> the full vendor IE (empty if too long or Mac fails). */ +inline std::vector seal_ie(std::vector b, char label, const Mac &mac) { + put(b, detail::crc16_ccitt(b.data(), b.size()), 2); + uint8_t t[kMgTagLen]; + if (!tag(mac, label, b.data(), b.size(), t) || 3 + b.size() + kMgTagLen > 255) + return {}; + b.insert(b.end(), t, t + kMgTagLen); + std::vector ie = {221, static_cast(3 + b.size()), kGrantOui[0], kGrantOui[1], + kGrantOui[2]}; + ie.insert(ie.end(), b.begin(), b.end()); + return ie; +} +/* Verify an IE payload (after the OUI) of `n` bytes ending crc + tag; + * returns the body length (before the crc) or 0. */ +inline size_t open_ie(const uint8_t *b, size_t n, char label, const Mac &mac) { + if (n < 2 + kMgTagLen + 3) + return 0; + const size_t body = n - 2 - kMgTagLen; + if (get(b + body, 2) != detail::crc16_ccitt(b, body)) + return 0; + if (mac) { + uint8_t want[kMgTagLen]; + if (!tag(mac, label, b, body + 2, want)) + return 0; + uint8_t d = 0; + for (size_t i = 0; i < kMgTagLen; ++i) + d |= static_cast(want[i] ^ b[body + 2 + i]); + if (d != 0) + return 0; + } + return body; +} +/* Each vendor IE payload (after the OUI) in a beacon, beginning with m0 m1. */ +template +inline void for_each_ie(const uint8_t *mpdu, size_t len, uint8_t m0, uint8_t m1, F &&f) { + if (mpdu == nullptr || len < 36 || mpdu[0] != 0x80) + return; + for (size_t off = 36; off + 2 <= len;) { + const size_t l = mpdu[off + 1]; + if (off + 2 + l > len) + break; + const uint8_t *v = mpdu + off + 2; + if (mpdu[off] == 221 && l > 5 && v[0] == kGrantOui[0] && v[1] == kGrantOui[1] && + v[2] == kGrantOui[2] && v[3] == m0 && v[4] == m1) + f(v + 3, l - 3); + off += 2 + l; + } +} +} /* namespace mg_detail */ + +inline std::vector encode_nl_ie(uint16_t cell, const std::vector &n, const Mac &mac) { + using namespace mg_detail; + if (n.size() > kMaxNeighbours) + return {}; + std::vector b = {'N', 'L', kNlVersion}; + put(b, cell, 2); + b.push_back(static_cast(n.size())); + for (const Neighbour &x : n) { + put(b, x.cell, 2); + b.push_back(x.channel); + put(b, static_cast(x.offset_us), 4); + } + return seal_ie(std::move(b), 'N', mac); +} + +/* The neighbour list in cell `cell`'s beacon (nullopt: none / not authentic). */ +inline std::optional> find_nl_ie(const uint8_t *mpdu, size_t len, uint16_t cell, + const Mac &mac) { + using namespace mg_detail; + std::optional> out; + for_each_ie(mpdu, len, 'N', 'L', [&](const uint8_t *b, size_t n) { + const size_t body = open_ie(b, n, 'N', mac); + if (body < 6 || b[2] != kNlVersion || get(b + 3, 2) != cell) + return; + const size_t cnt = b[5]; + if (cnt > kMaxNeighbours || body != 6 + cnt * 7) + return; + std::vector v; + for (size_t i = 0; i < cnt; ++i) { + const uint8_t *e = b + 6 + i * 7; + v.push_back({static_cast(get(e, 2)), e[2], + static_cast(static_cast(get(e + 3, 4)))}); + } + out = std::move(v); + }); + return out; +} + +inline std::vector encode_mr_ie(uint16_t cell, uint16_t ue, const std::vector &m, + const Mac &mac) { + using namespace mg_detail; + if (m.size() > kMaxNeighbours + 1) + return {}; + std::vector b = {'M', 'R', kMrVersion}; + put(b, cell, 2); + put(b, ue, 2); + b.push_back(static_cast(m.size())); + for (const MeasEntryUe &x : m) { + put(b, x.cell, 2); + b.push_back(static_cast(x.rssi_dbm)); + b.push_back(static_cast(x.snr_db)); + } + return seal_ie(std::move(b), 'M', mac); +} + +/* The measurement report in a UE beacon of cell `cell`'s UE `ue`. */ +inline std::optional> find_mr_ie(const uint8_t *mpdu, size_t len, uint16_t cell, + uint16_t ue, const Mac &mac) { + using namespace mg_detail; + std::optional> out; + for_each_ie(mpdu, len, 'M', 'R', [&](const uint8_t *b, size_t n) { + const size_t body = open_ie(b, n, 'M', mac); + if (body < 8 || b[2] != kMrVersion || get(b + 3, 2) != cell || get(b + 5, 2) != ue) + return; + const size_t cnt = b[7]; + if (cnt > kMaxNeighbours + 1 || body != 8 + cnt * 4) + return; + std::vector v; + for (size_t i = 0; i < cnt; ++i) { + const uint8_t *e = b + 8 + i * 4; + v.push_back({static_cast(get(e, 2)), static_cast(e[2]), + static_cast(e[3])}); + } + out = std::move(v); + }); + return out; +} + +/* An interval on the serving grid, [start, start + len) mod P. */ +struct GridSpan { + int64_t start = 0, len = 0; +}; + +/* The gap for one neighbour beacon (see the header): its start on the + * serving grid and length, or nullopt if it would cover any `busy` span. */ +inline std::optional gap_window(int32_t neighbour_offset_us, int64_t beacon_us, + int64_t retune_us, int64_t guard_us, int64_t period_us, + const std::vector &busy) { + const int64_t P = period_us; + const int64_t start = neighbour_offset_us - retune_us - guard_us; + const int64_t len = 2 * (retune_us + guard_us) + beacon_us; + if (len <= 0 || len >= P) + return std::nullopt; + auto mod = [P](int64_t x) { return ((x % P) + P) % P; }; + for (const GridSpan &b : busy) { + if (b.len <= 0) + continue; + if (mod(b.start - start) < len || mod(start - b.start) < b.len) + return std::nullopt; + } + return GridSpan{mod(start), len}; +} + +} /* namespace devourer::cell */ + +#endif /* DEVOURER_CELL_MEASGAP_H */ diff --git a/tests/cell_m6_selftest.cpp b/tests/cell_m6_selftest.cpp index 94022624..87e516b6 100644 --- a/tests/cell_m6_selftest.cpp +++ b/tests/cell_m6_selftest.cpp @@ -16,6 +16,7 @@ #include "cell/F1.h" #include "cell/Handover.h" #include "cell/HoCommand.h" +#include "cell/MeasGap.h" using namespace devourer::cell; @@ -434,6 +435,67 @@ int main() { CHECK(!f1::decode_uplink(w.data(), cut, k)); } + /* --- Measurement gaps (MeasGap.h): the neighbour offset, the NL and MR + * IEs, and where a gap fits. --- */ + { + const int64_t P = 102400; + CHECK(neighbour_offset_us(60000, 0, P) == 60000); + CHECK(neighbour_offset_us(0, 13450, P) == P - 13450); /* the other way round wraps */ + CHECK(neighbour_offset_us(13450 + 3 * P, 13450, P) == 0); + const Mac k = fake_mac(21), other = fake_mac(22); + std::vector nl = {{2, 157, 60000}, {3, 36, 81234}}; + std::vector bcn(36, 0); + bcn[0] = 0x80; + const auto ie = encode_nl_ie(1, nl, k); + CHECK(!ie.empty()); + bcn.insert(bcn.end(), ie.begin(), ie.end()); + auto got = find_nl_ie(bcn.data(), bcn.size(), 1, k); + CHECK(got && *got == nl); + CHECK(!find_nl_ie(bcn.data(), bcn.size(), 2, k)); /* another cell's list */ + CHECK(!find_nl_ie(bcn.data(), bcn.size(), 1, other)); /* a forged list is inert */ + auto torn = bcn; + torn[36 + 5 + 7] ^= 1; + CHECK(!find_nl_ie(torn.data(), torn.size(), 1, k)); + auto data = bcn; + data[0] = 0x08; + CHECK(!find_nl_ie(data.data(), data.size(), 1, k)); /* beacons only */ + std::vector mr = {{1, -52, 38}, {2, -49, 41}}; + std::vector ub(36, 0); + ub[0] = 0x80; + const auto mie = encode_mr_ie(1, 1, mr, k); + ub.insert(ub.end(), mie.begin(), mie.end()); + auto gm = find_mr_ie(ub.data(), ub.size(), 1, 1, k); + CHECK(gm && *gm == mr); + CHECK(!find_mr_ie(ub.data(), ub.size(), 1, 2, k)); /* another UE's */ + CHECK(!find_mr_ie(ub.data(), ub.size(), 1, 1, other)); /* a forged report is inert */ + CHECK(encode_nl_ie(1, std::vector(kMaxNeighbours + 1), k).empty()); + /* The rig grid (beacon 1500, DL 4000 x3 from 1500, turnaround 1000, UL + * 2000 x3 from 14500), a UE in position 2: busy = the serving beacon, its + * DL [5500, 9500), its UL [16500, 18500) with the uplink-beacon margin. */ + const std::vector busy = {{0, 1500}, {5500, 4000}, {16500, 2000}}; + const int64_t retune = 24000, guard = 2000, beacon = 500; + auto g = gap_window(60000, beacon, retune, guard, P, busy); + CHECK(g && g->start == 60000 - 26000 && g->len == 2 * 26000 + 500); + CHECK(!gap_window(13450, beacon, retune, guard, P, busy)); /* over the UE's own slots */ + CHECK(!gap_window(100000, beacon, retune, guard, P, busy)); /* wraps onto the serving beacon */ + CHECK(!gap_window(80000, beacon, retune, guard, P, busy)); /* [54000, 106500) wraps onto the beacon */ + CHECK(gap_window(70000, beacon, retune, guard, P, busy)); /* [44000, 96500): clear */ + /* F1 v7: Neighbours, and the UEs' own measurements in a Report. */ + f1::Neighbours nb{1, 777, {{2, 157, 60000}, {3, 36, 13450}}}; + const auto wn = f1::encode_neighbours(nb, k); + CHECK(f1::peek_type(wn.data(), wn.size()) == f1::Type::Neighbours); + auto dn = f1::decode_neighbours(wn.data(), wn.size(), k); + CHECK(dn && *dn == nb); + for (size_t cut = 0; cut < wn.size(); ++cut) + CHECK(!f1::decode_neighbours(wn.data(), cut, k)); + f1::Report rr; + rr.du = 1; + rr.ue_meas = {{1, 1, -52 * 4, 38 * 4, 120}, {1, 2, -49 * 4 - 1, 41 * 4, 900}}; + const auto wr = f1::encode_report(rr, k); + auto drr = f1::decode_report(wr.data(), wr.size(), k); + CHECK(drr && drr->ue_meas == rr.ue_meas); + } + if (failures == 0) std::printf("cell_m6_selftest: all checks passed\n"); return failures == 0 ? 0 : 1; diff --git a/tools/ran-controller/main.cpp b/tools/ran-controller/main.cpp index 0c0069d7..fab1d177 100644 --- a/tools/ran-controller/main.cpp +++ b/tools/ran-controller/main.cpp @@ -545,13 +545,14 @@ int main() { return d.at.time_since_epoch().count() != 0 && age_ms + rep_age <= meas_stale_ms; }; auto log_candidate = [&](uint16_t ue, uint16_t from, uint16_t to, double sv, double tv, - int64_t held) { + int64_t held, bool ue_reported) { char b[256]; snprintf(b, sizeof b, "{\"ev\":\"ran.ho_candidate\",\"t_ms\":%lld,\"ue\":%u,\"from\":%u," - "\"to\":%u,\"metric\":\"%s\",\"serving\":%.2f,\"target\":%.2f," + "\"to\":%u,\"metric\":\"%s%s\",\"serving\":%.2f,\"target\":%.2f," "\"held_ms\":%lld}", - (long long)tms, ue, from, to, metric.c_str(), sv, tv, (long long)held); + (long long)tms, ue, from, to, ue_reported ? "ue_" : "", metric.c_str(), sv, tv, + (long long)held); emit(b); }; auto log_clear = [&](uint16_t ue, uint16_t from, uint16_t to, const char *why) { @@ -571,9 +572,30 @@ int main() { for (const f1::UeMeas &q : dit->second.rep.meas) { if (!live(dit->second, q.age_ms)) continue; /* no valid serving measurement: handled below */ - const double serving = metric == "rssi" ? q.rssi_qdbm / 4.0 : q.snr_qdb / 4.0; + double serving = metric == "rssi" ? q.rssi_qdbm / 4.0 : q.snr_qdb / 4.0; std::map> neigh; + /* The UE's own measurements (its MR IE, relayed by this DU) when it + * reports them: the serving cell's beacon against the neighbours' + * (heard in measurement gaps on other channels) — comparable, and + * the only evidence across channels. Else what the DUs hear. */ + bool ue_reported = false; + for (const f1::UeCellMeas &m : dit->second.rep.ue_meas) + if (m.ue == q.ue && m.cell == cell && live(dit->second, m.age_ms)) { + serving = metric == "rssi" ? m.rssi_qdbm / 4.0 : m.snr_qdb / 4.0; + ue_reported = true; + } + if (ue_reported) { + for (long oid : expected) + if (oid != cell) + neigh[static_cast(oid)] = std::nullopt; + for (const f1::UeCellMeas &m : dit->second.rep.ue_meas) + if (m.ue == q.ue && m.cell != cell && neigh.count(m.cell) && + live(dit->second, m.age_ms)) + neigh[m.cell] = metric == "rssi" ? m.rssi_qdbm / 4.0 : m.snr_qdb / 4.0; + } for (long oid : expected) { + if (ue_reported) + break; if (oid == cell) continue; std::optional v; @@ -609,7 +631,7 @@ int main() { if (tgt) { const auto since = st.a3.holding_since(*tgt); log_candidate(q.ue, cell, *tgt, serving, neigh.at(*tgt).value_or(0), - since ? tms - *since : 0); + since ? tms - *since : 0, ue_reported); } } st.firing = tgt; @@ -770,6 +792,20 @@ int main() { if (now < next_plan) continue; next_plan = now + std::chrono::milliseconds(plan_ms); + /* The neighbour lists (M6 measurement gaps): each fresh DU gets every + * other fresh DU's channel and TBTT phase, every round. */ + for (auto &kv : dus) { + if (now - kv.second.at >= std::chrono::seconds(3)) + continue; + f1::Neighbours nb; + nb.du = kv.first; + nb.seq = next_seq(); + for (const auto &ov : dus) + if (ov.first != kv.first && now - ov.second.at < std::chrono::seconds(3) && + nb.cells.size() < devourer::cell::kMaxNeighbours) + nb.cells.push_back({ov.first, ov.second.rep.channel, ov.second.rep.phase_us}); + send(kv.second, f1::encode_neighbours(nb, mac)); + } /* The keepalive, every round and whatever the planning state: every DU * keeps its current schedule (a fresh sequence each send) while one * DU cannot be planned (e.g. it is re-locking). Only the plan waits. */ From 7581772f820c2f6b67c450034c0eeaf788ca216a Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:57:16 +0300 Subject: [PATCH 2/2] cell M6 measurement gaps: Qodo review fixes UE gaps: - planned with a conservative retune bound for both retunes (CELL_UE_GAP_RETUNE_US floor, raised by any slower retune measured); - no gap across bands; - skipped on a late wake-up; - listening cut short when the outbound retune overruns; - a failed return is retried, then the beacon stops and the normal retune path recovers (cell.ue.gap_return_fail); - neighbours that never fit are parked; - each measurable neighbour is revisited every CELL_UE_GAP_SF; - TSF steps wait 4 superframes after a gap: the only uplink losses seen with gaps followed one with a step; the mechanism is not established. Freshness, IE v2: - NL and MR carry a tagged generation; UEs refuse an older NL, and DUs an MR not newer or far from their superframe; - MR entries carry their sample age, added to the report age. - A synced DU airs an empty NL too. UEs drop a list not heard for 3 s, and clear it on a handover. Provenance: a beacon is measured only if it carries that cell's valid NL IE. The residual risk (a replayed genuine beacon) is stated. CU: per neighbour, one comparable pair (the UE's or the DUs'), with the A3 run on margins. F1: ue_meas cap kMaxUeMeas = 216, serving entry first, whole UEs only, truncation logged. Rig (m6_measgap.sh, 8 runs, default A3): all pass, 21 gaps with no uplink lost. The same-channel m6_handover regression passes. ctest 92/92. The docs state the stationary-UE limit. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/cell-m6.md | 84 ++++++++--- examples/cell/main.cpp | 272 ++++++++++++++++++++++++---------- src/cell/F1.h | 6 +- src/cell/MeasGap.h | 93 +++++++----- tests/cell_m6_selftest.cpp | 29 +++- tools/ran-controller/main.cpp | 66 +++++---- 6 files changed, 382 insertions(+), 168 deletions(-) diff --git a/docs/cell-m6.md b/docs/cell-m6.md index 5f759dc3..c215122d 100644 --- a/docs/cell-m6.md +++ b/docs/cell-m6.md @@ -187,8 +187,8 @@ time, and a neighbour's on another channel in a measurement gap (`src/cell/MeasGap.h`). **Where to listen.** The CU sends each DU its neighbours' channels and TBTT -phases (F1 `Neighbours`, every round). The DU airs them as offsets on its -own grid, in a tagged NL IE: +phases (F1 `Neighbours`, every round). Every synced DU airs them as offsets +on its own grid, in a tagged NL IE with a generation, empty lists included: offset = (phase_neighbour − phase_serving) mod P @@ -196,35 +196,72 @@ On the shared timebase a neighbour's beacon always falls at that offset of the UE's serving superframe, so the UE needs nothing beyond its serving clock. -**The gap.** Every `CELL_UE_GAP_SF` (10) superframes, for one other-channel -neighbour in turn, the UE computes `gap_window()`: +- **Newer only:** a UE takes only a list generation at least as new as the + last it accepted. +- **Clearing:** it drops a list not heard for 3 s, and clears it on a + handover. +- **Provenance:** the NL's tag is also what lets a UE count a beacon as a + measurement of a cell. A frame merely carrying a cell-shaped address is not + measured. +- **Residual risk:** a genuine beacon replayed by someone else, which is + only ever the real cell's beacon at the replayer's power. + +**The gap.** Each measurable other-channel neighbour is revisited every +`CELL_UE_GAP_SF` (10) superframes; with several, the gaps are spaced +accordingly. For each, the UE computes `gap_window()`: [offset − retune − guard, offset + beacon + retune + guard) The window must clear the serving beacon and the UE's own DL and UL slots. -The retune budget is the last measured retune. If the window fits, the UE -retunes there (`FastRetune`), its RX path hears the neighbour's beacon, and -it retunes back. If it does not fit, the UE skips that neighbour -(`cell.ue.gap_skip`): no slot is ever sacrificed. - -**Reports.** The UE L3-filters every cell beacon it hears, serving or -neighbour, and reports the filtered RSSI/SNR in a tagged MR IE in its uplink -beacon, refreshed each `CELL_UE_MR_MS` (1 s). The serving DU relays it over -F1 (`Report.ue_meas`, with ages). - -**The A3 on UE reports.** For a UE that reports, the CU's A3 compares -downlink with downlink: the serving beacon against the neighbours' (metric -`ue_rssi`). Otherwise it falls back to what the DUs hear. -**Rig result** (testrig `scripts/m6_measgap.sh`, 5 runs). Cell 1 is on +- **Retune bound:** both retunes are planned with a conservative bound, + `CELL_UE_GAP_RETUNE_US` (30 ms), raised by any slower retune measured. A + band change, which takes the full channel path, never gets a gap. +- **No fit:** the UE skips that neighbour (`cell.ue.gap_skip`), parks it, and + re-checks it rarely. No slot is ever sacrificed. +- **Late wake-up:** if the UE wakes for a gap later than half the guard, it + skips it (`cell.ue.gap_late`). +- **Outbound overrun:** an overrun shortens the listening, never the return + budget. +- **Failed return:** a return that fails is retried. If it still fails, the + beacon is stopped and the normal retune path recovers + (`cell.ue.gap_return_fail`). +- **TSF steps after a gap:** these wait 4 superframes. On the rig, the only + uplink losses seen with gaps (twice, two beacons each, in 10 runs) followed + a gap with a TA step in the next pass; the 20 gaps without one lost + nothing. The mechanism is not established. With the hold, 8 runs and 21 + gaps lost none. + +**Reports.** The UE L3-filters every authenticated cell beacon it hears, +serving or neighbour. It reports the filtered RSSI/SNR, with each entry's +sample age, in a tagged MR IE in its uplink beacon, refreshed each +`CELL_UE_MR_MS` (1 s). +- **Freshness:** the MR's generation is the serving superframe, strictly + increasing. The DU takes only a generation newer than the last and near + its own count, so a replayed report is refused. +- **Relay:** the DU forwards it over F1 (`Report.ue_meas`). Ages are sample + age plus report age, each UE's serving entry goes first, and the cap is + 216, whole UEs only. + +**The A3 on UE reports.** For each neighbour, the CU's A3 uses one +comparable pair: +- the UE's own (serving beacon, neighbour beacon) when the MR has both + (metric `ue_rssi`); +- otherwise the DUs' (serving uplink, neighbour-heard uplink). + +The A3 runs on the margins, so a neighbour the UE did not report keeps its +DU-heard evidence. + +**Rig result** (testrig `scripts/m6_measgap.sh`; 5 runs, and 8 on the final +code). Cell 1 is on ch149 at phase 0; cell 2 is on ch157 at phase 60000 µs, so its beacon falls in an idle part of cell 1's grid. The A3 runs at its **defaults** (offset -3 dB, hysteresis 1 dB, time-to-trigger 640 ms), with nothing forced. All 5 -runs pass. +3 dB, hysteresis 1 dB, time-to-trigger 640 ms), with nothing forced. All +pass. | KPI | min | median | max | |---|---|---|---| -| gap deaf time (14 gaps) | 53.7 ms | 53.9 ms | 56.6 ms | +| gap deaf time (21 gaps, final code: 30 ms retune bound + 3 ms guard) | 60.0 ms | 60.1 ms | 60.2 ms | | neighbour beacon in gaps | −46 dBm | −46 dBm | −46 dBm | | A3 held before firing | 1002 ms | 1024 ms | 1024 ms | | switch after the network instant (the UE's poll latency) | 21 ms | 21 ms | 28 ms | @@ -352,6 +389,11 @@ superframe when there is nothing to serve. - **Measurement-gap scheduling:** a gap goes only where it clears the UE's own slots, so some phase layouts leave a neighbour unmeasurable. Placing cells' phases so every pair can measure each other is a CU planning task. +- **No crossing test:** every handover result here is from stationary UEs. + The trigger is either forced or the measured standing difference between + two cells. No A→B path-loss ramp has been run, so none of these numbers + validates a crossing. An emulated crossing (the source DU ramping its TX + power, which its beacons follow) is the way to get one. - **Security context:** the cells share one key on the rig (`CELL_PSK`). Pre-staging a per-cell context (802.11r-style) is open. - **EVM:** not in the measurement reports. diff --git a/examples/cell/main.cpp b/examples/cell/main.cpp index 882b6246..46b6e677 100644 --- a/examples/cell/main.cpp +++ b/examples/cell/main.cpp @@ -906,11 +906,13 @@ int run_du(IRadio *dev, const Cfg &c) { * grid (aired in the NL IE), and what each own UE reported measuring (its * MR IE), relayed in Report.ue_meas. */ std::vector nb_list; + uint32_t nl_gen = 1; /* bumped whenever the list changes (UEs refuse older) */ bool have_nb_seq = false; uint64_t last_nb_seq = 0; struct UeReport { std::vector m; int64_t at_ms = 0; + uint32_t gen = 0; }; std::map ue_reports; std::map ho_out; /* by (source) UE id */ @@ -1033,8 +1035,17 @@ int run_du(IRadio *dev, const Cfg &c) { } 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)) { + /* Fresh only: a generation newer than the last one taken from this + * UE, and near this DU's own superframe count (a replayed old + * report is refused). */ + const auto at = rx_tsf(p.RxAtrib.tsfl); + const uint32_t sf_now = at ? static_cast(*at / P) : 0; std::lock_guard lk(f1_mu); - ue_reports[ub->ue] = {*mr, now_ms()}; + auto &ur = ue_reports[ub->ue]; + const bool newer = ur.at_ms == 0 || static_cast(mr->gen - ur.gen) > 0; + const bool near = at && std::abs(static_cast(mr->gen - sf_now)) < 100; + if (newer && near) + ur = {mr->cells, now_ms(), mr->gen}; } } /* The UL slot of the grant the UE runs (by the epoch it reports). */ @@ -1153,20 +1164,34 @@ int run_du(IRadio *dev, const Cfg &c) { r.positions = layout.positions; r.ues = f1_view.ues; f1::fill_report_measurements(r, c.cell, meas_tab.snapshot(now_ms())); - /* The UEs' own measurements (their MR IEs), with the report age. */ + /* The UEs' own measurements (their MR IEs): each entry's age is its + * sample's plus the report's since; each UE's serving entry first, + * whole UEs only against the cap (a truncation is logged). */ + size_t dropped_ues = 0; for (auto it = ue_reports.begin(); it != ue_reports.end();) { - const int64_t age = now_ms() - it->second.at_ms; - if (age > 10000) { + const int64_t rep_age = now_ms() - it->second.at_ms; + if (it->second.at_ms == 0 || rep_age > 10000) { it = ue_reports.erase(it); continue; } - for (const auto &e : it->second.m) - if (r.ue_meas.size() < f1::kMaxHeard) - r.ue_meas.push_back({it->first, e.cell, static_cast(e.rssi_dbm * 4), - static_cast(e.snr_db * 4), - static_cast(std::min(age, 65535))}); + std::vector mine; + for (int serving_pass = 1; serving_pass >= 0; --serving_pass) + for (const auto &e : it->second.m) + if ((e.cell == c.cell) == (serving_pass == 1)) + mine.push_back({it->first, e.cell, static_cast(e.rssi_dbm * 4), + static_cast(e.snr_db * 4), + static_cast(std::min(e.age_ms + rep_age, 65535))}); + if (r.ue_meas.size() + mine.size() <= f1::kMaxUeMeas) + r.ue_meas.insert(r.ue_meas.end(), mine.begin(), mine.end()); + else + ++dropped_ues; ++it; } + if (dropped_ues) { + char l[120]; + snprintf(l, sizeof l, "{\"ev\":\"cell.f1.ue_meas_truncated\",\"ues\":%zu}", dropped_ues); + emit(l); + } if (f1_view.net_sf < 0) /* no network superframe yet: nothing to plan on */ r.ues.clear(); } @@ -1214,7 +1239,10 @@ int run_du(IRadio *dev, const Cfg &c) { continue; /* a replay or reordering */ have_nb_seq = true; last_nb_seq = nb->seq; - nb_list = std::move(v); + if (v != nb_list) { + nb_list = std::move(v); + ++nl_gen; + } continue; } if (f1::peek_type(buf, static_cast(n)) == f1::Type::Handover) { @@ -1521,6 +1549,7 @@ int run_du(IRadio *dev, const Cfg &c) { bool cu_alive = true; std::optional f1_due; std::vector nb_snap; + uint32_t nl_gen_snap = 0; std::map ho_in_snap; std::map ho_out_snap; std::set ho_cancel_snap; @@ -1534,6 +1563,7 @@ int run_du(IRadio *dev, const Cfg &c) { for (auto it = daps_ids.begin(); it != daps_ids.end();) it = it->second < sf ? daps_ids.erase(it) : std::next(it); nb_snap = nb_list; + nl_gen_snap = nl_gen; ho_in_snap = ho_in; ho_out_snap = ho_out; /* Kept until the live map no longer has the UE (a deferred or failed @@ -1859,10 +1889,12 @@ int run_du(IRadio *dev, const Cfg &c) { emit(l); } } - /* The neighbour list rides the beacon too (only on the shared timebase: - * its offsets are network-time facts). */ - if (synced && !nb_snap.empty()) { - const auto nl = devourer::cell::encode_nl_ie(c.cell, nb_snap, auth); + /* The neighbour list rides every beacon on the shared timebase (its + * offsets are network-time facts), with zero entries too: it is how UEs + * learn a list went away, and its tag is what proves a beacon is this + * cell's to anyone measuring it. */ + if (synced && f1_on) { + const auto nl = devourer::cell::encode_nl_ie(c.cell, nl_gen_snap, nb_snap, auth); ho_ies.insert(ho_ies.end(), nl.begin(), nl.end()); } if (changed || rar_now != rar_on_air || ho_ies != ho_on_air) { @@ -2075,6 +2107,17 @@ int run_ue(IRadio *dev, const Cfg &c) { * the serving DU airs, and the UE's own filtered beacon measurements * (serving and neighbours), reported in its uplink beacon (MR IE). */ std::vector nbrs; + int64_t nbrs_ms = 0; /* when the serving DU's NL was last heard */ + uint32_t nbrs_gen = 0; /* the newest NL generation accepted (this serving cell) */ + bool have_nbrs_gen = false; + uint32_t mr_gen = 0; /* the MR generation (strictly increasing) */ + /* Gap scheduling: the retune bound both retunes are planned with (the + * configured floor, raised by any slower retune measured), and neighbours + * parked after skipping (no slot clear for them) until a superframe. */ + const int64_t gap_retune_floor_us = envl("CELL_UE_GAP_RETUNE_US", 30000, 1000, 500000); + 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) */ 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 */ @@ -2142,10 +2185,23 @@ int run_ue(IRadio *dev, const Cfg &c) { /* Any cell DU's beacon (02:ce:11::00): the UE's measurement of * that cell — its serving one, a same-channel neighbour, or one heard * in a measurement gap. */ - if (p.Data[10] == 0x02 && p.Data[11] == 0xce && p.Data[12] == 0x11 && p.Data[15] == 0) - ue_meas.update(static_cast(p.Data[13] << 8 | p.Data[14]), 0, - static_cast(p.RxAtrib.rssi[0]) - 110.0, - static_cast(p.RxAtrib.snr[0]) / 2.0 /* raw dB*2 */, ue_ms()); + /* Only a beacon that proves it is that cell's (its tagged NL IE) is + * measured: a frame merely carrying a cell-shaped address is not. */ + if (p.Data[10] == 0x02 && p.Data[11] == 0xce && p.Data[12] == 0x11 && p.Data[15] == 0) { + const uint16_t bc = static_cast(p.Data[13] << 8 | p.Data[14]); + if (auto nl = devourer::cell::find_nl_ie(p.Data.data(), p.Data.size(), bc, auth)) { + 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()); + /* 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)) { + nbrs = nl->cells; + nbrs_gen = nl->gen; + have_nbrs_gen = true; + nbrs_ms = ue_ms(); + } + } + } if (std::memcmp(p.Data.data() + 10, du_mac, 6) != 0) { /* The handover target's beacons: its grant maps, buffered for the * switch (they do not feed the clock: that is the source's until @@ -2169,8 +2225,6 @@ int run_ue(IRadio *dev, const Cfg &c) { emit(l); } sync.Add(*eg, p.RxAtrib.tsfl); - if (auto nl = devourer::cell::find_nl_ie(p.Data.data(), p.Data.size(), cell_a.load(), auth)) - nbrs = *nl; if (me_a > 0) if (auto h = devourer::cell::find_ho_ie(p.Data.data(), p.Data.size(), cell_a.load(), static_cast(me_a.load()), auth)) @@ -2248,7 +2302,6 @@ int run_ue(IRadio *dev, const Cfg &c) { int64_t next_mr_ms = 0; int64_t next_gap_sf = -1; /* the superframe of the next measurement gap */ size_t gap_rr = 0; /* round-robin over the other-channel neighbours */ - int64_t last_retune_us = 25000; /* the gap budget: the last measured retune */ while (std::chrono::steady_clock::now() < deadline) { std::this_thread::sleep_for(std::chrono::milliseconds(100)); uint64_t t = 0, f = 0; @@ -2295,6 +2348,9 @@ int run_ue(IRadio *dev, const Cfg &c) { rxr_source = rxr; rxr = *rxr_target; rxr_target.reset(); + nbrs.clear(); /* the new serving cell airs its own list */ + have_nbrs_gen = false; + gap_parked.clear(); ho_pending.reset(); hrx = HarqReceiver{}; /* the target's DL sequence space */ force_beacon = true; @@ -2333,6 +2389,9 @@ int run_ue(IRadio *dev, const Cfg &c) { if (rxr_source) rxr = *rxr_source; rxr_source.reset(); + nbrs.clear(); + have_nbrs_gen = false; + gap_parked.clear(); ho_back.reset(); hrx = HarqReceiver{}; force_beacon = true; @@ -2586,10 +2645,15 @@ int run_ue(IRadio *dev, const Cfg &c) { for (const auto &e : ue_meas.snapshot(ue_ms())) if (mr.size() < devourer::cell::kMaxNeighbours + 1) mr.push_back({e.cell, static_cast(std::lround(std::clamp(e.rssi_dbm, -128.0, 127.0))), - static_cast(std::lround(std::clamp(e.snr_db, -128.0, 127.0)))}); + static_cast(std::lround(std::clamp(e.snr_db, -128.0, 127.0))), + static_cast(std::clamp(e.age_ms, 0, 65535))}); } if (!mr.empty()) { - const auto ie = devourer::cell::encode_mr_ie(cell_a.load(), static_cast(me), mr, auth); + /* Its generation: the serving superframe, strictly increasing (the + * DU refuses anything not newer, and far from its own count). */ + mr_gen = std::max(mr_gen + 1, static_cast(du_now / P)); + const auto ie = + devourer::cell::encode_mr_ie(cell_a.load(), static_cast(me), mr_gen, mr, auth); b.insert(b.end(), ie.begin(), ie.end()); } } @@ -2614,7 +2678,12 @@ int run_ue(IRadio *dev, const Cfg &c) { } } const int64_t step = ue_tsf_step(static_cast(t), du_now, ul_off, g->ta_us, P, 2); - if (step != 0) { + /* No TSF step right after a measurement gap: on the rig, both uplink + * losses seen with gaps (two beacons each) followed a gap with a step + * in the next pass; 20 gaps without one lost nothing. The mechanism is + * not established; the step simply waits a few superframes. */ + if (step != 0 && du_now / P < no_step_until_sf) { + } else if (step != 0) { /* The offset changes only on a write, so the loop's (t, f) pair gives * the offset this step must produce. */ const int64_t want_off = static_cast(t) - static_cast(f) + step; @@ -2653,80 +2722,133 @@ int run_ue(IRadio *dev, const Cfg &c) { emit(l); } /* A measurement gap (MeasGap.h): for a neighbour on ANOTHER channel, - * every gap_every_sf superframes, if a window around its beacon fits - * clear of the serving beacon and this UE's own DL/UL slots. The UE + * each revisited every gap_every_sf superframes, if a window around its + * beacon fits clear of the serving beacon and this UE's own DL/UL slots + * — planned with a CONSERVATIVE retune bound for both retunes. The UE * retunes there, hears the beacon (the RX path measures it), retunes - * back. Never during a handover. */ + * back. Never during a handover; never across bands (the full channel + * path); skipped if it wakes late; listening cut short if the outbound + * retune overran into the return budget. */ if (gap_every_sf > 0 && !ho_pending && !ho_back) { std::vector other; + const int64_t sfn = du_now / P; { std::lock_guard lk(mu); - for (const auto &n : nbrs) - if (n.channel != 0 && n.channel != cur_ch) + if (ue_ms() - nbrs_ms > 3000) /* no list heard lately: none */ + nbrs.clear(); + for (const auto &n : nbrs) { + auto pk = gap_parked.find(n.cell); + if (n.channel != 0 && n.channel != cur_ch && (pk == gap_parked.end() || sfn >= pk->second)) other.push_back(n); + } } - const int64_t sfn = du_now / P; + /* Each measurable neighbour every gap_every_sf: one gap per + * gap_every_sf / n superframes (at least 2 apart). */ + const int64_t every = + std::max(2, gap_every_sf / static_cast(std::max(1, other.size()))); if (next_gap_sf < 0) - next_gap_sf = sfn + gap_every_sf; + next_gap_sf = sfn + every; if (!other.empty() && sfn >= next_gap_sf) { - next_gap_sf = sfn + gap_every_sf; - const auto &n = other[gap_rr++ % other.size()]; + next_gap_sf = sfn + every; + const auto n = other[gap_rr++ % other.size()]; const auto &sl = grid->slots(); const std::vector busy = { {0, c.bcn_us}, {sl[g->dl_slot].start_us, sl[g->dl_slot].len_us}, {sl[g->ul_slot].start_us, sl[g->ul_slot].len_us}}; - const auto w = devourer::cell::gap_window(n.offset_us, 500, last_retune_us, gap_guard_us, P, busy); - char l[240]; + const bool cross_band = (cur_ch <= 14) != (n.channel <= 14); + const auto w = cross_band ? std::nullopt + : devourer::cell::gap_window(n.offset_us, 500, gap_retune_us, + gap_guard_us, P, busy); + char l[300]; if (!w) { + gap_parked[n.cell] = sfn + 50 * every; /* rarely re-checked */ snprintf(l, sizeof l, - "{\"ev\":\"cell.ue.gap_skip\",\"ue\":%d,\"cell\":%u,\"offset_us\":%d}", - me, n.cell, n.offset_us); + "{\"ev\":\"cell.ue.gap_skip\",\"ue\":%d,\"cell\":%u,\"offset_us\":%d," + "\"why\":\"%s\"}", + me, n.cell, n.offset_us, cross_band ? "band" : "slots"); emit(l); } else { - /* The window in the NEXT superframe, on the serving clock. */ + gap_parked.erase(n.cell); const int64_t start = (sfn + 1) * P + w->start; - /* The serving clock NOW (this pass has spent time since du_now). */ - int64_t now_du = du_now; - if (uint64_t t3 = 0, f3 = 0; dev->ReadTsfAndFreeRun(t3, f3)) { + auto serving_now = [&]() -> std::optional { + uint64_t t3 = 0, f3 = 0; + if (!dev->ReadTsfAndFreeRun(t3, f3)) + return std::nullopt; std::lock_guard lk(mu); - now_du = sync.RemoteForLocal(static_cast(wrap.extend(static_cast(f3)))); - } - const int64_t wait = start - now_du; + return sync.RemoteForLocal(static_cast(wrap.extend(static_cast(f3)))); + }; + const auto now_du = serving_now(); + const int64_t wait = now_du ? start - *now_du : -1; if (wait > 0 && wait < 2 * P) { std::this_thread::sleep_for(std::chrono::microseconds(wait)); - const auto g0 = std::chrono::steady_clock::now(); - bool ok = true; - try { - dev->FastRetune(n.channel); - } catch (const std::exception &) { - ok = false; - } - const auto g1 = std::chrono::steady_clock::now(); - if (ok) - last_retune_us = std::chrono::duration_cast(g1 - g0).count(); - /* Listen until the beacon has passed (+ the guard). */ - std::this_thread::sleep_until(g0 + std::chrono::microseconds(w->len - last_retune_us)); - try { - dev->FastRetune(cur_ch); - } catch (const std::exception &) { - ok = false; - } - const auto g2 = std::chrono::steady_clock::now(); - std::optional got; - { - std::lock_guard lk(mu); - for (const auto &e : ue_meas.snapshot(ue_ms())) - if (e.cell == n.cell && e.age_ms <= (g2 - g0) / std::chrono::milliseconds(1) + 1) - got = e; + /* Woke late? The window was checked for its planned start only. */ + const auto woke = serving_now(); + const int64_t late = woke ? *woke - start : gap_guard_us; + if (late > gap_guard_us / 2) { + snprintf(l, sizeof l, + "{\"ev\":\"cell.ue.gap_late\",\"ue\":%d,\"cell\":%u,\"late_us\":%lld}", + me, n.cell, (long long)late); + emit(l); + } else { + const auto g0 = std::chrono::steady_clock::now(); + /* The window's end on this clock: the radio must be back by it. */ + const auto win_end = g0 + std::chrono::microseconds(w->len - std::max(0, late)); + bool out_ok = true; + try { + dev->FastRetune(n.channel); + } catch (const std::exception &) { + out_ok = false; + } + const auto g1 = std::chrono::steady_clock::now(); + /* Listen until the beacon has passed, but never into the return + * budget (an outbound overrun shortens the listening). */ + const auto listen_until = win_end - std::chrono::microseconds(gap_retune_us); + if (out_ok && g1 < listen_until) + std::this_thread::sleep_until(listen_until); + const auto g1b = std::chrono::steady_clock::now(); + /* Back: retried; a radio that will not return is handed to the + * normal retune path (beacon stopped, never on the wrong channel). */ + bool back_ok = false; + for (int k = 0; k < 3 && !back_ok; ++k) { + try { + dev->FastRetune(cur_ch); + back_ok = true; + } catch (const std::exception &) { + } + } + const auto g2 = std::chrono::steady_clock::now(); + if (!back_ok) { + if (beaconing && dev->StopBeacon()) + beaconing = false; + retune_to = cur_ch; + snprintf(l, sizeof l, "{\"ev\":\"cell.ue.gap_return_fail\",\"ue\":%d,\"ch\":%u}", + me, cur_ch); + emit(l); + } + const int64_t out_us = std::chrono::duration_cast(g1 - g0).count(); + const int64_t back_us = std::chrono::duration_cast(g2 - g1b).count(); + no_step_until_sf = du_now / P + 4; /* see the TSF step below */ + /* The bound only grows with what was measured (never below the floor). */ + gap_retune_us = std::max({gap_retune_floor_us, gap_retune_us, out_us, back_us}); + std::optional got; + { + std::lock_guard lk(mu); + for (const auto &e : ue_meas.snapshot(ue_ms())) + if (e.cell == n.cell && e.age_ms <= (g2 - g0) / std::chrono::milliseconds(1) + 1) + got = e; + } + snprintf(l, sizeof l, + "{\"ev\":\"cell.ue.gap\",\"ue\":%d,\"cell\":%u,\"channel\":%u," + "\"heard\":%d,\"rssi\":%.1f,\"deaf_us\":%lld,\"out_us\":%lld," + "\"back_us\":%lld,\"over_us\":%lld,\"ok\":%d}", + me, n.cell, n.channel, got ? 1 : 0, got ? got->rssi_dbm : 0.0, + (long long)std::chrono::duration_cast(g2 - g0).count(), + (long long)out_us, (long long)back_us, + (long long)std::chrono::duration_cast(g2 - win_end).count(), + out_ok && back_ok ? 1 : 0); + emit(l); } - snprintf(l, sizeof l, - "{\"ev\":\"cell.ue.gap\",\"ue\":%d,\"cell\":%u,\"channel\":%u," - "\"heard\":%d,\"rssi\":%.1f,\"deaf_us\":%lld,\"ok\":%d}", - me, n.cell, n.channel, got ? 1 : 0, got ? got->rssi_dbm : 0.0, - (long long)std::chrono::duration_cast(g2 - g0).count(), - ok ? 1 : 0); - emit(l); } } } diff --git a/src/cell/F1.h b/src/cell/F1.h index 9e217d54..81a266b2 100644 --- a/src/cell/F1.h +++ b/src/cell/F1.h @@ -79,6 +79,8 @@ constexpr size_t kMaxFixed = 8; constexpr size_t kMaxPositions = kMaxUesPerCell; constexpr size_t kMaxUes = kMaxUesPerCell; constexpr size_t kMaxHeard = 64; +/* Every UE of a full cell with a full MR (serving + kMaxNeighbours). */ +constexpr size_t kMaxUeMeas = kMaxUesPerCell * (kMaxNeighbours + 1); /* 216 < 256 */ enum class Type : uint8_t { Report = 1, Schedule = 2, Ack = 3, Handover = 4, Uplink = 5, Neighbours = 6 }; enum class AckStatus : uint8_t { Staged = 0, Applied = 1, Stale = 2, Late = 3, Invalid = 4 }; @@ -303,7 +305,7 @@ inline std::vector encode_report(const Report &r, const Mac &mac) { using namespace detail; if (r.fixed.size() > kMaxFixed || r.positions.size() > kMaxPositions || r.ues.size() > kMaxUes || r.heard.size() > kMaxHeard || r.meas.size() > kMaxUes || - r.ue_meas.size() > kMaxHeard) + r.ue_meas.size() > kMaxUeMeas) return {}; auto m = head(Type::Report); put(m, r.du, 2); @@ -412,7 +414,7 @@ inline std::optional decode_report(const uint8_t *p, size_t n, const Mac x.meas.push_back(q); } const size_t nu = r.get(1); - if (nu > kMaxHeard) + if (nu > kMaxUeMeas) return std::nullopt; for (size_t i = 0; i < nu && r.ok; ++i) { UeCellMeas q; diff --git a/src/cell/MeasGap.h b/src/cell/MeasGap.h index d46c2633..37d46763 100644 --- a/src/cell/MeasGap.h +++ b/src/cell/MeasGap.h @@ -10,15 +10,21 @@ * offset = (phase_neighbour - phase_serving) mod P, * so the UE needs nothing beyond its serving clock. * - * NL IE (the serving DU's beacon; the CU's neighbour list, F1 Neighbours): - * 'N' 'L' ver(1)=1 cell(2) n(1) n x {cell(2) channel(1) offset_us(4)} - * crc16(2) tag(8) + * NL IE (every synced DU beacon, also with zero entries; the CU's list): + * 'N' 'L' ver(1)=2 cell(2) gen(4) n(1) n x {cell(2) channel(1) + * offset_us(4)} crc16(2) tag(8) * MR IE (the UE's uplink beacon; relayed by the serving DU over F1): - * 'M' 'R' ver(1)=1 cell(2) ue(2) n(1) n x {cell(2) rssi_dbm(1, signed) - * snr_db(1, signed)} crc16(2) tag(8) + * 'M' 'R' ver(1)=2 cell(2) ue(2) gen(4) n(1) n x {cell(2) + * rssi_dbm(1, signed) snr_db(1, signed) age_ms(2)} crc16(2) tag(8) * Both carry the CRC (a torn body) and the cell key's tag (labels 'N' / 'M', - * as the other scheduling frames), so a forged list cannot send UEs deaf, and - * a forged report cannot steer a handover. + * as the other scheduling frames): a forged list or report is inert. + * Freshness: `gen` is covered by the tag and only moves forward — the NL's + * per DU (a new list), the MR's per UE (each report, its serving superframe) + * — so a replayed older list or report is refused by its receiver; an MR + * entry's age is its sample's, so the CU never takes an expired sample for + * a fresh one. Provenance: a UE counts a beacon as a measurement of a cell + * only if it carries that cell's valid NL IE. The residual: a genuine beacon + * replayed by someone else (only the real cell's, at the replayer's power). * * gap_window(): where in its superframe a UE may go deaf for one neighbour * beacon — [offset - retune - guard, offset + beacon + retune + guard) — or @@ -41,8 +47,8 @@ namespace devourer::cell { -constexpr uint8_t kNlVersion = 1; -constexpr uint8_t kMrVersion = 1; +constexpr uint8_t kNlVersion = 2; +constexpr uint8_t kMrVersion = 2; constexpr size_t kMgTagLen = 8; constexpr size_t kMaxNeighbours = 8; @@ -57,8 +63,19 @@ struct MeasEntryUe { uint16_t cell = 0; int8_t rssi_dbm = 0; int8_t snr_db = 0; + uint16_t age_ms = 0; /* the sample's, at report time (saturates) */ bool operator==(const MeasEntryUe &) const = default; }; +struct NlIe { + uint32_t gen = 0; + std::vector cells; + bool operator==(const NlIe &) const = default; +}; +struct MrIe { + uint32_t gen = 0; + std::vector cells; + bool operator==(const MrIe &) const = default; +}; /* A neighbour's TBTT on the serving grid. */ inline int32_t neighbour_offset_us(int64_t phase_neighbour, int64_t phase_serving, int64_t period_us) { @@ -142,12 +159,14 @@ inline void for_each_ie(const uint8_t *mpdu, size_t len, uint8_t m0, uint8_t m1, } } /* namespace mg_detail */ -inline std::vector encode_nl_ie(uint16_t cell, const std::vector &n, const Mac &mac) { +inline std::vector encode_nl_ie(uint16_t cell, uint32_t gen, const std::vector &n, + const Mac &mac) { using namespace mg_detail; if (n.size() > kMaxNeighbours) return {}; std::vector b = {'N', 'L', kNlVersion}; put(b, cell, 2); + put(b, gen, 4); b.push_back(static_cast(n.size())); for (const Neighbour &x : n) { put(b, x.cell, 2); @@ -157,65 +176,69 @@ inline std::vector encode_nl_ie(uint16_t cell, const std::vector> find_nl_ie(const uint8_t *mpdu, size_t len, uint16_t cell, - const Mac &mac) { +/* The neighbour list in cell `cell`'s beacon (nullopt: none / not authentic). + * Its presence also authenticates the beacon as that cell's. */ +inline std::optional find_nl_ie(const uint8_t *mpdu, size_t len, uint16_t cell, const Mac &mac) { using namespace mg_detail; - std::optional> out; + std::optional out; for_each_ie(mpdu, len, 'N', 'L', [&](const uint8_t *b, size_t n) { const size_t body = open_ie(b, n, 'N', mac); - if (body < 6 || b[2] != kNlVersion || get(b + 3, 2) != cell) + if (body < 10 || b[2] != kNlVersion || get(b + 3, 2) != cell) return; - const size_t cnt = b[5]; - if (cnt > kMaxNeighbours || body != 6 + cnt * 7) + const size_t cnt = b[9]; + if (cnt > kMaxNeighbours || body != 10 + cnt * 7) return; - std::vector v; + NlIe x; + x.gen = static_cast(get(b + 5, 4)); for (size_t i = 0; i < cnt; ++i) { - const uint8_t *e = b + 6 + i * 7; - v.push_back({static_cast(get(e, 2)), e[2], - static_cast(static_cast(get(e + 3, 4)))}); + const uint8_t *e = b + 10 + i * 7; + x.cells.push_back({static_cast(get(e, 2)), e[2], + static_cast(static_cast(get(e + 3, 4)))}); } - out = std::move(v); + out = std::move(x); }); return out; } -inline std::vector encode_mr_ie(uint16_t cell, uint16_t ue, const std::vector &m, - const Mac &mac) { +inline std::vector encode_mr_ie(uint16_t cell, uint16_t ue, uint32_t gen, + const std::vector &m, const Mac &mac) { using namespace mg_detail; if (m.size() > kMaxNeighbours + 1) return {}; std::vector b = {'M', 'R', kMrVersion}; put(b, cell, 2); put(b, ue, 2); + put(b, gen, 4); b.push_back(static_cast(m.size())); for (const MeasEntryUe &x : m) { put(b, x.cell, 2); b.push_back(static_cast(x.rssi_dbm)); b.push_back(static_cast(x.snr_db)); + put(b, x.age_ms, 2); } return seal_ie(std::move(b), 'M', mac); } /* The measurement report in a UE beacon of cell `cell`'s UE `ue`. */ -inline std::optional> find_mr_ie(const uint8_t *mpdu, size_t len, uint16_t cell, - uint16_t ue, const Mac &mac) { +inline std::optional find_mr_ie(const uint8_t *mpdu, size_t len, uint16_t cell, uint16_t ue, + const Mac &mac) { using namespace mg_detail; - std::optional> out; + std::optional out; for_each_ie(mpdu, len, 'M', 'R', [&](const uint8_t *b, size_t n) { const size_t body = open_ie(b, n, 'M', mac); - if (body < 8 || b[2] != kMrVersion || get(b + 3, 2) != cell || get(b + 5, 2) != ue) + if (body < 12 || b[2] != kMrVersion || get(b + 3, 2) != cell || get(b + 5, 2) != ue) return; - const size_t cnt = b[7]; - if (cnt > kMaxNeighbours + 1 || body != 8 + cnt * 4) + const size_t cnt = b[11]; + if (cnt > kMaxNeighbours + 1 || body != 12 + cnt * 6) return; - std::vector v; + MrIe x; + x.gen = static_cast(get(b + 7, 4)); for (size_t i = 0; i < cnt; ++i) { - const uint8_t *e = b + 8 + i * 4; - v.push_back({static_cast(get(e, 2)), static_cast(e[2]), - static_cast(e[3])}); + const uint8_t *e = b + 12 + i * 6; + x.cells.push_back({static_cast(get(e, 2)), static_cast(e[2]), + static_cast(e[3]), static_cast(get(e + 4, 2))}); } - out = std::move(v); + out = std::move(x); }); return out; } diff --git a/tests/cell_m6_selftest.cpp b/tests/cell_m6_selftest.cpp index 87e516b6..bc3bba5a 100644 --- a/tests/cell_m6_selftest.cpp +++ b/tests/cell_m6_selftest.cpp @@ -446,11 +446,18 @@ int main() { std::vector nl = {{2, 157, 60000}, {3, 36, 81234}}; std::vector bcn(36, 0); bcn[0] = 0x80; - const auto ie = encode_nl_ie(1, nl, k); + const auto ie = encode_nl_ie(1, 7, nl, k); CHECK(!ie.empty()); bcn.insert(bcn.end(), ie.begin(), ie.end()); auto got = find_nl_ie(bcn.data(), bcn.size(), 1, k); - CHECK(got && *got == nl); + CHECK(got && got->cells == nl && got->gen == 7); /* the generation travels, tagged */ + /* An empty list is a valid NL (a list going away; the beacon's provenance). */ + std::vector eb(36, 0); + eb[0] = 0x80; + const auto eie = encode_nl_ie(1, 8, {}, k); + eb.insert(eb.end(), eie.begin(), eie.end()); + auto eg = find_nl_ie(eb.data(), eb.size(), 1, k); + CHECK(eg && eg->cells.empty() && eg->gen == 8); CHECK(!find_nl_ie(bcn.data(), bcn.size(), 2, k)); /* another cell's list */ CHECK(!find_nl_ie(bcn.data(), bcn.size(), 1, other)); /* a forged list is inert */ auto torn = bcn; @@ -459,16 +466,16 @@ int main() { auto data = bcn; data[0] = 0x08; CHECK(!find_nl_ie(data.data(), data.size(), 1, k)); /* beacons only */ - std::vector mr = {{1, -52, 38}, {2, -49, 41}}; + std::vector mr = {{1, -52, 38, 40}, {2, -49, 41, 2900}}; std::vector ub(36, 0); ub[0] = 0x80; - const auto mie = encode_mr_ie(1, 1, mr, k); + const auto mie = encode_mr_ie(1, 1, 12345, mr, k); ub.insert(ub.end(), mie.begin(), mie.end()); auto gm = find_mr_ie(ub.data(), ub.size(), 1, 1, k); - CHECK(gm && *gm == mr); + CHECK(gm && gm->cells == mr && gm->gen == 12345); /* sample ages + generation travel */ CHECK(!find_mr_ie(ub.data(), ub.size(), 1, 2, k)); /* another UE's */ CHECK(!find_mr_ie(ub.data(), ub.size(), 1, 1, other)); /* a forged report is inert */ - CHECK(encode_nl_ie(1, std::vector(kMaxNeighbours + 1), k).empty()); + CHECK(encode_nl_ie(1, 1, std::vector(kMaxNeighbours + 1), k).empty()); /* The rig grid (beacon 1500, DL 4000 x3 from 1500, turnaround 1000, UL * 2000 x3 from 14500), a UE in position 2: busy = the serving beacon, its * DL [5500, 9500), its UL [16500, 18500) with the uplink-beacon margin. */ @@ -491,6 +498,16 @@ int main() { f1::Report rr; rr.du = 1; rr.ue_meas = {{1, 1, -52 * 4, 38 * 4, 120}, {1, 2, -49 * 4 - 1, 41 * 4, 900}}; + /* A full cell's worth of UE measurements fits the dedicated cap. */ + f1::Report full; + full.du = 1; + full.ue_meas.resize(f1::kMaxUeMeas); + const auto wf = f1::encode_report(full, k); + CHECK(!wf.empty()); + auto df = f1::decode_report(wf.data(), wf.size(), k); + CHECK(df && df->ue_meas.size() == f1::kMaxUeMeas); + full.ue_meas.resize(f1::kMaxUeMeas + 1); + CHECK(f1::encode_report(full, k).empty()); const auto wr = f1::encode_report(rr, k); auto drr = f1::decode_report(wr.data(), wr.size(), k); CHECK(drr && drr->ue_meas == rr.ue_meas); diff --git a/tools/ran-controller/main.cpp b/tools/ran-controller/main.cpp index fab1d177..c0f866f9 100644 --- a/tools/ran-controller/main.cpp +++ b/tools/ran-controller/main.cpp @@ -572,40 +572,48 @@ int main() { for (const f1::UeMeas &q : dit->second.rep.meas) { if (!live(dit->second, q.age_ms)) continue; /* no valid serving measurement: handled below */ - double serving = metric == "rssi" ? q.rssi_qdbm / 4.0 : q.snr_qdb / 4.0; - std::map> neigh; - /* The UE's own measurements (its MR IE, relayed by this DU) when it - * reports them: the serving cell's beacon against the neighbours' - * (heard in measurement gaps on other channels) — comparable, and - * the only evidence across channels. Else what the DUs hear. */ - bool ue_reported = false; + /* Per neighbour, ONE comparable pair: the UE's own (serving beacon, + * neighbour beacon) when its MR has both, else the DUs' (serving + * uplink, neighbour-heard uplink). The A3 sees margins (neighbour - + * serving; the A3 condition depends on the difference only), so a + * neighbour the UE did not report keeps its DU-heard evidence. */ + auto val = [&](int16_t qdb_rssi, int16_t qdb_snr) { + return metric == "rssi" ? qdb_rssi / 4.0 : qdb_snr / 4.0; + }; + const double serving_du = val(q.rssi_qdbm, q.snr_qdb); + std::optional serving_ue; for (const f1::UeCellMeas &m : dit->second.rep.ue_meas) - if (m.ue == q.ue && m.cell == cell && live(dit->second, m.age_ms)) { - serving = metric == "rssi" ? m.rssi_qdbm / 4.0 : m.snr_qdb / 4.0; - ue_reported = true; - } - if (ue_reported) { - for (long oid : expected) - if (oid != cell) - neigh[static_cast(oid)] = std::nullopt; - for (const f1::UeCellMeas &m : dit->second.rep.ue_meas) - if (m.ue == q.ue && m.cell != cell && neigh.count(m.cell) && - live(dit->second, m.age_ms)) - neigh[m.cell] = metric == "rssi" ? m.rssi_qdbm / 4.0 : m.snr_qdb / 4.0; - } + if (m.ue == q.ue && m.cell == cell && live(dit->second, m.age_ms)) + serving_ue = val(m.rssi_qdbm, m.snr_qdb); + struct Pair { + double s, t; + bool ue; + }; + std::map pairs; + std::map> neigh; for (long oid : expected) { - if (ue_reported) - break; if (oid == cell) continue; - std::optional v; - auto oit = dus.find(static_cast(oid)); - if (oit != dus.end()) + const auto o = static_cast(oid); + std::optional ue_v, du_v; + if (serving_ue) + for (const f1::UeCellMeas &m : dit->second.rep.ue_meas) + if (m.ue == q.ue && m.cell == o && live(dit->second, m.age_ms)) + ue_v = val(m.rssi_qdbm, m.snr_qdb); + if (auto oit = dus.find(o); oit != dus.end()) for (const f1::HeardUe &h : oit->second.rep.heard) if (h.cell == cell && h.ue == q.ue && live(oit->second, h.age_ms)) - v = metric == "rssi" ? h.rssi_dbm : h.snr_db; - neigh[static_cast(oid)] = v; /* absent or expired: no evidence */ + du_v = metric == "rssi" ? h.rssi_dbm : h.snr_db; + if (ue_v) + pairs[o] = {*serving_ue, *ue_v, true}; + else if (du_v) + pairs[o] = {serving_du, *du_v, false}; + if (pairs.count(o)) + neigh[o] = pairs[o].t - pairs[o].s; + else + neigh[o] = std::nullopt; /* absent or expired: no evidence */ } + const double serving = 0.0; /* margins */ const auto key = std::make_pair(cell, q.ue); /* A just-moved UE's old key: the source's last (stale) reports must * not trigger again. Time-bound (stale window + release + a report), @@ -630,8 +638,8 @@ int main() { log_clear(q.ue, cell, *st.firing, tgt ? "retarget" : "condition"); if (tgt) { const auto since = st.a3.holding_since(*tgt); - log_candidate(q.ue, cell, *tgt, serving, neigh.at(*tgt).value_or(0), - since ? tms - *since : 0, ue_reported); + const Pair &pp = pairs.at(*tgt); + log_candidate(q.ue, cell, *tgt, pp.s, pp.t, since ? tms - *since : 0, pp.ue); } } st.firing = tgt;