From 1809089b75058f68e2d254253ae13fd1fb111e7f Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:17:50 +0300 Subject: [PATCH 1/2] =?UTF-8?q?cell=20M6:=20the=20conditional=20handover?= =?UTF-8?q?=20=E2=80=94=20a=20source=20lost=20before=20the=20command=20(#5?= =?UTF-8?q?66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RAN_CHO=1 (F1 v18, HoIe v4, HoCommand.h ChoCondition): the CU prepares the move early (the A3 or RAN_HO_FORCE), the target grants the UE from the command, and the UE switches on its own condition: its serving beacons absent RAN_CHO_LOST_SF superframes, or its own measurement of the target ahead by RAN_CHO_MARGIN_DB for RAN_CHO_TTT_MS. None by the expiry: the UE stays, the target releases. Gaps go on through a conditional preparation; the DAPS visits run from it. Rig (testrig m6_cho.sh, 2 runs per case, cross-channel): source killed after the preparation, the UE moves 4 superframes after its last serving beacon with its DL intact (1.007-1.008 of UE2's PDUs, 0-1 uplinks lost); without it the UE is stranded (0.30-0.33). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/cell-m6.md | 81 ++++++++++++++++++++++++++++++++++- examples/cell/main.cpp | 81 +++++++++++++++++++++++++++++++---- src/cell/F1.h | 12 +++++- src/cell/HoCommand.h | 77 ++++++++++++++++++++++++++++++++- tests/cell_m6_selftest.cpp | 50 ++++++++++++++++++++- tools/ran-controller/main.cpp | 34 +++++++++++++-- 6 files changed, 314 insertions(+), 21 deletions(-) diff --git a/docs/cell-m6.md b/docs/cell-m6.md index 5ef1221b..3b1d1351 100644 --- a/docs/cell-m6.md +++ b/docs/cell-m6.md @@ -1696,6 +1696,78 @@ target on the arriving UE's in-slot uplinks: applied exactly, but a UE handed over soon after attaching carries a half-converged TA. +## The conditional handover (a source lost before the command) + +A handover command reaches the UE only through its source's beacon. A +source that fades or dies before the command leaves the UE with nothing to +act on. On the rig, with cell 2 on another channel and DU1 killed before any +command, UE1 was stranded: no switch, and no DL for the rest of the run +(31.5 % of the PDUs UE2 got over the same run). As a static UE it never +re-joins, and a re-join would go to its own cell anyway. + +**The conditional handover** (`RAN_CHO=1`; F1 v18, HoIe v4, +`HoCommand.h` `ChoCondition`): +- **The CU prepares early.** The A3, or `RAN_HO_FORCE`, issues the command + as before, but conditional: + - its instant is the expiry, `RAN_CHO_WINDOW_SF` [300] superframes on; + - it carries the UE's switch condition: `RAN_CHO_LOST_SF` [4], + `RAN_CHO_MARGIN_DB` [3] and `RAN_CHO_TTT_MS` [640]. + + Set the A3 offset below the margin to prepare before the move is due. +- **The target grants the UE from the command on,** not from an instant. + It publishes the arriving UE's grant as soon as it can be announced, so + the UE is granted whenever it lands. The silence clock starts at the + expiry: a UE that never came is released after it. +- **The UE decides.** Each pass, it switches at once if either holds: + - **`source_lost`:** it has heard no serving beacon for `cond_lost_sf` + superframes; + - **`target_better`:** its own filtered level of the target has led the + serving cell's by `cond_margin_db` for `cond_ttt_ms` + (`cell.ue.cho_exec`). + + The switch itself is the ordinary one: the TSF delta, the retune and the + target's identity. By the expiry without either, the UE stays and the + preparation lapses (`cell.ue.cho_expired`). +- **The preparation carries the DAPS work.** Across channels, the visits + and the target's DL answers run from the preparation, so the fade before + the move is covered. The measurement gaps go on through a conditional + preparation, since the UE's own measurement of the target is its + condition. The two share the UE's thread, and a gap whose start went by + is retried. +- **The source** relays the conditional IE until the expiry. It releases a + UE that went silent after the expiry, as after an ordinary instant. +- **The CU** concludes the move (`ran.ho_done`) when the target hears the + UE, at any time before the expiry. A flight is not dropped when its + source goes silent: the CU keeps every DU it has heard. + +**Rig result** (testrig `scripts/m6_cho.sh`): 2 runs per case, all pass. +- **The layout:** cell 1 on ch149, cell 2 on ch157 at phase 60000, the + CU's DL stream on, and the levels pinned, so cell 2 leads at UE1 by + ~11 dB. +- **The trigger:** the preparation is forced at 40 s, with the A3 held off. +- **Lost uplinks** are seq gaps at the CU. A stranded UE's uplinks just + stop, so the count does not apply to the baseline. + +| case | the move | DL new PDUs vs UE2 (same run) | longest DL silence | uplinks lost | +|---|---|---|---|---| +| prepared, then the source killed (margin 20 dB: never met) | `source_lost`, 4 superframes after its last serving beacon | 1.007, 1.008 | 248, 205 ms | 1, 0 | +| no preparation, the source killed | none: stranded | 0.304, 0.328 | — | — | +| prepared, no fault (margin 3 dB) | `target_better`, 8–9 superframes after the preparation | 1.006, 0.999 | 167, 205 ms | 0, 0 | +| prepared, margin never met, 150-superframe window | none: lapsed; the target released its reservation, cell 1 kept the UE | 1.008, 1.003 | 205 ms | 1, 0 | + +- **A lost source costs nothing measurable.** The UE moved at the 4th + silent superframe. In between, its visits put every uplink on the target + (145–149 visits per run, all sent), and the target's answers carried the + DL. The longest DL silence (205–248 ms) is the stream's own cadence: a + PDU every 100 ms against one DL frame per 102.4 ms superframe. +- **`target_better`** fired on the UE's own gap measurements: serving + −62 to −63 dBm, target −52 dBm, held the 640 ms. +- **The lapsed preparation** ran 131–132 visits and 95–96 gaps beside them + without a fault. Cell 1 heard the UE 629–647 times after the expiry. +- **The cost of preparing early** is the target's reserved position and, + across channels, the visit every superframe for as long as the + preparation stands. + ## The 802.11 roam baseline The M6 issue measured the handover "vs the ~100 ms roam ghost bar", but no @@ -1812,8 +1884,13 @@ replay checks caught it in the signed-list run: UE2 reported cell 1 in only - **DAPS across channels, beyond one visit per superframe:** the uplink and the downlink are duplicated through the visits, one per superframe from - the accept to the switch. A UE whose source fades before the command still - loses what it misses before the visits begin. + the accept to the switch. A source that fades before an ordinary command + still costs what is missed before the visits begin. The conditional + handover moves the accept to the preparation (see "The conditional + handover"). +- **When to prepare a conditional handover:** the rig forces the + preparation. On the A3 it is the offset below the margin; no policy + for that offset, or for re-preparing after a lapse, has been tuned. - **More than three cells, and a full tour:** the rig runs three cells, with handovers among them and a UE crossing 1 → 2 → 3. The way back into cell 1 is out of reach of the DUs' 20 dB TX range on this rig. Re-phasing diff --git a/examples/cell/main.cpp b/examples/cell/main.cpp index 22ac9c90..45541c6b 100644 --- a/examples/cell/main.cpp +++ b/examples/cell/main.cpp @@ -1395,6 +1395,7 @@ int run_du(IRadio *dev, const Cfg &c) { uint8_t pos = 0; uint64_t eff_sf = 0; int16_t ta_us = 0; /* the UE's TA at its source (HoCommand v17) */ + bool cond = false; /* conditional (F1 v18): eff_sf is the expiry, published at once */ }; std::map ta_report; /* f1_mu: each own UE's TA in the map on air */ struct HoOut { @@ -2147,7 +2148,7 @@ int run_du(IRadio *dev, const Cfg &c) { const auto eff = static_cast(static_cast(cmd.effective_net_sf) - off); { std::lock_guard lk(f1_mu); - ho_in[cmd.target_ue] = {cmd.id, cmd.target_pos, eff, cmd.ta_us}; + ho_in[cmd.target_ue] = {cmd.id, cmd.target_pos, eff, cmd.ta_us, cmd.conditional()}; } /* Pre-admit under exactly the id the CU gave it (the registry * hands out the lowest free one; the CU chose that one). */ @@ -2202,6 +2203,9 @@ int run_du(IRadio *dev, const Cfg &c) { ie.tsf_delta_us = cmd.tsf_delta_us; ie.channel = cmd.target_channel != du_ch.load() ? cmd.target_channel : 0; ie.ctx = cmd.ctx; /* the target's key for this UE: relayed, opaque */ + ie.cond_lost_sf = cmd.cond_lost_sf; /* conditional: relayed */ + ie.cond_margin_db = cmd.cond_margin_db; + ie.cond_ttt_ms = cmd.cond_ttt_ms; auto bytes = devourer::cell::encode_ho_ie(ie, auth); if (!bytes.empty()) { std::lock_guard lk(f1_mu); @@ -2683,7 +2687,10 @@ int run_du(IRadio *dev, const Cfg &c) { ho_expired.push_back(u.ue); continue; } - if (static_cast(sf) + f1c.publish_lead_sf < hi->second.eff_sf || f1_due || + /* Conditional: in force as soon as it can be announced (the UE + * may come any time before the expiry, eff_sf). */ + const bool cond = hi->second.cond; + if ((!cond && static_cast(sf) + f1c.publish_lead_sf < hi->second.eff_sf) || f1_due || ho_eff || !synced) continue; /* not yet (or another instant is being published, or no clock) */ set_position(g, hi->second.pos); @@ -2702,8 +2709,10 @@ int run_du(IRadio *dev, const Cfg &c) { * nothing of it); its DL queue is the DAPS buffer: kept. */ dlue.insert_or_assign(u.ue, make_dlue(u.ue)); dlue.at(u.ue).arriving = true; - last_ul_sf[u.ue] = static_cast(hi->second.eff_sf); /* silence clock at the switch */ - ho_eff = hi->second.eff_sf; + /* The silence clock from the switch (conditional: the expiry, so + * a UE that never came is released after it). */ + last_ul_sf[u.ue] = static_cast(hi->second.eff_sf); + ho_eff = cond ? static_cast(sf) + f1c.publish_lead_sf : hi->second.eff_sf; ho_eff_ue = u.ue; changed = true; continue; @@ -3414,6 +3423,11 @@ int run_ue(IRadio *dev, const Cfg &c) { * serving DU's beacon addressed to us, the one accepted (fresh, instant * ahead), and the target's grant maps heard while waiting for it. */ std::optional ho_seen, ho_pending; + /* A conditional handover's switch decision (HoCommand.h), and the last + * source superframe a serving beacon was heard in (-1 = none yet). */ + std::optional cho; + int64_t serv_bcn_sf = -1; + uint64_t serv_bcn_seen = 0; /* M5: the serving cell's channel move (ChannelSwitch.h), as seen in its * beacon, accepted (CsReceiver), and after it the way back if the cell is * not heard on the new channel. */ @@ -3970,6 +3984,7 @@ int run_ue(IRadio *dev, const Cfg &c) { no_step_until_sf = -1; ho_seen.reset(); /* commands aired by the old instance */ ho_pending.reset(); + cho.reset(); cs_seen.reset(); cs_pending.reset(); /* A target that restarted mid-handover lost the staged UE: the way @@ -4077,19 +4092,62 @@ int run_ue(IRadio *dev, const Cfg &c) { cell_mac(tgt_du_mac, ho_pending->target, 0); visit_tgt_cell = ho_pending->target; visit_tgt_ue = ho_pending->target_ue; - char l[220]; + if (ho_pending->conditional()) + cho.emplace(*ho_pending); + else + cho.reset(); + char l[240]; snprintf(l, sizeof l, "{\"ev\":\"cell.ue.ho_staged\",\"id\":%u,\"ue\":%u,\"from\":%u,\"to\":%u," - "\"target_ue\":%u,\"effective_sf\":%llu,\"sf\":%lld}", + "\"target_ue\":%u,\"effective_sf\":%llu,\"sf\":%lld,\"cond\":%d}", ho_pending->id, ho_pending->ue, ho_pending->cell, ho_pending->target, ho_pending->target_ue, (unsigned long long)ho_pending->effective_sf, - (long long)sfn); + (long long)sfn, ho_pending->conditional() ? 1 : 0); emit(l); } ho_seen.reset(); } - if (ho_pending && static_cast(sfn) >= ho_pending->effective_sf) { + /* The serving cell heard: its beacons, counted by the RX path. */ + if (const uint64_t nb = serv_bcn_n.load(); nb != serv_bcn_seen) { + serv_bcn_seen = nb; + serv_bcn_sf = sfn; + } + /* Conditional: the UE's own decision (its source silent, or the target + * ahead on its own measurements); none by the expiry = it stays. */ + bool cho_go = false; + if (ho_pending && ho_pending->conditional() && cho) { + std::optional sv, tg; + for (const auto &e : ue_meas.snapshot(ue_ms())) { + if (e.cell == ho_pending->cell) + sv = e.rssi_dbm; + if (e.cell == ho_pending->target) + tg = e.rssi_dbm; + } + const auto why = cho->update(sfn, serv_bcn_sf, sv, tg, ue_ms()); + char l[260]; + if (why != devourer::cell::ChoCondition::Why::None) { + cho_go = true; + snprintf(l, sizeof l, + "{\"ev\":\"cell.ue.cho_exec\",\"id\":%u,\"why\":\"%s\",\"sf\":%lld," + "\"expiry_sf\":%llu,\"serving_heard_sf\":%lld,\"serving_dbm\":%.1f,\"target_dbm\":%.1f}", + ho_pending->id, devourer::cell::cho_why_name(why), (long long)sfn, + (unsigned long long)ho_pending->effective_sf, (long long)serv_bcn_sf, + sv ? *sv : 0.0, tg ? *tg : 0.0); + emit(l); + } else if (static_cast(sfn) >= ho_pending->effective_sf) { + snprintf(l, sizeof l, "{\"ev\":\"cell.ue.cho_expired\",\"id\":%u,\"sf\":%lld}", + ho_pending->id, (long long)sfn); + emit(l); + ho_pending.reset(); + rxr_target.reset(); + visit_tgt_cell = 0; + cho.reset(); + } + } + if (ho_pending && (cho_go || (!ho_pending->conditional() && + static_cast(sfn) >= ho_pending->effective_sf))) { const devourer::cell::HoIe h = *ho_pending; + cho.reset(); const int64_t before = du_now; sync.ShiftRemote(h.tsf_delta_us); du_now = sync.RemoteForLocal(static_cast(wrap.extend(static_cast(f)))); @@ -4119,6 +4177,7 @@ int run_ue(IRadio *dev, const Cfg &c) { force_beacon = true; listen_from = -1; /* DL accounting restarts on the target's count */ sfn = du_now >= 0 ? du_now / P : 0; + serv_bcn_sf = -1; /* another serving count */ char l[300]; snprintf(l, sizeof l, "{\"ev\":\"cell.ue.ho\",\"id\":%u,\"from\":%u,\"ue\":%u,\"to\":%u," @@ -4172,6 +4231,7 @@ int run_ue(IRadio *dev, const Cfg &c) { listen_from = -1; rearm_ack = true; sfn = du_now >= 0 ? du_now / P : 0; + serv_bcn_sf = -1; /* another serving count */ char l[200]; snprintf(l, sizeof l, "{\"ev\":\"cell.ue.ho_fallback\",\"id\":%u,\"to\":%u,\"ue\":%u,\"sf\":%lld}", @@ -4794,7 +4854,10 @@ int run_ue(IRadio *dev, const Cfg &c) { break; } } - if ((gap_every_sf > 0 || gap_suspend_every > 0) && !ho_pending && !ho_back) { + /* Gaps go on through a conditional handover's preparation: the UE's own + * measurement of the target is its switch condition. */ + if ((gap_every_sf > 0 || gap_suspend_every > 0) && (!ho_pending || ho_pending->conditional()) && + !ho_back) { std::vector other, susp; const int64_t sfn = du_now / P; const auto &sl = grid->slots(); diff --git a/src/cell/F1.h b/src/cell/F1.h index f1d26e06..076bbc54 100644 --- a/src/cell/F1.h +++ b/src/cell/F1.h @@ -82,8 +82,10 @@ namespace devourer::cell::f1 { * Report carries the DU's suspend-gap period. */ /* 14: Uplink carries its reception instant (net_us). */ /* v14: Uplink.net_us; v15: ChannelSwitch; v16: EVM in HeardUe and UeMeas; - * v17: UeMeas.ta_us and HoCommand.ta_us (the TA carried in a handover) */ -constexpr uint8_t kVersion = 17; + * v17: UeMeas.ta_us and HoCommand.ta_us (the TA carried in a handover); + * v18: HoCommand's conditional fields (cond_lost_sf, cond_margin_db, + * cond_ttt_ms: the conditional handover) */ +constexpr uint8_t kVersion = 18; constexpr int8_t kNoEvm8 = INT8_MIN; /* HeardUe.evm_db: none measured */ constexpr int16_t kNoEvmQ = INT16_MIN; /* UeMeas.evm_qdb: none measured */ constexpr size_t kTagLen = 8; @@ -604,6 +606,9 @@ inline std::vector encode_handover(uint16_t du, const HoCommand &h, con m.insert(m.end(), h.ctx.begin(), h.ctx.end()); put_home(m, h.home); put(m, static_cast(h.ta_us), 2); + m.push_back(h.cond_lost_sf); + m.push_back(static_cast(h.cond_margin_db)); + put(m, h.cond_ttt_ms, 2); return seal(std::move(m), mac); } @@ -631,6 +636,9 @@ inline std::optional> decode_handover(const uint8 b = static_cast(r.get(1)); h.home = get_home(r); h.ta_us = static_cast(static_cast(r.get(2))); + h.cond_lost_sf = static_cast(r.get(1)); + h.cond_margin_db = static_cast(static_cast(r.get(1))); + h.cond_ttt_ms = static_cast(r.get(2)); if (!r.ok || r.i != r.n) return std::nullopt; return std::make_pair(du, h); diff --git a/src/cell/HoCommand.h b/src/cell/HoCommand.h index 88d94604..e09834ec 100644 --- a/src/cell/HoCommand.h +++ b/src/cell/HoCommand.h @@ -21,6 +21,7 @@ * The beacon IE (vendor 221, kGrantOui), little-endian: * 'H' 'O' ver(1)=2 id(4) cell(2) ue(2) target(2) target_ue(2) * dl_slot(1) ul_slot(1) channel(1) effective_sf(8) tsf_delta(8, signed) + * ctx(24) cond_lost_sf(1) cond_margin_db(1, signed) cond_ttt_ms(2) * crc16(2) tag(8) * `channel` is the target's: when it differs from the source's the UE * retunes at the switch (inter-frequency; the TSF delta holds regardless, @@ -30,6 +31,15 @@ * other scheduling frames, label 'H') makes a forged handover inert. An * unkeyed cell writes and ignores a zero tag (development). * + * Conditional (v4, `cond_lost_sf` > 0): the CU prepares the handover + * before it is needed and the UE decides when. `effective_sf` is then the + * EXPIRY. The UE switches at the first superframe in which it has heard no + * serving beacon for `cond_lost_sf` superframes (its source is gone), or in + * which its own measurement of the target has led the serving cell's by + * `cond_margin_db` for `cond_ttt_ms`. The target's grant is in force from + * the preparation, so the UE is granted wherever it lands. Neither by the + * expiry: the UE stays and the preparation lapses (ChoCondition below). + * * Replay: the tag stops forgery, not replay. HoReceiver (the UE side) acts * on an IE only if its id is above every one it accepted and its instant * is still ahead, so an old beacon replayed later is inert. @@ -54,7 +64,8 @@ namespace devourer::cell { /* 2: the target's channel (inter-frequency handover, the UE retunes). */ /* v3: the per-cell security context (src/cell/Keys.h) rides the IE. */ -constexpr uint8_t kHoIeVersion = 3; +/* v4: the conditional handover (cond_lost_sf, cond_margin_db, cond_ttt_ms). */ +constexpr uint8_t kHoIeVersion = 4; constexpr size_t kHoTagLen = 8; /* target TSF - source TSF, at any instant (see the header). */ @@ -87,6 +98,13 @@ struct HoCommand { * corrects the UE's clock and actuation phase, which do not change with * the cell. */ int16_t ta_us = 0; + /* Conditional (F1 v18; 0 = an ordinary command): effective_net_sf is the + * expiry, and the UE switches on its own condition before it (the IE + * header). The target publishes the UE's grant at once. */ + uint8_t cond_lost_sf = 0; /* serving beacons absent this many superframes */ + int8_t cond_margin_db = 0; /* the target's lead over the serving cell, at the UE */ + uint16_t cond_ttt_ms = 0; /* held this long */ + bool conditional() const { return cond_lost_sf != 0; } bool operator==(const HoCommand &) const = default; }; @@ -101,6 +119,10 @@ struct HoIe { uint64_t effective_sf = 0; /* in the source cell's count */ int64_t tsf_delta_us = 0; std::array ctx{}; /* HoCommand::ctx, relayed */ + uint8_t cond_lost_sf = 0; /* HoCommand's conditional fields, relayed */ + int8_t cond_margin_db = 0; + uint16_t cond_ttt_ms = 0; + bool conditional() const { return cond_lost_sf != 0; } bool operator==(const HoIe &) const = default; }; @@ -115,7 +137,7 @@ inline uint64_t get(const uint8_t *p, int n) { v |= static_cast(p[i]) << (8 * i); return v; } -constexpr size_t kBodyLen = 2 + 1 + 4 + 2 + 2 + 2 + 2 + 1 + 1 + 1 + 8 + 8 + 24; /* 'H' 'O' .. ctx */ +constexpr size_t kBodyLen = 2 + 1 + 4 + 2 + 2 + 2 + 2 + 1 + 1 + 1 + 8 + 8 + 24 + 1 + 1 + 2; /* 'H' 'O' .. cond */ inline bool tag(const Mac &mac, const uint8_t *body, size_t n, uint8_t out[kHoTagLen]) { if (!mac) { std::memset(out, 0, kHoTagLen); @@ -146,6 +168,9 @@ inline std::vector encode_ho_ie(const HoIe &h, const Mac &mac) { put(b, h.effective_sf, 8); put(b, static_cast(h.tsf_delta_us), 8); b.insert(b.end(), h.ctx.begin(), h.ctx.end()); + b.push_back(h.cond_lost_sf); + b.push_back(static_cast(h.cond_margin_db)); + put(b, h.cond_ttt_ms, 2); put(b, detail::crc16_ccitt(b.data(), b.size()), 2); uint8_t t[kHoTagLen]; if (!tag(mac, b.data(), b.size(), t)) @@ -188,6 +213,9 @@ inline std::optional decode_ho_body(const uint8_t *b, size_t n, const Mac h.effective_sf = get(b + 18, 8); h.tsf_delta_us = static_cast(get(b + 26, 8)); std::copy(b + 34, b + 58, h.ctx.begin()); + h.cond_lost_sf = b[58]; + h.cond_margin_db = static_cast(b[59]); + h.cond_ttt_ms = static_cast(get(b + 60, 2)); return h; } @@ -238,6 +266,51 @@ class HoReceiver { uint32_t last_id_ = 0; }; +/* The UE's side of a conditional handover: when to switch. Fed each pass + * with the UE's source superframe, the last one it heard a serving beacon + * in, and its own filtered levels of the serving and target cells (absent = + * nullopt: never a lead). Fires once: the source silent `lost_sf` + * superframes, or the target ahead by `margin_db` for `ttt_ms` + * (continuously: a pass without the lead restarts the hold). */ +class ChoCondition { +public: + enum class Why { None, SourceLost, TargetBetter }; + explicit ChoCondition(const HoIe &h) : lost_sf_(h.cond_lost_sf), margin_(h.cond_margin_db), ttt_ms_(h.cond_ttt_ms) {} + Why update(int64_t sf, int64_t last_serving_sf, std::optional serving_dbm, + std::optional target_dbm, int64_t now_ms) { + if (lost_sf_ > 0 && last_serving_sf >= 0 && sf - last_serving_sf >= lost_sf_) + return Why::SourceLost; + if (serving_dbm && target_dbm && *target_dbm - *serving_dbm >= margin_) { + if (since_ms_ < 0) + since_ms_ = now_ms; + if (now_ms - since_ms_ >= ttt_ms_) + return Why::TargetBetter; + } else { + since_ms_ = -1; + } + return Why::None; + } + /* How long the target has led, so far (ms; -1 = not leading). */ + int64_t held_ms(int64_t now_ms) const { return since_ms_ < 0 ? -1 : now_ms - since_ms_; } + +private: + int64_t lost_sf_; + int margin_; + int64_t ttt_ms_; + int64_t since_ms_ = -1; +}; + +inline const char *cho_why_name(ChoCondition::Why w) { + switch (w) { + case ChoCondition::Why::SourceLost: + return "source_lost"; + case ChoCondition::Why::TargetBetter: + return "target_better"; + default: + return "none"; + } +} + } /* namespace devourer::cell */ #endif /* DEVOURER_CELL_HOCOMMAND_H */ diff --git a/tests/cell_m6_selftest.cpp b/tests/cell_m6_selftest.cpp index ed98dd38..a066dec7 100644 --- a/tests/cell_m6_selftest.cpp +++ b/tests/cell_m6_selftest.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include "cell/F1.h" @@ -377,7 +378,7 @@ int main() { mpdu[0] = 0x80; for (const HoIe &x : {a, newer, someone_else}) { auto ie = encode_ho_ie(x, k); - CHECK(ie.size() == 2 + 3 + 58 + 2 + kHoTagLen); + CHECK(ie.size() == 2 + 3 + 62 + 2 + kHoTagLen); mpdu.insert(mpdu.end(), ie.begin(), ie.end()); } auto got = find_ho_ie(mpdu.data(), mpdu.size(), 1, 1, k); @@ -398,6 +399,47 @@ int main() { auto not_bcn = mpdu; not_bcn[0] = 0x08; /* a data frame carrying the same bytes */ CHECK(!find_ho_ie(not_bcn.data(), not_bcn.size(), 1, 1, k)); + /* v4: the conditional fields ride the IE (covered by CRC and tag). */ + HoIe cond = a; + cond.cond_lost_sf = 4; + cond.cond_margin_db = -3; + cond.cond_ttt_ms = 640; + std::vector m3(36, 0); + m3[0] = 0x80; + const auto cie = encode_ho_ie(cond, k); + m3.insert(m3.end(), cie.begin(), cie.end()); + auto gc = find_ho_ie(m3.data(), m3.size(), 1, 1, k); + CHECK(gc && *gc == cond && gc->conditional() && !a.conditional()); + m3[m3.size() - kHoTagLen - 2 - 3] ^= 0x01; /* the margin, tampered */ + CHECK(!find_ho_ie(m3.data(), m3.size(), 1, 1, k)); + } + + /* --- ChoCondition: the UE's own switch decision. --- */ + { + HoIe h; + h.cond_lost_sf = 4; + h.cond_margin_db = 3; + h.cond_ttt_ms = 600; + using W = ChoCondition::Why; + ChoCondition c(h); + /* Serving heard, the target not measured: never. */ + CHECK(c.update(100, 99, -60.0, std::nullopt, 0) == W::None); + /* The target 2 dB ahead: below the margin. */ + CHECK(c.update(101, 100, -60.0, -58.0, 100) == W::None && c.held_ms(100) == -1); + /* 3 dB ahead from t=200: fires only after 600 ms of it. */ + CHECK(c.update(102, 101, -60.0, -57.0, 200) == W::None && c.held_ms(200) == 0); + CHECK(c.update(103, 102, -60.0, -57.0, 700) == W::None); + /* A pass without the lead restarts the hold. */ + CHECK(c.update(104, 103, -60.0, -59.0, 750) == W::None && c.held_ms(750) == -1); + CHECK(c.update(105, 104, -60.0, -56.0, 800) == W::None); + CHECK(c.update(111, 110, -60.0, -56.0, 1400) == W::TargetBetter); + /* The source silent: 3 superframes is not enough, 4 is. */ + ChoCondition d(h); + CHECK(d.update(203, 200, -60.0, -70.0, 0) == W::None); + CHECK(d.update(204, 200, -60.0, -70.0, 100) == W::SourceLost); + /* No serving beacon heard yet (-1): not a loss. */ + CHECK(d.update(500, -1, std::nullopt, std::nullopt, 200) == W::None); + CHECK(std::string(cho_why_name(W::SourceLost)) == "source_lost"); } /* --- Replay across beacons: the UE acts once, on a fresh command. --- */ @@ -861,11 +903,15 @@ int main() { hc.id = 9; hc.home = HomeId{true, 0, 0, jm}; hc.ta_us = -428; /* v17: the source's TA travels with the UE */ + hc.cond_lost_sf = 4; /* v18: a conditional command */ + hc.cond_margin_db = -2; + hc.cond_ttt_ms = 640; const auto wh = f1::encode_handover(2, hc, fake_mac(0xF1)); auto dh = f1::decode_handover(wh.data(), wh.size(), fake_mac(0xF1)); CHECK(dh && dh->second.home == hc.home && dh->second.ta_us == -428); + CHECK(dh && dh->second == hc && dh->second.conditional()); auto badk = wh; - badk[badk.size() - 8 - 2 - 7] = 2; /* home kind 2: unknown (before the 2-byte TA) */ + badk[badk.size() - 8 - 4 - 2 - 7] = 2; /* home kind 2: unknown (before the TA and cond) */ CHECK(!f1::decode_handover(badk.data(), badk.size(), Mac{})); /* Per-UE area keys: A_u from the UE's key, wrapped for one DU. */ const auto au = ue_area_key(fake_mac(0x11)); diff --git a/tools/ran-controller/main.cpp b/tools/ran-controller/main.cpp index 3382041e..7c769dec 100644 --- a/tools/ran-controller/main.cpp +++ b/tools/ran-controller/main.cpp @@ -36,6 +36,17 @@ // its UEs (F1 ChannelSwitch, ChannelSwitch.h) // RAN_HO_TA [1] carry the UE's TA at its source in the handover // command (the target's TA loop starts there) +// RAN_CHO [0] 1 = conditional handover (HoCommand.h): the A3 +// (or RAN_HO_FORCE) PREPARES the move, the UE +// makes it — when its serving beacons have been +// absent RAN_CHO_LOST_SF [4] superframes, or its +// own measurement of the target has led the +// serving cell's by RAN_CHO_MARGIN_DB [3] for +// RAN_CHO_TTT_MS [640]. The preparation lapses +// RAN_CHO_WINDOW_SF [300] superframes after the +// command; the target grants the UE from the +// command on. Set the A3 offset below the margin +// to prepare before the move is due. // RAN_CS_LEAD_SF [20] a move's instant, this many superframes ahead // RAN_CS_BACKOFF_S [30] a DU whose move was refused or timed out is not // asked again before this @@ -240,6 +251,11 @@ int main() { const uint64_t lead_sf = static_cast(envl("RAN_LEAD_SF", 12, 2, 100000)); const bool retune_apply = envl("RAN_RETUNE_APPLY", 0, 0, 1) != 0; const bool ho_ta = envl("RAN_HO_TA", 1, 0, 1) != 0; /* carry the source's TA in a handover */ + const bool cho = envl("RAN_CHO", 0, 0, 1) != 0; + const uint8_t cho_lost_sf = static_cast(envl("RAN_CHO_LOST_SF", 4, 1, 255)); + const int8_t cho_margin_db = static_cast(envl("RAN_CHO_MARGIN_DB", 3, -30, 30)); + const uint16_t cho_ttt_ms = static_cast(envl("RAN_CHO_TTT_MS", 640, 0, 60000)); + const uint64_t cho_window_sf = static_cast(envl("RAN_CHO_WINDOW_SF", 300, 24, 100000)); const uint64_t cs_lead_sf = static_cast(envl("RAN_CS_LEAD_SF", 20, 4, 100000)); const int cs_backoff_s = static_cast(envl("RAN_CS_BACKOFF_S", 30, 1, 3600)); const int plan_ms = static_cast(envl("RAN_PLAN_MS", 1000, 50, 60000)); @@ -1121,8 +1137,15 @@ int main() { cmd.target_channel = tr.channel; /* != the source's: inter-frequency */ const double age_us = std::chrono::duration(now - dit->second.at).count(); + /* Conditional: the instant is the expiry; the UE moves on its + * own condition before it. */ cmd.effective_net_sf = - sr.net_sf + static_cast(age_us / sr.period_us) + ho_lead_sf; + sr.net_sf + static_cast(age_us / sr.period_us) + (cho ? cho_window_sf : ho_lead_sf); + if (cho) { + cmd.cond_lost_sf = cho_lost_sf; + cmd.cond_margin_db = cho_margin_db; + cmd.cond_ttt_ms = cho_ttt_ms; + } cmd.tsf_delta_us = devourer::cell::ho_tsf_delta_us(sr.phase_us, sr.net_off, tr.phase_us, tr.net_off, sr.period_us); std::optional home; @@ -1178,10 +1201,11 @@ int main() { snprintf(b, sizeof b, "{\"ev\":\"ran.ho_cmd\",\"id\":%u,\"ue\":%u,\"from\":%u,\"to\":%u," "\"target_ue\":%u,\"pos\":%u,\"effective_net_sf\":%llu,\"delta_us\":%lld," - "\"ctx\":%d,\"home\":\"%s\",\"ta_us\":%d}", + "\"ctx\":%d,\"home\":\"%s\",\"ta_us\":%d,\"cond\":%d}", cmd.id, cmd.ue, cmd.source, cmd.target, cmd.target_ue, cmd.target_pos, (unsigned long long)cmd.effective_net_sf, (long long)cmd.tsf_delta_us, - ctx_ok ? 1 : 0, home ? home_name(*home).c_str() : "none", cmd.ta_us); + ctx_ok ? 1 : 0, home ? home_name(*home).c_str() : "none", cmd.ta_us, + cmd.conditional() ? 1 : 0); emit(b); } } @@ -1215,7 +1239,9 @@ int main() { bool there = false; for (const f1::UeMeas &q : tit->second.rep.meas) there = there || (q.ue == fl.cmd.target_ue && live(tit->second, q.age_ms)); - if (there && tnet >= fl.cmd.effective_net_sf) { + /* A conditional move happens whenever the UE decides (before the + * expiry): the target hearing it is the move. */ + if (there && (fl.cmd.conditional() || tnet >= fl.cmd.effective_net_sf)) { char b[220]; snprintf(b, sizeof b, "{\"ev\":\"ran.ho_done\",\"id\":%u,\"ue\":%u,\"from\":%u,\"to\":%u," From fb286c5d9ee9893aea7169f3ec1d8d4b3f7a95b4 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:28:25 +0300 Subject: [PATCH 2/2] cell M6 CHO (review): expiry first; source loss timed by the beacon; a fresh target level only; commit on a beacon of the new cell; the source releases a silent conditional UE; the CU needs a post-command target sample Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/cell-m6.md | 34 ++++++++++----- examples/cell/main.cpp | 79 +++++++++++++++++++++-------------- src/cell/HoCommand.h | 29 +++++++++---- tests/cell_m6_selftest.cpp | 33 +++++++++------ tools/ran-controller/main.cpp | 6 ++- 5 files changed, 118 insertions(+), 63 deletions(-) diff --git a/docs/cell-m6.md b/docs/cell-m6.md index 3b1d1351..8ec75607 100644 --- a/docs/cell-m6.md +++ b/docs/cell-m6.md @@ -1718,27 +1718,41 @@ re-joins, and a re-join would go to its own cell anyway. It publishes the arriving UE's grant as soon as it can be announced, so the UE is granted whenever it lands. The silence clock starts at the expiry: a UE that never came is released after it. -- **The UE decides.** Each pass, it switches at once if either holds: +- **The UE decides.** Each pass, the expiry comes first: at or past it the + preparation has lapsed, whatever else holds, and the UE stays + (`cell.ue.cho_expired`). Before it, the UE switches at once if either + holds (`cell.ue.cho_exec`): - **`source_lost`:** it has heard no serving beacon for `cond_lost_sf` - superframes; + superframes, or the serving map in force no longer grants it. The + beacon's own departure TSF times it, so a late loop pass does not + delay it. - **`target_better`:** its own filtered level of the target has led the - serving cell's by `cond_margin_db` for `cond_ttt_ms` - (`cell.ue.cho_exec`). + serving cell's by `cond_margin_db` for `cond_ttt_ms`. The target's + level counts only while fresh: within twice the gap revisit period, + and never under a second. A target that went quiet cannot finish the + hold on a cached value. The switch itself is the ordinary one: the TSF delta, the retune and the - target's identity. By the expiry without either, the UE stays and the - preparation lapses (`cell.ue.cho_expired`). + target's identity. Any switch, conditional or not, now commits on the + target's grant only once a beacon of the new serving cell has been heard + since the switch. A map cached from before the switch proves nothing + about the target now. - **The preparation carries the DAPS work.** Across channels, the visits and the target's DL answers run from the preparation, so the fade before the move is covered. The measurement gaps go on through a conditional preparation, since the UE's own measurement of the target is its condition. The two share the UE's thread, and a gap whose start went by is retried. -- **The source** relays the conditional IE until the expiry. It releases a - UE that went silent after the expiry, as after an ordinary instant. +- **The source** relays the conditional IE until the UE leaves. It + releases the UE (its grant, the IE on air, and its place in the CU's + view) once it has been silent `CELL_HO_RELEASE_SF` superframes, not at + the expiry. A UE still there but released by the source is no longer + granted, which is a `source_lost` of its own. - **The CU** concludes the move (`ran.ho_done`) when the target hears the - UE, at any time before the expiry. A flight is not dropped when its - source goes silent: the CU keeps every DU it has heard. + UE, at any time before the expiry. The sample must be taken after the + command was issued: an id's previous holder at the target may still have + a live measurement. A flight is not dropped when its source goes silent: + the CU keeps every DU it has heard. **Rig result** (testrig `scripts/m6_cho.sh`): 2 runs per case, all pass. - **The layout:** cell 1 on ch149, cell 2 on ch157 at phase 60000, the diff --git a/examples/cell/main.cpp b/examples/cell/main.cpp index 45541c6b..29a70901 100644 --- a/examples/cell/main.cpp +++ b/examples/cell/main.cpp @@ -1403,6 +1403,7 @@ int run_du(IRadio *dev, const Cfg &c) { uint64_t eff_sf = 0; uint16_t target = 0, target_ue = 0; std::vector ie; + bool cond = false; /* conditional: eff_sf is the expiry; the UE may leave any time before it */ }; std::map ho_in; /* by target UE id */ std::set ho_cancel; /* published arrivals the CU cancelled */ @@ -2209,7 +2210,7 @@ int run_du(IRadio *dev, const Cfg &c) { auto bytes = devourer::cell::encode_ho_ie(ie, auth); if (!bytes.empty()) { std::lock_guard lk(f1_mu); - ho_out[cmd.ue] = {cmd.id, ie.effective_sf, cmd.target, cmd.target_ue, bytes}; + ho_out[cmd.ue] = {cmd.id, ie.effective_sf, cmd.target, cmd.target_ue, bytes, cmd.conditional()}; /* DAPS: its target identity, heard here after the switch. */ daps_ids[{cmd.target, cmd.target_ue}] = static_cast(ie.effective_sf) + ho_release_sf; @@ -2768,18 +2769,25 @@ int run_du(IRadio *dev, const Cfg &c) { for (const auto &kv : ho_out_snap) { const uint16_t ue = kv.first; const HoOut &ho = kv.second; - if (static_cast(sf) <= ho.eff_sf + 2) { + auto h = heard_ul_sf.find(ue); + /* Conditional: the UE leaves whenever its condition holds, so a UE + * silent here release_sf superframes has gone (or is lost to this + * cell either way): released now, not held to the expiry — its grant, + * the IE on air and the CU's view of it go with it. Once out of the + * live map, the entry concludes. */ + const bool cond_gone = ho.cond && (!map.find(ue) || (h != heard_ul_sf.end() && + sf - h->second > static_cast(ho_release_sf))); + if (!cond_gone && static_cast(sf) <= ho.eff_sf + 2) { if (synced) ho_ies.insert(ho_ies.end(), ho.ie.begin(), ho.ie.end()); continue; } - if (static_cast(sf) < ho.eff_sf + static_cast(ho_release_sf)) + if (!cond_gone && static_cast(sf) < ho.eff_sf + static_cast(ho_release_sf)) continue; /* Its last uplink as ours in the switch superframe itself (+2: the * UE's poll and its beacon update) is still the old identity. */ - auto h = heard_ul_sf.find(ue); - const bool switched = - h == heard_ul_sf.end() || h->second <= static_cast(ho.eff_sf) + 2; + const bool switched = cond_gone || h == heard_ul_sf.end() || + h->second <= static_cast(ho.eff_sf) + 2; if (switched && map.find(ue)) { /* Released through the normal publish; this entry concludes once * the live map no longer has the UE (a deferred publish retries). */ @@ -3426,8 +3434,10 @@ int run_ue(IRadio *dev, const Cfg &c) { /* A conditional handover's switch decision (HoCommand.h), and the last * source superframe a serving beacon was heard in (-1 = none yet). */ std::optional cho; - int64_t serv_bcn_sf = -1; - uint64_t serv_bcn_seen = 0; + /* The serving superframe of the last serving beacon heard, from the + * beacon's own departure TSF (the RX path): a late loop pass does not + * delay a loss. -1 = none since the last switch. */ + std::atomic serv_bcn_sf{-1}; /* M5: the serving cell's channel move (ChannelSwitch.h), as seen in its * beacon, accepted (CsReceiver), and after it the way back if the cell is * not heard on the new channel. */ @@ -3510,6 +3520,7 @@ int run_ue(IRadio *dev, const Cfg &c) { int64_t delta_us = 0; int64_t deadline_sf = 0; /* in the target's count */ uint32_t id = 0; + uint64_t bcn_at = 0; /* serving beacons heard at the switch: commit only after a newer one */ }; std::optional ho_back; std::optional rxr_source; @@ -3706,6 +3717,7 @@ int run_ue(IRadio *dev, const Cfg &c) { if (auto cs = devourer::cell::find_cs_ie(p.Data.data(), p.Data.size(), cell_a.load(), auth)) cs_seen = cs; ++serv_bcn_n; + serv_bcn_sf.store(static_cast(*eg) / P); wrap.feed(p.RxAtrib.tsfl); if (reattach_sf > 0 && sync.Steps() != reattach_armed_steps) { reattach_armed_steps = sync.Steps(); @@ -4107,34 +4119,27 @@ int run_ue(IRadio *dev, const Cfg &c) { } ho_seen.reset(); } - /* The serving cell heard: its beacons, counted by the RX path. */ - if (const uint64_t nb = serv_bcn_n.load(); nb != serv_bcn_seen) { - serv_bcn_seen = nb; - serv_bcn_sf = sfn; - } - /* Conditional: the UE's own decision (its source silent, or the target - * ahead on its own measurements); none by the expiry = it stays. */ + /* Conditional: the UE's own decision (its source silent or no longer + * granting it, or the target ahead on its own measurements); at the + * expiry it has lapsed and the UE stays. */ bool cho_go = false; if (ho_pending && ho_pending->conditional() && cho) { + /* The target's level only while freshly measured: a target that went + * quiet must not finish the hold on a cached value (its gap revisit + * period, twice, and never under a second). */ + const int64_t fresh_ms = std::max(1000, 2 * gap_every_sf * P / 1000); std::optional sv, tg; for (const auto &e : ue_meas.snapshot(ue_ms())) { if (e.cell == ho_pending->cell) sv = e.rssi_dbm; - if (e.cell == ho_pending->target) + if (e.cell == ho_pending->target && e.age_ms <= fresh_ms) tg = e.rssi_dbm; } - const auto why = cho->update(sfn, serv_bcn_sf, sv, tg, ue_ms()); + const GrantMap *cm = rxr.current(static_cast(sfn)); + const bool grants = !cm || cm->find(static_cast(me_a.load())); + const auto why = cho->update(sfn, serv_bcn_sf.load(), grants, sv, tg, ue_ms()); char l[260]; - if (why != devourer::cell::ChoCondition::Why::None) { - cho_go = true; - snprintf(l, sizeof l, - "{\"ev\":\"cell.ue.cho_exec\",\"id\":%u,\"why\":\"%s\",\"sf\":%lld," - "\"expiry_sf\":%llu,\"serving_heard_sf\":%lld,\"serving_dbm\":%.1f,\"target_dbm\":%.1f}", - ho_pending->id, devourer::cell::cho_why_name(why), (long long)sfn, - (unsigned long long)ho_pending->effective_sf, (long long)serv_bcn_sf, - sv ? *sv : 0.0, tg ? *tg : 0.0); - emit(l); - } else if (static_cast(sfn) >= ho_pending->effective_sf) { + if (why == devourer::cell::ChoCondition::Why::Expired) { snprintf(l, sizeof l, "{\"ev\":\"cell.ue.cho_expired\",\"id\":%u,\"sf\":%lld}", ho_pending->id, (long long)sfn); emit(l); @@ -4142,6 +4147,15 @@ int run_ue(IRadio *dev, const Cfg &c) { rxr_target.reset(); visit_tgt_cell = 0; cho.reset(); + } else if (why != devourer::cell::ChoCondition::Why::None) { + cho_go = true; + snprintf(l, sizeof l, + "{\"ev\":\"cell.ue.cho_exec\",\"id\":%u,\"why\":\"%s\",\"sf\":%lld," + "\"expiry_sf\":%llu,\"serving_heard_sf\":%lld,\"serving_dbm\":%.1f,\"target_dbm\":%.1f}", + ho_pending->id, devourer::cell::cho_why_name(why), (long long)sfn, + (unsigned long long)ho_pending->effective_sf, (long long)serv_bcn_sf.load(), + sv ? *sv : 0.0, tg ? *tg : 0.0); + emit(l); } } if (ho_pending && (cho_go || (!ho_pending->conditional() && @@ -4177,7 +4191,7 @@ int run_ue(IRadio *dev, const Cfg &c) { force_beacon = true; listen_from = -1; /* DL accounting restarts on the target's count */ sfn = du_now >= 0 ? du_now / P : 0; - serv_bcn_sf = -1; /* another serving count */ + serv_bcn_sf.store(-1); /* another serving count */ char l[300]; snprintf(l, sizeof l, "{\"ev\":\"cell.ue.ho\",\"id\":%u,\"from\":%u,\"ue\":%u,\"to\":%u," @@ -4187,7 +4201,8 @@ int run_ue(IRadio *dev, const Cfg &c) { (long long)h.tsf_delta_us, (long long)before, (long long)du_now); emit(l); rearm_ack = true; - ho_back = HoBack{source_key, cur_ch, h.cell, h.ue, h.tsf_delta_us, sfn + ho_fallback_sf, h.id}; + ho_back = HoBack{source_key, cur_ch, h.cell, h.ue, h.tsf_delta_us, sfn + ho_fallback_sf, h.id, + serv_bcn_n.load()}; if (du_now >= 0) { restart_du = (sfn + 1) * P + 1000; next_pass_at = std::chrono::steady_clock::now() + std::chrono::microseconds(restart_du - du_now); @@ -4203,7 +4218,9 @@ int run_ue(IRadio *dev, const Cfg &c) { * with neither cell). */ if (ho_back) { const GrantMap *tm = rxr.current(static_cast(sfn)); - if (tm && tm->find(static_cast(me_a.load()))) { + /* ... and a beacon of the new serving cell heard since the switch: a + * map cached from before it proves nothing about the target now. */ + if (tm && tm->find(static_cast(me_a.load())) && serv_bcn_n.load() > ho_back->bcn_at) { ho_back.reset(); rxr_source.reset(); } else if (sfn >= ho_back->deadline_sf) { @@ -4231,7 +4248,7 @@ int run_ue(IRadio *dev, const Cfg &c) { listen_from = -1; rearm_ack = true; sfn = du_now >= 0 ? du_now / P : 0; - serv_bcn_sf = -1; /* another serving count */ + serv_bcn_sf.store(-1); /* another serving count */ char l[200]; snprintf(l, sizeof l, "{\"ev\":\"cell.ue.ho_fallback\",\"id\":%u,\"to\":%u,\"ue\":%u,\"sf\":%lld}", diff --git a/src/cell/HoCommand.h b/src/cell/HoCommand.h index e09834ec..6e98fc9d 100644 --- a/src/cell/HoCommand.h +++ b/src/cell/HoCommand.h @@ -267,18 +267,26 @@ class HoReceiver { }; /* The UE's side of a conditional handover: when to switch. Fed each pass - * with the UE's source superframe, the last one it heard a serving beacon - * in, and its own filtered levels of the serving and target cells (absent = - * nullopt: never a lead). Fires once: the source silent `lost_sf` - * superframes, or the target ahead by `margin_db` for `ttt_ms` - * (continuously: a pass without the lead restarts the hold). */ + * with the UE's source superframe, the superframe of the last serving beacon + * it heard (its own timestamp; -1 = none yet), whether the serving map in + * force still grants it, and its own filtered levels of the serving and + * target cells — the target's only while freshly measured (absent = nullopt: + * never a lead). In order: at or past the expiry it has lapsed, whatever + * else holds; the source silent `lost_sf` superframes, or no longer granting + * the UE (it released it), is SourceLost; the target ahead by `margin_db` + * for `ttt_ms` (continuously: a pass without the lead restarts the hold) is + * TargetBetter. */ class ChoCondition { public: - enum class Why { None, SourceLost, TargetBetter }; - explicit ChoCondition(const HoIe &h) : lost_sf_(h.cond_lost_sf), margin_(h.cond_margin_db), ttt_ms_(h.cond_ttt_ms) {} - Why update(int64_t sf, int64_t last_serving_sf, std::optional serving_dbm, + enum class Why { None, Expired, SourceLost, TargetBetter }; + explicit ChoCondition(const HoIe &h) + : expiry_sf_(static_cast(h.effective_sf)), lost_sf_(h.cond_lost_sf), margin_(h.cond_margin_db), + ttt_ms_(h.cond_ttt_ms) {} + Why update(int64_t sf, int64_t last_serving_sf, bool serving_grants, std::optional serving_dbm, std::optional target_dbm, int64_t now_ms) { - if (lost_sf_ > 0 && last_serving_sf >= 0 && sf - last_serving_sf >= lost_sf_) + if (sf >= expiry_sf_) + return Why::Expired; + if (!serving_grants || (lost_sf_ > 0 && last_serving_sf >= 0 && sf - last_serving_sf >= lost_sf_)) return Why::SourceLost; if (serving_dbm && target_dbm && *target_dbm - *serving_dbm >= margin_) { if (since_ms_ < 0) @@ -294,6 +302,7 @@ class ChoCondition { int64_t held_ms(int64_t now_ms) const { return since_ms_ < 0 ? -1 : now_ms - since_ms_; } private: + int64_t expiry_sf_; int64_t lost_sf_; int margin_; int64_t ttt_ms_; @@ -306,6 +315,8 @@ inline const char *cho_why_name(ChoCondition::Why w) { return "source_lost"; case ChoCondition::Why::TargetBetter: return "target_better"; + case ChoCondition::Why::Expired: + return "expired"; default: return "none"; } diff --git a/tests/cell_m6_selftest.cpp b/tests/cell_m6_selftest.cpp index a066dec7..fe6cbfd2 100644 --- a/tests/cell_m6_selftest.cpp +++ b/tests/cell_m6_selftest.cpp @@ -417,29 +417,38 @@ int main() { /* --- ChoCondition: the UE's own switch decision. --- */ { HoIe h; + h.effective_sf = 1000; /* the expiry */ h.cond_lost_sf = 4; h.cond_margin_db = 3; h.cond_ttt_ms = 600; using W = ChoCondition::Why; ChoCondition c(h); /* Serving heard, the target not measured: never. */ - CHECK(c.update(100, 99, -60.0, std::nullopt, 0) == W::None); + CHECK(c.update(100, 99, true, -60.0, std::nullopt, 0) == W::None); /* The target 2 dB ahead: below the margin. */ - CHECK(c.update(101, 100, -60.0, -58.0, 100) == W::None && c.held_ms(100) == -1); + CHECK(c.update(101, 100, true, -60.0, -58.0, 100) == W::None && c.held_ms(100) == -1); /* 3 dB ahead from t=200: fires only after 600 ms of it. */ - CHECK(c.update(102, 101, -60.0, -57.0, 200) == W::None && c.held_ms(200) == 0); - CHECK(c.update(103, 102, -60.0, -57.0, 700) == W::None); - /* A pass without the lead restarts the hold. */ - CHECK(c.update(104, 103, -60.0, -59.0, 750) == W::None && c.held_ms(750) == -1); - CHECK(c.update(105, 104, -60.0, -56.0, 800) == W::None); - CHECK(c.update(111, 110, -60.0, -56.0, 1400) == W::TargetBetter); + CHECK(c.update(102, 101, true, -60.0, -57.0, 200) == W::None && c.held_ms(200) == 0); + CHECK(c.update(103, 102, true, -60.0, -57.0, 700) == W::None); + /* A pass without the lead (or without a fresh target level) restarts the hold. */ + CHECK(c.update(104, 103, true, -60.0, std::nullopt, 750) == W::None && c.held_ms(750) == -1); + CHECK(c.update(105, 104, true, -60.0, -56.0, 800) == W::None); + CHECK(c.update(111, 110, true, -60.0, -56.0, 1400) == W::TargetBetter); /* The source silent: 3 superframes is not enough, 4 is. */ ChoCondition d(h); - CHECK(d.update(203, 200, -60.0, -70.0, 0) == W::None); - CHECK(d.update(204, 200, -60.0, -70.0, 100) == W::SourceLost); + CHECK(d.update(203, 200, true, -60.0, -70.0, 0) == W::None); + CHECK(d.update(204, 200, true, -60.0, -70.0, 100) == W::SourceLost); /* No serving beacon heard yet (-1): not a loss. */ - CHECK(d.update(500, -1, std::nullopt, std::nullopt, 200) == W::None); - CHECK(std::string(cho_why_name(W::SourceLost)) == "source_lost"); + CHECK(d.update(500, -1, true, std::nullopt, std::nullopt, 200) == W::None); + /* The serving map no longer grants it (the source released it): lost. */ + CHECK(d.update(501, 500, false, -60.0, -70.0, 300) == W::SourceLost); + /* At the expiry it has lapsed, even where a condition first holds there. */ + ChoCondition e(h); + CHECK(e.update(999, 998, true, -60.0, -70.0, 0) == W::None); + CHECK(e.update(1000, 990, true, -60.0, -70.0, 100) == W::Expired); /* silent 10 sf, but lapsed */ + CHECK(e.update(1001, 1000, false, -60.0, -50.0, 200) == W::Expired); + CHECK(std::string(cho_why_name(W::SourceLost)) == "source_lost" && + std::string(cho_why_name(W::Expired)) == "expired"); } /* --- Replay across beacons: the UE acts once, on a fresh command. --- */ diff --git a/tools/ran-controller/main.cpp b/tools/ran-controller/main.cpp index 7c769dec..a1d2b42a 100644 --- a/tools/ran-controller/main.cpp +++ b/tools/ran-controller/main.cpp @@ -357,6 +357,9 @@ int main() { struct HoFlight { devourer::cell::HoCommand cmd; bool src_ok = false, tgt_ok = false, switched = false; + /* When the command was issued: only a target measurement sampled after + * it is the UE (an id's previous holder may still have a live one). */ + std::chrono::steady_clock::time_point at = std::chrono::steady_clock::now(); }; std::map, HoFlight> flights; /* The per-cell security context (src/cell/Keys.h): with RAN_MASTER_KEY the @@ -1238,7 +1241,8 @@ int main() { tit->second.rep.net_sf + static_cast(age_us / tit->second.rep.period_us); bool there = false; for (const f1::UeMeas &q : tit->second.rep.meas) - there = there || (q.ue == fl.cmd.target_ue && live(tit->second, q.age_ms)); + there = there || (q.ue == fl.cmd.target_ue && live(tit->second, q.age_ms) && + tit->second.at - std::chrono::milliseconds(q.age_ms) > fl.at); /* A conditional move happens whenever the UE decides (before the * expiry): the target hearing it is the move. */ if (there && (fl.cmd.conditional() || tnet >= fl.cmd.effective_net_sf)) {