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
12 changes: 9 additions & 3 deletions docs/cell-m6.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ 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. 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 |
| 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) |
| 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 |
Expand Down Expand Up @@ -1741,4 +1741,10 @@ replay checks caught it in the signed-list run: UE2 reported cell 1 in only
unauthenticated development cell. Their tags were zeros and were not
checked; F1 was keyed throughout. The testrig scripts now give every cell
role one key. The suite re-run keyed is in the next section.
- **EVM:** not in the measurement reports.
- **EVM is reported, not used by the A3** (F1 v16; the CU logs it last in
each `ran.meas` tuple). The A3 compares cells: a neighbour's margin over
the serving cell, on RSSI or SNR, which both DUs measure on the same
frames. EVM tracks SNR at moderate levels and saturates at high SNR (the
rig's uplinks sit at −30 to −33 dB EVM with SNR 38–39 dB), so it adds no
ranking between cells there. It is per-link quality for the scheduler
and link adaptation, not a handover metric.
13 changes: 11 additions & 2 deletions examples/cell/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1544,6 +1544,13 @@ int run_du(IRadio *dev, const Cfg &c) {
send_f1(devourer::cell::f1::encode_uplink(u, f1_mac));
};
const int64_t ho_release_sf = envl("CELL_HO_RELEASE_SF", 20, 3, 100000);
/* A frame's EVM (dB, negative), if the radio measured one (raw: dB*2, 0 =
* none; the same convention as UeRxAttribution). */
auto frame_evm_db = [](const Packet &pk) -> std::optional<double> {
if (pk.RxAtrib.evm[0] == 0)
return std::nullopt;
return static_cast<double>(pk.RxAtrib.evm[0]) / 2.0;
};
auto now_ms = [] {
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch())
Expand Down Expand Up @@ -1633,7 +1640,8 @@ int run_du(IRadio *dev, const Cfg &c) {
if (auto ai = arrival_of.find({oc, ou}); ai != arrival_of.end())
arrival = ai->second;
meas_tab.update(oc, ou, static_cast<double>(p.RxAtrib.rssi[0]) - 110.0,
static_cast<double>(p.RxAtrib.snr[0]) / 2.0 /* raw dB*2 */, now_ms());
static_cast<double>(p.RxAtrib.snr[0]) / 2.0 /* raw dB*2 */, now_ms(),
frame_evm_db(p));
fwd = daps_on && daps_ids.count({oc, ou}) > 0;
}
if (arrival && ob->dl) {
Expand Down Expand Up @@ -1705,7 +1713,8 @@ int run_du(IRadio *dev, const Cfg &c) {
{
std::lock_guard<std::mutex> lk(f1_mu);
meas_tab.update(c.cell, ub->ue, static_cast<double>(p.RxAtrib.rssi[0]) - 110.0,
static_cast<double>(p.RxAtrib.snr[0]) / 2.0 /* raw dB*2 */, now_ms());
static_cast<double>(p.RxAtrib.snr[0]) / 2.0 /* raw dB*2 */, now_ms(),
frame_evm_db(p));
}
forward_ul(c.cell, ub->ue, p); /* the serving path: always (F1 on) */
if (auto mr = devourer::cell::find_mr_ie(p.Data.data(), p.Data.size(), c.cell, ub->ue, auth)) {
Expand Down
18 changes: 15 additions & 3 deletions src/cell/F1.h
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
#include <cmath>
#include <cstddef>
#include <array>
#include <climits>
#include <cstdint>
#include <cstring>
#include <functional>
Expand Down Expand Up @@ -80,7 +81,10 @@ 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). */
constexpr uint8_t kVersion = 15; /* v14: Uplink.net_us; v15: ChannelSwitch */
/* v14: Uplink.net_us; v15: ChannelSwitch; v16: EVM in HeardUe and UeMeas */
constexpr uint8_t kVersion = 16;
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;
constexpr size_t kMaxFixed = 8;
constexpr size_t kMaxPositions = kMaxUesPerCell;
Expand Down Expand Up @@ -120,6 +124,7 @@ struct HeardUe {
int8_t rssi_dbm = 0; /* filtered (src/cell/Handover.h L3Filter), dBm */
int8_t snr_db = 0; /* filtered, dB */
uint16_t age_ms = 0; /* since its newest sample, at report time (saturates) */
int8_t evm_db = kNoEvm8; /* filtered EVM, dB (negative); kNoEvm8: none */
bool operator==(const HeardUe &) const = default;
};
/* What a UE measured itself (its MR IE, src/cell/MeasGap.h): `cell`'s
Expand All @@ -138,6 +143,7 @@ struct UeMeas {
int16_t rssi_qdbm = 0;
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 */
bool operator==(const UeMeas &) const = default;
};

