diff --git a/docs/cell-m6.md b/docs/cell-m6.md index 046c3743..c215122d 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,116 @@ 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). 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 + +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. + +- **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. + +- **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 +pass. + +| KPI | min | median | max | +|---|---|---|---| +| 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 | +| 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 +386,14 @@ 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. +- **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 40c8d28a..46b6e677 100644 --- a/examples/cell/main.cpp +++ b/examples/cell/main.cpp @@ -902,6 +902,19 @@ 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; + 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 */ bool have_ho_seq = false; uint64_t last_ho_seq = 0; @@ -1021,6 +1034,19 @@ 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)) { + /* 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); + 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). */ std::optional slot; @@ -1138,6 +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): 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 rep_age = now_ms() - it->second.at_ms; + if (it->second.at_ms == 0 || rep_age > 10000) { + it = ue_reports.erase(it); + continue; + } + 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(); } @@ -1171,6 +1225,26 @@ 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; + if (v != nb_list) { + nb_list = std::move(v); + ++nl_gen; + } + 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 +1548,8 @@ 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; + uint32_t nl_gen_snap = 0; std::map ho_in_snap; std::map ho_out_snap; std::set ho_cancel_snap; @@ -1486,6 +1562,8 @@ 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; + 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 @@ -1811,6 +1889,14 @@ int run_du(IRadio *dev, const Cfg &c) { emit(l); } } + /* 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) { if (changed) { ++next.epoch; @@ -2017,6 +2103,31 @@ 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; + 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 */ + 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 +2182,26 @@ 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. */ + /* 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 @@ -2168,6 +2299,9 @@ 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 */ while (std::chrono::steady_clock::now() < deadline) { std::this_thread::sleep_for(std::chrono::milliseconds(100)); uint64_t t = 0, f = 0; @@ -2214,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; @@ -2252,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; @@ -2488,12 +2628,35 @@ 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))), + static_cast(std::clamp(e.age_ms, 0, 65535))}); + } + if (!mr.empty()) { + /* 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()); + } + } const bool ok = beaconing ? dev->UpdateBeaconPayload(b.data(), b.size()) : dev->StartBeacon(b.data(), b.size(), c.tu); if (!ok) { @@ -2515,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; @@ -2553,6 +2721,138 @@ 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, + * 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; 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); + 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); + } + } + /* 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 + every; + if (!other.empty() && sfn >= next_gap_sf) { + 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 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," + "\"why\":\"%s\"}", + me, n.cell, n.offset_us, cross_band ? "band" : "slots"); + emit(l); + } else { + gap_parked.erase(n.cell); + const int64_t start = (sfn + 1) * P + w->start; + auto serving_now = [&]() -> std::optional { + uint64_t t3 = 0, f3 = 0; + if (!dev->ReadTsfAndFreeRun(t3, f3)) + return std::nullopt; + std::lock_guard lk(mu); + 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)); + /* 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); + } + } + } + } + } } if (beaconing) dev->StopBeacon(); diff --git a/src/cell/F1.h b/src/cell/F1.h index a7b886cd..81a266b2 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,19 @@ 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; +/* 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 }; +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 +102,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 +144,7 @@ struct Report { std::vector ues; std::vector heard; std::vector meas; + std::vector ue_meas; bool operator==(const Report &) const = default; }; @@ -280,7 +295,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 +304,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() > kMaxUeMeas) return {}; auto m = head(Type::Report); put(m, r.du, 2); @@ -328,6 +344,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 +413,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 > kMaxUeMeas) + 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 +574,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..37d46763 --- /dev/null +++ b/src/cell/MeasGap.h @@ -0,0 +1,273 @@ +/* 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 (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)=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): 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 + * 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 = 2; +constexpr uint8_t kMrVersion = 2; +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; + 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) { + 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, 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); + 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). + * 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; + 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 < 10 || b[2] != kNlVersion || get(b + 3, 2) != cell) + return; + const size_t cnt = b[9]; + if (cnt > kMaxNeighbours || body != 10 + cnt * 7) + return; + NlIe x; + x.gen = static_cast(get(b + 5, 4)); + for (size_t i = 0; i < cnt; ++i) { + 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(x); + }); + return out; +} + +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) { + 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 < 12 || b[2] != kMrVersion || get(b + 3, 2) != cell || get(b + 5, 2) != ue) + return; + const size_t cnt = b[11]; + if (cnt > kMaxNeighbours + 1 || body != 12 + cnt * 6) + return; + MrIe x; + x.gen = static_cast(get(b + 7, 4)); + for (size_t i = 0; i < cnt; ++i) { + 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(x); + }); + 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..bc3bba5a 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,84 @@ 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, 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->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; + 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, 40}, {2, -49, 41, 2900}}; + std::vector ub(36, 0); + ub[0] = 0x80; + 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->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, 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}}; + /* 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); + } + 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..c0f866f9 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,19 +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 */ - const double serving = metric == "rssi" ? q.rssi_qdbm / 4.0 : q.snr_qdb / 4.0; + /* 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_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 (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), @@ -608,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); + 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; @@ -770,6 +800,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. */