diff --git a/docs/cell-m6.md b/docs/cell-m6.md index 5ef1221b..8ec75607 100644 --- a/docs/cell-m6.md +++ b/docs/cell-m6.md @@ -1696,6 +1696,92 @@ 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, 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, 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`. 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. 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 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. 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 + 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 +1898,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..29a70901 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 { @@ -1402,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 */ @@ -2147,7 +2149,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,10 +2204,13 @@ 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); - 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; @@ -2683,7 +2688,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 +2710,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; @@ -2759,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). */ @@ -3414,6 +3431,13 @@ 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; + /* 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. */ @@ -3496,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; @@ -3692,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(); @@ -3970,6 +3996,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 +4104,64 @@ 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) { + /* 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 && e.age_ms <= fresh_ms) + tg = e.rssi_dbm; + } + 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::Expired) { + 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(); + } 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() && + 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 +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.store(-1); /* another serving count */ char l[300]; snprintf(l, sizeof l, "{\"ev\":\"cell.ue.ho\",\"id\":%u,\"from\":%u,\"ue\":%u,\"to\":%u," @@ -4128,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); @@ -4144,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) { @@ -4172,6 +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.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}", @@ -4794,7 +4871,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..6e98fc9d 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,62 @@ 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 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, 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 (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) + 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 expiry_sf_; + 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"; + case ChoCondition::Why::Expired: + return "expired"; + 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..fe6cbfd2 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,56 @@ 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.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, true, -60.0, std::nullopt, 0) == W::None); + /* The target 2 dB ahead: below the margin. */ + 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, 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, 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, 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. --- */ @@ -861,11 +912,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..a1d2b42a 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)); @@ -341,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 @@ -1121,8 +1140,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 +1204,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); } } @@ -1214,8 +1241,11 @@ 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)); - if (there && tnet >= fl.cmd.effective_net_sf) { + 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)) { char b[220]; snprintf(b, sizeof b, "{\"ev\":\"ran.ho_done\",\"id\":%u,\"ue\":%u,\"from\":%u,\"to\":%u,"