Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 49 additions & 9 deletions docs/cell-m6.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions examples/cell/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 <algorithm>
Expand Down Expand Up @@ -827,6 +838,18 @@ int run_du(IRadio *dev, const Cfg &c) {
std::atomic<bool> net_ok{false};
uint64_t sync_samples = 0;
std::array<uint64_t, 3> 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;
Expand Down Expand Up @@ -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<uint64_t>(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<std::mutex> 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))
Expand Down Expand Up @@ -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<int>(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). */
Expand Down Expand Up @@ -2384,6 +2430,17 @@ int run_ue(IRadio *dev, const Cfg &c) {
if (!sync.Ready() || !wrap.ready())
continue;
du_now = sync.RemoteForLocal(static_cast<uint64_t>(wrap.extend(static_cast<uint32_t>(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
Expand Down
68 changes: 65 additions & 3 deletions src/TsfSync.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <utility>
#include <vector>

#include "RxPacket.h"

Expand All @@ -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 {
Expand All @@ -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<int64_t>(remote_tsf);
if (_step_thr > 0 && _init) {
const int64_t res = remote - RemoteForLocal(static_cast<uint64_t>(local));
if (std::llabs(res) > _step_thr) {
if (!_held.empty() && std::llabs(res - _held_res) > _step_thr / 4) {
_rejected += static_cast<long long>(_held.size());
_held.clear();
}
if (_held.empty())
_held_res = res;
_held.push_back({remote, local});
if (static_cast<int>(_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<long long>(_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<double>(remote);
_y0 = static_cast<double>(local);
Expand All @@ -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. */
Expand Down Expand Up @@ -100,15 +152,19 @@ 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<double>(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
* raw crystal offset before the fit, useful as a coarse health readout. */
int64_t OffsetUs() const { return _last_local - _last_remote; }

private:
void ShiftAxis(int64_t delta_us) {
_x0 += static_cast<double>(delta_us);
_last_remote += delta_us;
}
double Intercept(double a) const {
return (_sy - a * _sx) / (_n ? static_cast<double>(_n) : 1.0);
}
Expand All @@ -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<std::pair<int64_t, int64_t>> _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
Expand Down
57 changes: 57 additions & 0 deletions tests/tsf_sync_selftest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<uint64_t>(tx), static_cast<uint32_t>(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<uint64_t>(r), static_cast<uint32_t>(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<uint64_t>(tx), static_cast<uint32_t>(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<uint64_t>(tx), static_cast<uint32_t>(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);
Expand Down
Loading