diff --git a/docs/cell-m6.md b/docs/cell-m6.md index fd8a0687..5ef1221b 100644 --- a/docs/cell-m6.md +++ b/docs/cell-m6.md @@ -9,7 +9,7 @@ is no scan, no clock re-acquisition and no association exchange. | piece | where | what | |---|---|---| | filtered measurements | `src/cell/Handover.h` `L3Filter`, `MeasTable` | 3GPP L3 filter per (cell, UE) over uplink RSSI/SNR, and EVM from the frames that carry one. Stale = absent, never weak. | -| measurement reports | F1 `Report.meas` / `heard` | each DU's own UEs and the other cells' UEs it hears, with sample ages; EVM since F1 v16 (none = a sentinel) | +| measurement reports | F1 `Report.meas` / `heard` | each DU's own UEs and the other cells' UEs it hears, with sample ages; EVM since F1 v16 (none = a sentinel); each own UE's TA since v17 | | A3 (CHO) condition | `Handover.h` `A3Trigger`; `tools/ran-controller` | offset, hysteresis, per-neighbour time-to-trigger, dwell | | the command | `src/cell/HoCommand.h` `HoCommand`, F1 `Type::Handover` | UE, target id and position, switch instant, TSF delta | | the UE's copy | `HoIe` in the source DU's beacon | CRC and cell-key tag (forgery is inert); `HoReceiver` rejects a replay | @@ -120,10 +120,10 @@ The first runs, single, measured with testrig `scripts/m5_ran_coord.sh` plus | source | released the UE at switch + 21 | kept the UE (still heard as its own): 1092 / 1092 uplinks in slot after the switch | | CU | `ran.ho_done` 5 superframes after the instant | `ran.ho_timeout` | -- **Timing advance:** the target's TA loop starts from 0 for the new UE. +- **Timing advance:** the target's TA loop started from 0 for the new UE. The first arrivals at the target sat about −418 µs from the UL target, the - same offset the source's TA had converged to. Carrying the TA in the - command is a possible refinement. + same offset the source's TA had converged to. The command now carries the + TA (F1 v17, see "The timing advance in the handover command"). - **The UE's uplink in the fault case:** while no target map is in force, the UE does not rebuild its uplink beacon. Its uplink never left the source, which is part of why the fallback is seamless. @@ -1660,6 +1660,42 @@ a standing difference defaults it to the medians of its passing runs: **Result:** on the changed rig every M6 suite passes again: handover, measgap, three_ho, daps, daps_dl, daps_xch, crossing3, crossing. +## The timing advance in the handover command + +The UEs on this rig run a TA of about −420 µs: their inherent actuation +offset, the same at every cell. A target whose TA loop starts from 0 sees +the arriving UE that far off and halves it every 2 s (`CELL_TA_EVERY_S`, +gain 0.5). Since F1 v17 the CU carries the TA instead: +- each DU reports its own UEs' TA in `UeMeas.ta_us`: the TA of each UE's + grant in the map on air, taken when a map goes on air (a failed publish, + a released ID or a pending loop step never reaches the report); +- the CU puts the source's value in `HoCommand.ta_us` (`RAN_HO_TA`, on by + default; `ran.ho_cmd` logs it); +- the target starts the arriving UE's TA loop and its first grant there at + it (`cell.ho.ta_carried`). The UE's first uplink on the target's grant is + already advanced. + +Rig, testrig `scripts/m6_ho_ta.sh`: UE1 cell 1 → 2, forced at 70 s once the +source's TA had converged, three interleaved A/B rounds, measured at the +target on the arriving UE's in-slot uplinks: + +| | TA carried (µs) | arrival error, first 40 uplinks (median, µs) | uplinks until within 50 µs | +|---|---|---|---| +| `RAN_HO_TA=1` | −426, −426, −425 | −2, −1, −1 | 0, 0, 0 | +| `RAN_HO_TA=0` | 0 | −427, −428.5, −425.5 | 98, 84, 79 | + +- **Slot hits do not change on this layout:** 38–39 of the first 40 + uplinks are in slot either way, since 427 µs still lands in the UL slot + (the misses are single stale-phase beacons). What the carried TA buys is + the guard margin from the first frame. It decides slot hits only where + the UL slot and guard are tighter than the UE's offset. +- **The carried value is only as good as the source's loop.** With the A3 + on the pinned levels, the handover fired ~4 s after the attach. The + command carried −207 to −211 µs (one TA step) or 0 (before the first), + and the target's first arrivals were −210 or −420 µs: the value is + applied exactly, but a UE handed over soon after attaching carries a + half-converged TA. + ## The 802.11 roam baseline The M6 issue measured the handover "vs the ~100 ms roam ghost bar", but no diff --git a/examples/cell/main.cpp b/examples/cell/main.cpp index ca7eaf52..22ac9c90 100644 --- a/examples/cell/main.cpp +++ b/examples/cell/main.cpp @@ -1394,7 +1394,9 @@ int run_du(IRadio *dev, const Cfg &c) { uint32_t id = 0; uint8_t pos = 0; uint64_t eff_sf = 0; + int16_t ta_us = 0; /* the UE's TA at its source (HoCommand v17) */ }; + std::map ta_report; /* f1_mu: each own UE's TA in the map on air */ struct HoOut { uint32_t id = 0; uint64_t eff_sf = 0; @@ -1886,6 +1888,9 @@ int run_du(IRadio *dev, const Cfg &c) { if (auto h = ue_home.find(u.ue); h != ue_home.end() && r.homes.size() < f1::kMaxUes) r.homes.push_back({u.ue, h->second}); f1::fill_report_measurements(r, c.cell, meas_tab.snapshot(now_ms())); + for (auto &q : r.meas) /* each own UE's TA now (carried on a handover, v17) */ + if (auto t = ta_report.find(q.ue); t != ta_report.end()) + q.ta_us = t->second; /* 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). */ @@ -2142,7 +2147,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}; + ho_in[cmd.target_ue] = {cmd.id, cmd.target_pos, eff, cmd.ta_us}; } /* Pre-admit under exactly the id the CU gave it (the registry * hands out the lowest free one; the CU chose that one). */ @@ -2682,8 +2687,17 @@ int run_du(IRadio *dev, const Cfg &c) { ho_eff || !synced) continue; /* not yet (or another instant is being published, or no clock) */ set_position(g, hi->second.pos); + /* The UE's TA at its source: its clock and actuation phase do not + * change with the cell, so the target's loop starts there. */ + g.ta_us = hi->second.ta_us; next.grants.push_back(g); - reg.context(u.ue); + reg.context(u.ue).ta.set_ta(hi->second.ta_us); + if (hi->second.ta_us) { + char l[120]; + snprintf(l, sizeof l, "{\"ev\":\"cell.ho.ta_carried\",\"ue\":%u,\"ta_us\":%d}", u.ue, + hi->second.ta_us); + emit(l); + } /* Fresh DL state (an id reused after another UE left keeps * nothing of it); its DL queue is the DAPS buffer: kept. */ dlue.insert_or_assign(u.ue, make_dlue(u.ue)); @@ -3041,6 +3055,15 @@ int run_du(IRadio *dev, const Cfg &c) { } prev_map = map; map = next; + { + /* The TA each UE runs is its grant's in the map now on air: the + * report carries exactly that (a carried arrival's at once, a + * released ID's never, a failed publish's not at all). */ + std::lock_guard lk(f1_mu); + ta_report.clear(); + for (const Grant &g : map.grants) + ta_report[g.ue] = g.ta_us; + } if (f1_eff) { std::lock_guard lk(f1_mu); /* due() released it this pass and the deadline was checked just diff --git a/src/cell/F1.h b/src/cell/F1.h index ec63b813..f1d26e06 100644 --- a/src/cell/F1.h +++ b/src/cell/F1.h @@ -81,8 +81,9 @@ namespace devourer::cell::f1 { /* 12: Downlink (CU -> DU, the CU's DL PDUs; DAPS duplicates them). 13: * Report carries the DU's suspend-gap period. */ /* 14: Uplink carries its reception instant (net_us). */ -/* v14: Uplink.net_us; v15: ChannelSwitch; v16: EVM in HeardUe and UeMeas */ -constexpr uint8_t kVersion = 16; +/* 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; 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; @@ -144,6 +145,7 @@ struct UeMeas { int16_t snr_qdb = 0; uint16_t age_ms = 0; /* since its newest sample, at report time (saturates) */ int16_t evm_qdb = kNoEvmQ; /* filtered EVM, quarter dB; kNoEvmQ: none */ + int16_t ta_us = 0; /* the DU's timing advance for this UE now */ bool operator==(const UeMeas &) const = default; }; @@ -410,6 +412,7 @@ 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); put(m, static_cast(q.evm_qdb), 2); + put(m, static_cast(q.ta_us), 2); } m.push_back(static_cast(r.ue_meas.size())); for (const UeCellMeas &q : r.ue_meas) { @@ -492,6 +495,7 @@ inline std::optional decode_report(const uint8_t *p, size_t n, const Mac q.snr_qdb = static_cast(static_cast(r.get(2))); q.age_ms = static_cast(r.get(2)); q.evm_qdb = static_cast(static_cast(r.get(2))); + q.ta_us = static_cast(static_cast(r.get(2))); x.meas.push_back(q); } const size_t nu = r.get(1); @@ -599,6 +603,7 @@ inline std::vector encode_handover(uint16_t du, const HoCommand &h, con put(m, h.seq, 8); m.insert(m.end(), h.ctx.begin(), h.ctx.end()); put_home(m, h.home); + put(m, static_cast(h.ta_us), 2); return seal(std::move(m), mac); } @@ -625,6 +630,7 @@ inline std::optional> decode_handover(const uint8 for (auto &b : h.ctx) b = static_cast(r.get(1)); h.home = get_home(r); + h.ta_us = static_cast(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 4ef284ac..88d94604 100644 --- a/src/cell/HoCommand.h +++ b/src/cell/HoCommand.h @@ -82,6 +82,11 @@ struct HoCommand { /* The UE's home identity (Keys.h): the target records it for the id it * gives the UE and reports it on, for the UE's next handover. */ HomeId home{}; + /* The UE's timing advance at the source (us; F1 v17): the target starts + * its TA loop and the UE's first grant there, instead of at 0. The TA + * corrects the UE's clock and actuation phase, which do not change with + * the cell. */ + int16_t ta_us = 0; bool operator==(const HoCommand &) const = default; }; diff --git a/tests/cell_m6_selftest.cpp b/tests/cell_m6_selftest.cpp index 132213f3..ed98dd38 100644 --- a/tests/cell_m6_selftest.cpp +++ b/tests/cell_m6_selftest.cpp @@ -305,6 +305,16 @@ int main() { CHECK(r.heard.size() == 1 && r.heard[0].evm_db == f1::kNoEvm8 + 1); } + /* --- F1 v17: the DU's TA per own UE round-trips in its report. --- */ + { + f1::Report r; + r.du = 1; + r.meas = {{1, -200, 120, 0, -124, -431}, {2, -220, 100, 5, f1::kNoEvmQ, 37}}; + const auto w = f1::encode_report(r, {}); + auto d = f1::decode_report(w.data(), w.size(), {}); + CHECK(d && d->meas == r.meas && d->meas[0].ta_us == -431 && d->meas[1].ta_us == 37); + } + /* --- F1 v2: the measurement fields round-trip. --- */ { f1::Report r; @@ -850,11 +860,12 @@ int main() { HoCommand hc; hc.id = 9; hc.home = HomeId{true, 0, 0, jm}; + hc.ta_us = -428; /* v17: the source's TA travels with the UE */ 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); + CHECK(dh && dh->second.home == hc.home && dh->second.ta_us == -428); auto badk = wh; - badk[badk.size() - 8 - 7] = 2; /* home kind 2: unknown */ + badk[badk.size() - 8 - 2 - 7] = 2; /* home kind 2: unknown (before the 2-byte TA) */ 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 1b85b4b1..3382041e 100644 --- a/tools/ran-controller/main.cpp +++ b/tools/ran-controller/main.cpp @@ -34,6 +34,8 @@ // RAN_SPARE_CHANNELS channels the planner may propose (advisory) // RAN_RETUNE_APPLY [0] 1: apply a proposal — move that running DU and // 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_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 @@ -237,6 +239,7 @@ int main() { const int edge_dbm = static_cast(envl("RAN_EDGE_DBM", -90, -127, 0)); 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 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)); @@ -1110,6 +1113,11 @@ int main() { cmd.target = *act; cmd.target_ue = tue; cmd.target_pos = tpos; + /* The UE's TA at the source (its report): the target starts there. */ + if (ho_ta) + for (const f1::UeMeas &m : sr.meas) + if (m.ue == q.ue) + cmd.ta_us = m.ta_us; cmd.target_channel = tr.channel; /* != the source's: inter-frequency */ const double age_us = std::chrono::duration(now - dit->second.at).count(); @@ -1170,10 +1178,10 @@ 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\"}", + "\"ctx\":%d,\"home\":\"%s\",\"ta_us\":%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"); + ctx_ok ? 1 : 0, home ? home_name(*home).c_str() : "none", cmd.ta_us); emit(b); } }