Expand Down Expand Up @@ -340,9 +346,11 @@ inline void fill_report_measurements(Report &r, uint16_t own, const std::vector<
for (const MeasEntry &e : snap) {
if (e.cell == own) {
if (r.meas.size() < kMaxUes)
r.meas.push_back({e.ue, clampq(e.rssi_dbm), clampq(e.snr_db), age16(e.age_ms)});
r.meas.push_back({e.ue, clampq(e.rssi_dbm), clampq(e.snr_db), age16(e.age_ms),
e.evm_valid ? std::max<int16_t>(clampq(e.evm_db), kNoEvmQ + 1) : kNoEvmQ});
} else if (r.heard.size() < kMaxHeard) {
r.heard.push_back({e.cell, e.ue, clamp8(e.rssi_dbm), clamp8(e.snr_db), age16(e.age_ms)});
r.heard.push_back({e.cell, e.ue, clamp8(e.rssi_dbm), clamp8(e.snr_db), age16(e.age_ms),
e.evm_valid ? std::max<int8_t>(clamp8(e.evm_db), kNoEvm8 + 1) : kNoEvm8});
}
}
}
Expand Down Expand Up @@ -393,13 +401,15 @@ inline std::vector<uint8_t> encode_report(const Report &r, const Mac &mac) {
m.push_back(static_cast<uint8_t>(h.rssi_dbm));
m.push_back(static_cast<uint8_t>(h.snr_db));
put(m, h.age_ms, 2);
m.push_back(static_cast<uint8_t>(h.evm_db));
}
m.push_back(static_cast<uint8_t>(r.meas.size()));
for (const UeMeas &q : r.meas) {
put(m, q.ue, 2);
put(m, static_cast<uint16_t>(q.rssi_qdbm), 2);
put(m, static_cast<uint16_t>(q.snr_qdb), 2);
put(m, q.age_ms, 2);
put(m, static_cast<uint16_t>(q.evm_qdb), 2);
}
m.push_back(static_cast<uint8_t>(r.ue_meas.size()));
for (const UeCellMeas &q : r.ue_meas) {
Expand Down Expand Up @@ -469,6 +479,7 @@ inline std::optional<Report> decode_report(const uint8_t *p, size_t n, const Mac
h.rssi_dbm = static_cast<int8_t>(r.get(1));
h.snr_db = static_cast<int8_t>(r.get(1));
h.age_ms = static_cast<uint16_t>(r.get(2));
h.evm_db = static_cast<int8_t>(r.get(1));
x.heard.push_back(h);
}
const size_t nm = r.get(1);
Expand All @@ -480,6 +491,7 @@ inline std::optional<Report> decode_report(const uint8_t *p, size_t n, const Mac
q.rssi_qdbm = static_cast<int16_t>(static_cast<uint16_t>(r.get(2)));
q.snr_qdb = static_cast<int16_t>(static_cast<uint16_t>(r.get(2)));
q.age_ms = static_cast<uint16_t>(r.get(2));
q.evm_qdb = static_cast<int16_t>(static_cast<uint16_t>(r.get(2)));
x.meas.push_back(q);
}
const size_t nu = r.get(1);
Expand Down
26 changes: 19 additions & 7 deletions src/cell/Handover.h
Original file line number Diff line number Diff line change
Expand Up @@ -163,21 +163,27 @@ class A3Trigger {

/* The filtered uplink quality per (cell, UE) one DU measures: its own UEs
* (cell = its own id) and the other cells' it hears. RSSI and SNR each go
* through an L3Filter; snapshot() lists only the valid (fresh) entries. */
* through an L3Filter, and so does EVM, from the frames that carry one;
* snapshot() lists only the valid (fresh) entries. */
struct MeasEntry {
uint16_t cell = 0, ue = 0;
double rssi_dbm = 0, snr_db = 0;
int64_t age_ms = 0; /* since the newest sample in it */
double evm_db = 0; /* filtered EVM (dB, negative), when evm_valid */
bool evm_valid = false;
};
class MeasTable {
public:
explicit MeasTable(L3FilterConfig c = {}) : cfg_(c) {}
void update(uint16_t cell, uint16_t ue, double rssi_dbm, double snr_db, int64_t now_ms) {
void update(uint16_t cell, uint16_t ue, double rssi_dbm, double snr_db, int64_t now_ms,
std::optional<double> evm_db = std::nullopt) {
auto it = m_.find({cell, ue});
if (it == m_.end())
it = m_.emplace(std::make_pair(cell, ue), Pair{L3Filter(cfg_), L3Filter(cfg_)}).first;
it = m_.emplace(std::make_pair(cell, ue), Pair{L3Filter(cfg_), L3Filter(cfg_), L3Filter(cfg_)}).first;
it->second.rssi.update(rssi_dbm, now_ms);
it->second.snr.update(snr_db, now_ms);
if (evm_db)
it->second.evm.update(*evm_db, now_ms);
}
/* Valid entries now; entries stale for 5x stale_ms are forgotten. */
std::vector<MeasEntry> snapshot(int64_t now_ms) {
Expand All @@ -187,17 +193,23 @@ class MeasTable {
it = m_.erase(it);
continue;
}
if (it->second.rssi.valid(now_ms))
v.push_back({it->first.first, it->first.second, it->second.rssi.value(),
it->second.snr.value(), now_ms - it->second.rssi.last_ms()});
if (it->second.rssi.valid(now_ms)) {
MeasEntry e{it->first.first, it->first.second, it->second.rssi.value(), it->second.snr.value(),
now_ms - it->second.rssi.last_ms()};
if (it->second.evm.valid(now_ms)) {
e.evm_db = it->second.evm.value();
e.evm_valid = true;
}
v.push_back(e);
}
++it;
}
return v;
}

private:
struct Pair {
L3Filter rssi, snr;
L3Filter rssi, snr, evm;
};
L3FilterConfig cfg_;
std::map<std::pair<uint16_t, uint16_t>, Pair> m_;
Expand Down
27 changes: 27 additions & 0 deletions tests/cell_m6_selftest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,33 @@ int main() {
CHECK(d && d->meas == r.meas && d->heard == r.heard);
}

/* --- F1 v16: EVM, filtered, from the frames that carry one; "none" when
* none did, in both directions of the report. --- */
{
MeasTable t({4, 2000});
for (int i = 0; i < 20; ++i) {
t.update(2, 1, -55, 30, i * 100, -31.0); /* own UE: EVM -31 dB */
t.update(1, 1, -72, 12, i * 100, i % 2 ? std::optional<double>(-18.0) : std::nullopt);
t.update(3, 2, -80, 8, i * 100); /* heard, no EVM ever */
}
f1::Report r;
r.du = 2;
f1::fill_report_measurements(r, 2, t.snapshot(1900));
CHECK(r.meas.size() == 1 && r.meas[0].evm_qdb == -124);
const f1::HeardUe *h1 = nullptr, *h3 = nullptr;
for (const auto &h : r.heard)
(h.cell == 1 ? h1 : h3) = &h;
CHECK(h1 && h1->evm_db == -18 && h3 && h3->evm_db == f1::kNoEvm8);
const auto w = f1::encode_report(r, {});
auto d = f1::decode_report(w.data(), w.size(), {});
CHECK(d && d->meas == r.meas && d->heard == r.heard);
/* a real EVM never encodes as "none": heard (8-bit dB) clamps at -127 */
MeasTable u({4, 2000});
u.update(5, 1, -55, 30, 0, -200.0);
f1::fill_report_measurements(r, 2, u.snapshot(0));
CHECK(r.heard.size() == 1 && r.heard[0].evm_db == f1::kNoEvm8 + 1);
}

/* --- F1 v2: the measurement fields round-trip. --- */
{
f1::Report r;
Expand Down
19 changes: 14 additions & 5 deletions tools/ran-controller/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@
// 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
// ran.meas: per DU, own [ue, rssi, snr, evm] and heard [cell, ue, rssi, snr,
// evm] (filtered; evm null when the frames carried none). EVM is reported,
// not used by the A3.
// Handover evaluation (M6, #260) — OBSERVE ONLY: for every UE the CU runs the
// A3 (CHO) condition (src/cell/Handover.h) on the DUs' filtered reports —
// the serving DU's own measurement against every other DU's heard one — and
Expand Down Expand Up @@ -875,16 +878,22 @@ int main() {
std::string m = "{\"ev\":\"ran.meas\",\"du\":" + std::to_string(kv.first) + ",\"own\":[";
for (size_t i = 0; i < kv.second.rep.meas.size(); ++i) {
const auto &q = kv.second.rep.meas[i];
char b[64];
snprintf(b, sizeof b, "%s[%u,%.2f,%.2f]", i ? "," : "", q.ue, q.rssi_qdbm / 4.0,
q.snr_qdb / 4.0);
char b[96];
char ev[16] = "null"; /* EVM last (F1 v16): earlier fields keep their places */
if (q.evm_qdb != f1::kNoEvmQ)
snprintf(ev, sizeof ev, "%.2f", q.evm_qdb / 4.0);
snprintf(b, sizeof b, "%s[%u,%.2f,%.2f,%s]", i ? "," : "", q.ue, q.rssi_qdbm / 4.0,
q.snr_qdb / 4.0, ev);
m += b;
}
m += "],\"heard\":[";
for (size_t i = 0; i < kv.second.rep.heard.size(); ++i) {
const auto &h = kv.second.rep.heard[i];
char b[64];
snprintf(b, sizeof b, "%s[%u,%u,%d,%d]", i ? "," : "", h.cell, h.ue, h.rssi_dbm, h.snr_db);
char b[96];
char ev[16] = "null";
if (h.evm_db != f1::kNoEvm8)
snprintf(ev, sizeof ev, "%d", h.evm_db);
snprintf(b, sizeof b, "%s[%u,%u,%d,%d,%s]", i ? "," : "", h.cell, h.ue, h.rssi_dbm, h.snr_db, ev);
m += b;
}
emit(m + "]}");
Expand Down
Loading