diff --git a/docs/cell-m6.md b/docs/cell-m6.md index d17ac310..9d08c0b9 100644 --- a/docs/cell-m6.md +++ b/docs/cell-m6.md @@ -349,11 +349,50 @@ after the DUs, with the DU asking for its phase after its beacon had started. - **What changed:** the DU now asks before starting its beacon, and the rig starts the UEs only once both DUs log `cell.du.start`. Five runs since have the UEs' skew at −3 to −8 ppm. -- **Not established:** the source of the jump. Neither is how often a UE - meets one in service, for example on a DU restart under attached UEs. - That case is an open item below. - -## DAPS: the duplicated uplink (same channel) +- **Not established:** the source of the jump. The UE now survives one + (next section). + +## The UE's clock fit across a jump of its DU's TSF + +The UE fits the DU's TSF against its own free-running counter +(`src/TsfSync.h`), which no TSF write on the UE side touches. So a +discontinuity can only come from the DU's side. The fit is cumulative: a +jump read as skew biases every translation for a long time, as in the first +phase-planning runs above. + +**Step detection** (`TsfSync::SetStepDetect`; the UE's settings are in the +`examples/cell/main.cpp` env header): +- **Held out:** a beacon more than 200 µs off the fit is not fitted. +- **Confirmed:** six in a row agreeing on one offset (within 50 µs) are a + step. Six beacons outlast the DU's own discipline pass (500 ms), so a jump + the DU undoes itself is never followed. The remote axis is re-anchored by it (`ShiftRemote`: a TSF step + changes the offset, never the crystals' rate) and the held beacons join + the fit (`cell.ue.fit_step`). +- **Dropped:** held beacons that never agree are discarded, so one stray + frame never bends the line. + +**Rig result** (testrig `scripts/m6_fitstep.sh`, 3 runs of each case, all +pass; both cells on ch149). +- **Restart:** DU1 is killed and restarted under its attached UE. Its new + TSF lands on another superframe count, a lasting jump (about −31 s), and + the restarted DU's acquisition adds a few more steps. +- **Transient:** DU1 adds 1300 µs to its TSF behind its discipline's back + (`CELL_DU_TEST_TSF_STEP`, test only). The discipline undoes it one pass + (~500 ms) later. + +| case | steps followed | UE skew, max \|ppm\| | DL frames in the DL area after | uplinks off-slot, 20 sf after | +|---|---|---|---|---| +| restart, detection on | the restart jump (−30.5 to −31.1 s) and the acquisition steps | 8.0–10.1 | 100 % | not judged (below) | +| restart, detection off (1 run) | — | 996 125 | 11 % | not judged | +| transient, detection on | none (5 beacons held, then dropped) | 8.4–11.9 | 100 % | 0 | + +- **Without detection,** the restart wrecks the fit: the skew reads about + 10⁶ ppm and the DL frames scatter over the whole superframe. +- **The confirm window matters:** with 3 beacons (shorter than the DU's + undo), the UE followed the transient out and back, 3 beacons late each way. + That put 4–5 of the next 20 uplinks off-slot, in 3 of 3 runs. + +## DAPS: the duplicated uplink (same channel)## DAPS: the duplicated uplink (same channel) **Common sequence.** A UE uplinks once per superframe. Every DU that hears one transmission hears it at the same instant on the shared PTP timebase, @@ -453,10 +492,11 @@ superframe when there is nothing to serve. running ones, which never move. With three or more cells on different channels, the 26 ms retune leaves no layout where every pair can measure both ways. Re-phasing live cells, or longer superframes, is open. -- **A UE's clock fit after a jump in its serving beacon's timing:** - `TsfSync` never forgets, so one discontinuity (seen as about 1.3 ms, source - not established) skews the UE for a long time. A UE-side reset on a - detected discontinuity is open. +- **A DU restart under attached UEs:** the UE's clock now follows it, but + its grant state does not. The restarted DU's grant maps start again at a + low epoch and another superframe count. The UE keeps its old map and is + not heard in the new grid. Re-attachment after a DU restart (a fresh map + epoch base, or the UE dropping a map no longer in force) is open. - **No crossing test:** every handover result here is from stationary UEs. The trigger is either forced or the measured standing difference between two cells. No A→B path-loss ramp has been run, so none of these numbers diff --git a/examples/cell/main.cpp b/examples/cell/main.cpp index 17c97860..bbdf85e5 100644 --- a/examples/cell/main.cpp +++ b/examples/cell/main.cpp @@ -122,6 +122,17 @@ // superframes = the DU's beacon is not reaching // them. Three failed beacon updates in a row also // count. +// CELL_UE_FIT_STEP_US [200] CELL_UE_FIT_CONFIRM [6] UE: a serving beacon +// this far off the clock fit is held out of it; +// that many in a row agreeing on one offset are a +// step of the DU's TSF, re-anchored (TsfSync step +// detection, cell.ue.fit_step). 0 = off. 6 +// beacons outlast the DU's discipline pass +// (CELL_SYNC_LOOP_MS 500): a jump the DU undoes +// itself is never followed, only a lasting one. +// CELL_DU_TEST_TSF_STEP DU, test only: US@SF — add US to the TSF once at +// local superframe SF, behind the discipline's back +// (cell.test.tsf_step); the discipline undoes it. // DEVOURER_CHANNEL [149] // Output: JSONL on stdout ("ev":"cell.*"). #include @@ -827,6 +838,18 @@ int run_du(IRadio *dev, const Cfg &c) { std::atomic net_ok{false}; uint64_t sync_samples = 0; std::array sync_in_state{}; + /* CELL_DU_TEST_TSF_STEP=US@SF (test only): one TSF jump the discipline + * did not ask for, what a UE's clock fit must survive. */ + int64_t test_step_us = 0, test_step_sf = -1; + if (const char *ts = std::getenv("CELL_DU_TEST_TSF_STEP"); ts && *ts) { + long long us = 0, at = 0; + if (std::sscanf(ts, "%lld@%lld", &us, &at) != 2 || at < 0 || us == 0 || std::llabs(us) > P) { + fprintf(stderr, "CELL_DU_TEST_TSF_STEP: US@SF, 0 < |US| <= period\n"); + return 2; + } + test_step_us = us; + test_step_sf = at; + } auto sync_once = [&](int64_t sf_now, bool beacon_ok) { uint64_t t = 0, f = 0; double r1 = 0, r2 = 0; @@ -879,6 +902,24 @@ int run_du(IRadio *dev, const Cfg &c) { d.phase_err_us, (long long)d.step_us, wok ? 1 : 0, d.ppm, rok ? 1 : 0, beacon_ok ? 1 : 0); emit(l); + /* The test step goes in AFTER this pass's sample, so it stays on air + * until the next pass (CELL_SYNC_LOOP_MS) undoes it; the RX offset is + * re-read as after any write. */ + if (test_step_sf >= 0 && sf_now >= test_step_sf) { + const bool ok = dev->WriteTsf(dev->ReadTsf() + static_cast(test_step_us)); + test_step_sf = -1; + uint64_t t3 = 0, f3 = 0; + if (dev->ReadTsfAndFreeRun(t3, f3)) { + publish_clock(t3, f3); + } else { /* as after any write: no stale offset until a fresh read */ + std::lock_guard lk(clk_mu); + clk.valid = false; + } + char l2[120]; + snprintf(l2, sizeof l2, "{\"ev\":\"cell.test.tsf_step\",\"sf\":%lld,\"step_us\":%lld,\"ok\":%d}", + (long long)sf_now, (long long)test_step_us, ok ? 1 : 0); + emit(l2); + } }; auto set_mode = [&](bool coord, int64_t sf_now) { if (coord == coordinated || !apply_mode(coord)) @@ -2145,6 +2186,11 @@ int run_ue(IRadio *dev, const Cfg &c) { const CellMac auth = cell_auth(c); std::mutex mu; // guards sync, wrap, rxr devourer::TsfSync sync; + /* A step of the DU's TSF re-anchors the fit instead of reading as skew: + * on the rig a ~1.3 ms jump once ran a UE's skew to thousands of ppm. */ + sync.SetStepDetect(envl("CELL_UE_FIT_STEP_US", 200, 0, 1000000), + static_cast(envl("CELL_UE_FIT_CONFIRM", 6, 1, 50))); + long long fit_steps_seen = 0, fit_rejected_seen = 0; Wrap32 wrap; GrantMapReceiver rxr(c.cell); /* DL HARQ receiver + the DL SNR since the last feedback publish (mu). */ @@ -2384,6 +2430,17 @@ int run_ue(IRadio *dev, const Cfg &c) { if (!sync.Ready() || !wrap.ready()) continue; du_now = sync.RemoteForLocal(static_cast(wrap.extend(static_cast(f)))); + if (sync.Steps() != fit_steps_seen || sync.Rejected() != fit_rejected_seen) { + char l[200]; + snprintf(l, sizeof l, + "{\"ev\":\"cell.ue.fit_step\",\"ue\":%d,\"steps\":%lld,\"last_step_us\":%lld," + "\"rejected\":%lld,\"skew_ppm\":%.2f}", + me_a.load(), sync.Steps(), (long long)sync.LastStepUs(), sync.Rejected(), + sync.SkewPpm()); + emit(l); + fit_steps_seen = sync.Steps(); + fit_rejected_seen = sync.Rejected(); + } int64_t sfn = du_now >= 0 ? du_now / P : 0; /* M6: a fresh handover command is accepted once (HoReceiver: newer * id, instant ahead); at its instant the UE switches: the clock is diff --git a/src/TsfSync.h b/src/TsfSync.h index 23327ca1..bf6a8c22 100644 --- a/src/TsfSync.h +++ b/src/TsfSync.h @@ -3,6 +3,9 @@ #include #include +#include +#include +#include #include "RxPacket.h" @@ -26,7 +29,17 @@ * Least-squares in offset coordinates for numerical conditioning; the local * 32-bit tsfl is reconstructed to 64 bits across its ~71.6 min wrap. Header-only, * no chip dependency. Not internally synchronised — feed it from one thread - * (typically the RX callback). */ + * (typically the RX callback). + * + * Step detection (opt-in, SetStepDetect): the fit is cumulative, so one + * jump of the transmitter's clock (a TSF write on its side) would otherwise + * be absorbed as skew and bias every translation for a long time. With it + * on, a beacon more than `threshold_us` off the line is held out of the fit; + * `confirm` consecutive held beacons that agree on one new offset (within a + * quarter of the threshold) are a STEP: the remote axis is re-anchored by it + * (ShiftRemote — a TSF step is an offset change, the rate is the crystals') + * and the held beacons join the fit. Held beacons that never agree are + * dropped (a stray frame never bends the line). */ namespace devourer { class TsfSync { @@ -37,6 +50,43 @@ class TsfSync { void Add(uint64_t remote_tsf, uint32_t local_tsfl) { const int64_t local = ReconLocal(local_tsfl); const int64_t remote = static_cast(remote_tsf); + if (_step_thr > 0 && _init) { + const int64_t res = remote - RemoteForLocal(static_cast(local)); + if (std::llabs(res) > _step_thr) { + if (!_held.empty() && std::llabs(res - _held_res) > _step_thr / 4) { + _rejected += static_cast(_held.size()); + _held.clear(); + } + if (_held.empty()) + _held_res = res; + _held.push_back({remote, local}); + if (static_cast(_held.size()) < _step_confirm) + return; + ShiftAxis(_held_res); + ++_steps; + _last_step = _held_res; + for (const auto &h : _held) + Ingest(h.first, h.second); + _held.clear(); + return; + } + _rejected += static_cast(_held.size()); + _held.clear(); + } + Ingest(remote, local); + } + + /* Step detection (see the header); threshold 0 = off (the default). */ + void SetStepDetect(int64_t threshold_us, int confirm) { + _step_thr = threshold_us > 0 ? threshold_us : 0; + _step_confirm = confirm > 1 ? confirm : 1; + } + long long Steps() const { return _steps; } /* confirmed transmitter steps */ + int64_t LastStepUs() const { return _last_step; } + long long Rejected() const { return _rejected; } /* held beacons never confirmed */ + + private: + void Ingest(int64_t remote, int64_t local) { if (!_init) { _x0 = static_cast(remote); _y0 = static_cast(local); @@ -53,6 +103,8 @@ class TsfSync { _last_local = local; } + public: + /* Convenience: feed a received Packet. Returns true iff it carried an egress * TSF (a beacon / probe response) and was ingested — so a caller can simply * `sync.Add(packet)` on every frame and let non-beacons fall through. */ @@ -100,8 +152,8 @@ class TsfSync { * src/cell/HoCommand.h ho_tsf_delta_us): it keeps its clock, no * re-acquisition, and the new transmitter's beacons extend the same line. */ void ShiftRemote(int64_t delta_us) { - _x0 += static_cast(delta_us); - _last_remote += delta_us; + ShiftAxis(delta_us); + _held.clear(); /* beacons held from the previous transmitter */ } /* Instantaneous (local − remote) offset at the most recent beacon, µs — the @@ -109,6 +161,10 @@ class TsfSync { int64_t OffsetUs() const { return _last_local - _last_remote; } private: + void ShiftAxis(int64_t delta_us) { + _x0 += static_cast(delta_us); + _last_remote += delta_us; + } double Intercept(double a) const { return (_sy - a * _sx) / (_n ? static_cast(_n) : 1.0); } @@ -124,6 +180,12 @@ class TsfSync { long long _n = 0; int64_t _hi = 0, _last_remote = 0, _last_local = 0; uint32_t _plo = 0; + int64_t _step_thr = 0; + int _step_confirm = 3; + std::vector> _held; /* (remote, local) off the line */ + int64_t _held_res = 0; /* the first held beacon's offset */ + long long _steps = 0, _rejected = 0; + int64_t _last_step = 0; }; } // namespace devourer diff --git a/tests/tsf_sync_selftest.cpp b/tests/tsf_sync_selftest.cpp index 40f16405..e429c1cb 100644 --- a/tests/tsf_sync_selftest.cpp +++ b/tests/tsf_sync_selftest.cpp @@ -120,6 +120,63 @@ int main() { "tracks B after the switch"); } + { + // Step detection: the transmitter's TSF jumps +1300 us after 24 beacons + // (the M6 rig case). Off: the cumulative fit takes the jump as skew. On: + // 3 agreeing beacons confirm a step, the axis is re-anchored, the skew + // stays the crystals'; a lone stray beacon is dropped, not fitted. + const double skew = 1.0 - 4.3e-6; + const int64_t a0 = 7'000'000, J = 1300; + const uint32_t l0 = 2'000'000; + auto feed = [&](devourer::TsfSync &s, int from, int to, int64_t jump, int stray_at) { + for (int i = from; i < to; ++i) { + const int64_t r = a0 + i * 102400LL; // the transmitter's true time + const int64_t tx = r + (i >= 24 ? jump : 0) + (i == stray_at ? 5000 : 0); + s.Add(static_cast(tx), static_cast(l0 + llround((r - a0) * skew))); + } + }; + devourer::TsfSync off; + feed(off, 0, 60, J, -1); + CHECK(std::fabs(off.SkewPpm() + 4.3) > 50, "without detection the jump reads as skew"); + + devourer::TsfSync on; + on.SetStepDetect(200, 3); + feed(on, 0, 60, J, 40); + CHECK(on.Steps() == 1 && on.LastStepUs() == J, "one step, of the jump"); + CHECK(std::fabs(on.SkewPpm() + 4.3) < 0.5, "skew stays the crystals'"); + CHECK(on.Rejected() == 1, "the stray beacon dropped"); + const int64_t later = a0 + 80 * 102400LL; + CHECK(std::llabs(on.RemoteForLocal(l0 + llround((later - a0) * skew)) - (later + J)) <= 2, + "tracks the stepped clock"); + // ... and back (the transmitter's discipline undoing it). + for (int i = 60; i < 90; ++i) { + const int64_t r = a0 + i * 102400LL; + on.Add(static_cast(r), static_cast(l0 + llround((r - a0) * skew))); + } + CHECK(on.Steps() == 2 && on.LastStepUs() == -J, "the step back too"); + // A jump undone within the confirm window (the DU's discipline, ~5 + // beacons, against the UE's 6) is never followed: held, then dropped. + devourer::TsfSync tr; + tr.SetStepDetect(200, 6); + for (int i = 0; i < 60; ++i) { + const int64_t r = a0 + i * 102400LL; + const int64_t tx = r + (i >= 30 && i < 35 ? J : 0); + tr.Add(static_cast(tx), static_cast(l0 + llround((r - a0) * skew))); + } + CHECK(tr.Steps() == 0 && tr.Rejected() == 5, "a transient jump: dropped, not followed"); + CHECK(std::fabs(tr.SkewPpm() + 4.3) < 0.5, "and the line unbent"); + // Disagreeing outliers never confirm a step. + devourer::TsfSync noisy; + noisy.SetStepDetect(200, 3); + for (int i = 0; i < 60; ++i) { + const int64_t r = a0 + i * 102400LL; + const int64_t tx = r + (i >= 30 && i < 36 ? (i % 2 ? 900 : -900) : 0); + noisy.Add(static_cast(tx), static_cast(l0 + llround((r - a0) * skew))); + } + CHECK(noisy.Steps() == 0 && noisy.Rejected() == 6, "alternating outliers: no step"); + CHECK(std::fabs(noisy.SkewPpm() + 4.3) < 0.5, "and the line unbent"); + } + std::printf(g_fail ? "tsf_sync_selftest: %d FAILURE(S)\n" : "tsf_sync_selftest: all passed\n", g_fail);