From 85d96c55777ea8a069df986bd063fed78eece27f Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Fri, 13 Nov 2026 09:57:32 +0300 Subject: [PATCH] =?UTF-8?q?cell=20M1:=20QoS=20classes=20(CellScheduler)=20?= =?UTF-8?q?=E2=80=94=205QI=20characteristics=20set=20each=20UE's=20HARQ=20?= =?UTF-8?q?budget,=20DL=20discard=20timer=20and=20link=20adaptation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/cell/CellScheduler.h: the 5QIs 1 2 3 5 7 9 with their standardized characteristics (TS 23.501 table 5.7.4-1); policy_for() derives a UE's HARQ attempts (1 + the HARQ round trips that fit its PDB after the first superframe), a DL discard timer (the PDB, never below a superframe + 5 ms; also applied to a due retransmission) and link adaptation from the PER target, flagging a PDB shorter than a superframe (pdb_unmet); admission_order() and preempt_for() rank by priority level (headless). HarqSender::abandon(seq). The DU takes CELL_QOS="ue:5qi,..." (cell.qos, cell.dl.discard); unlisted UEs are served as before. Rig (testrig m1_qos.sh, lossy DL): no class 8.1 % lost (deliveries up to attempt 4); 5QI 9 at the cell's 3-superframe round trip 1 attempt, 52 %; with a 1-superframe round trip 2 attempts, 33 %; 5QI 1 54 %. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/cell-m1.md | 36 +++++++++++ examples/cell/main.cpp | 118 +++++++++++++++++++++++++++++++-- src/cell/CellScheduler.h | 129 +++++++++++++++++++++++++++++++++++++ src/cell/Harq.h | 12 ++++ tests/cell_m2_selftest.cpp | 11 ++++ tests/cell_selftest.cpp | 36 +++++++++++ 6 files changed, 338 insertions(+), 4 deletions(-) create mode 100644 src/cell/CellScheduler.h diff --git a/docs/cell-m1.md b/docs/cell-m1.md index 0a1694d1..1b97753b 100644 --- a/docs/cell-m1.md +++ b/docs/cell-m1.md @@ -90,6 +90,42 @@ clock. On the rig it is driven by testrig `scripts/m1_cell_2ue.sh`. schedule (Kestrel's TBTT behaviour after a TSF step), which is at most one step-size off. +## QoS classes (`CellScheduler`) + +M1's grid is fixed (one DL and one UL opportunity per UE per superframe), +so a class can't buy a UE airtime. It decides how the UE is served: its 5QI +sets the HARQ budget, a DL discard timer (also applied to a retransmission +past it), link adaptation, and admission priority. `src/cell/CellScheduler.h` +holds the mechanism and the class table. The DU takes `CELL_QOS="ue:5qi,..."` +and logs each UE's policy (`cell.qos`) and its discards (`cell.dl.discard`). +A UE not listed is served as before. + +**Rig** (testrig `scripts/m1_qos.sh`): UE1's DL at a fixed 54 Mb/s, link +adaptation off and −15 dB of per-packet attenuation, so about half the +first transmissions are lost; the CU sends one PDU per 100 ms. Two rounds, +interleaved. Each class's policy was logged as derived, and no PDU arrived on +a later attempt than its class allows. + +| class (2 runs) | PDUs lost | delivered on attempt 1 / 2 / 3 / 4 | +|---|---|---| +| none (the cell's HARQ: 4 attempts, 3-superframe round trip) | 8.1 % | 53 / 31 / 11 / 4 % | +| 5QI 9 (PDB 300 ms: no retry fits a 3-superframe round trip, 1 attempt) | 52 % | 100 % | +| 5QI 9 with a 1-superframe round trip (`CELL_HARQ_TIMEOUT_SF=1`, 2 attempts) | 33 % | 67 / 33 % | +| 5QI 1 (1 attempt, `pdb_unmet`) | 54 % | 100 % | + +- **The HARQ round trip decides what a budget buys.** On this rig every + retransmission comes from the timeout: the HARQ stats showed 0 NACKs, + because the UE's feedback reaches the DU a superframe or two late. So a + retry lands one round trip (3 superframes, ~307 ms) after the attempt + before it. A first version counted retries a superframe apart: 5QI 9 got 2 + attempts, and its second came after its 300 ms budget (sent late, then + discarded once retransmissions were checked against the timer). The + policy now counts the configured round trip. +- **The cost is delivery:** a tighter budget bounds the delay (each retry is + a round trip) and pays in delivery on this deliberately lossy link. The + attenuation sits on a steep edge (−14 dB lost nothing at four attempts, + −17 dB lost everything), so losses vary between runs. + ## Running it ```sh diff --git a/examples/cell/main.cpp b/examples/cell/main.cpp index 5101ad7e..88c68754 100644 --- a/examples/cell/main.cpp +++ b/examples/cell/main.cpp @@ -97,6 +97,10 @@ // CELL_JOINER_MAC the joiner's MAC (default: derived from the host // name + DEVOURER_PCIE_BDF, so a restart re-joins // into the same position instead of a new one) +// CELL_QOS [""] DU: "ue:5qi,..." — a UE's QoS class (5QI 1 2 3 +// 5 7 9, CellScheduler.h): its HARQ budget, DL +// discard timer and link adaptation. Unlisted: +// the cell's HARQ and link adaptation, no discard // CELL_CS_RETUNE_US [26000] a channel move's retune budget (DU and UE): // the move needs it, + 2 ms, after every slot and // before the next beacon (ChannelSwitch.h) @@ -251,6 +255,7 @@ #include "cell/SlotGrid.h" #include "logger.h" #include "radio_open.h" +#include "cell/CellScheduler.h" #include "cell/ChannelSwitch.h" #include "cell/Coord.h" #include "cell/F1.h" @@ -656,7 +661,31 @@ int run_du(IRadio *dev, const Cfg &c) { const uint16_t atten_ue = static_cast(envl("CELL_ATTEN_UE", 0, 0, 254)); const int8_t atten_db = static_cast(envl("CELL_ATTEN_DB", 0, -40, 0)); const int64_t P = grid->period_us(); - if (atten_ue != 0 && atten_db != 0 && !dev->GetAdapterCaps().per_packet_txpower) { + /* M1 QoS (CellScheduler.h): CELL_QOS="ue:5qi,..." gives a UE a 5QI class, + * and with it its HARQ budget, DL discard timer and link adaptation. A UE + * not listed is served as before (the cell's HARQ and link adaptation, no + * discard): DAPS's buffered PDUs, for one, are older than any budget. */ + std::map qos_pol; + if (const char *q = std::getenv("CELL_QOS")) { + std::string spec(q); + for (size_t i = 0; i < spec.size();) { + const size_t comma = spec.find(',', i); + const std::string item = spec.substr(i, comma == std::string::npos ? std::string::npos : comma - i); + unsigned ue = 0, qi = 0; + char tail = 0; + const auto cls = sscanf(item.c_str(), "%u:%u%c", &ue, &qi, &tail) == 2 && ue >= 1 && ue <= 254 && qi <= 255 + ? devourer::cell::qos_class(static_cast(qi)) + : std::nullopt; + if (!cls) { + fprintf(stderr, "CELL_QOS: \"%s\" is not ue:5qi with a known 5QI (1 2 3 5 7 9)\n", item.c_str()); + return 2; + } + qos_pol[static_cast(ue)] = devourer::cell::policy_for(*cls, P, {}, hc.max_attempts, 5, hc.timeout_sf); + if (comma == std::string::npos) + break; + i = comma + 1; + } + } if (atten_ue != 0 && atten_db != 0 && !dev->GetAdapterCaps().per_packet_txpower) { fprintf(stderr, "CELL_ATTEN_DB needs per-packet TX power (per_packet_txpower=0): the " "attenuation would silently not apply\n"); return 2; @@ -1227,6 +1256,7 @@ int run_du(IRadio *dev, const Cfg &c) { /* The CU DL PDUs each HARQ seq in flight carries, and their stream (a * retransmission re-airs the same ones). */ std::map>> pdus; + std::map first_ms; /* each seq's first attempt (QoS discard) */ /* A handover arrival not heard yet: the frames aired to it so far may * have gone out before the UE switched (see the uplink handler). */ bool arriving = false; @@ -1245,6 +1275,8 @@ int run_du(IRadio *dev, const Cfg &c) { uint32_t last_sn = 0; /* the newest sn taken in it */ std::chrono::steady_clock::time_point last_push; uint64_t dropped = 0, refused = 0; + std::map at_ms; /* when each sn came (the QoS discard timer) */ + uint64_t discarded = 0; }; constexpr size_t kDlQueueMax = 64; /* A contiguous queue's out-of-window PDUs (at most kDlQueueMax - 33) and the @@ -1298,8 +1330,24 @@ int run_du(IRadio *dev, const Cfg &c) { } return {gone.size(), requeued}; }; + /* A UE's DL state, from its QoS class if it has one (qos_pol). */ + auto make_dlue = [&](uint16_t ue) { + auto it = qos_pol.find(ue); + if (it == qos_pol.end()) + return DlUe{HarqSender(hc), LinkAdapt({}, dl_rate0)}; + HarqConfig h = hc; + h.max_attempts = it->second.harq_attempts; + char l[220]; + snprintf(l, sizeof l, + "{\"ev\":\"cell.qos\",\"ue\":%u,\"qi\":%u,\"harq_attempts\":%d,\"discard_ms\":%lld," + "\"pdb_unmet\":%d,\"la_down\":%.2f,\"la_up\":%.2f}", + ue, it->second.qi, h.max_attempts, (long long)it->second.discard_ms, it->second.pdb_unmet ? 1 : 0, + it->second.la.down_loss, it->second.la.up_loss); + emit(l); + return DlUe{HarqSender(h), LinkAdapt(it->second.la, dl_rate0)}; + }; for (uint16_t ue : ues) - dlue.emplace(ue, DlUe{HarqSender(hc), LinkAdapt({}, dl_rate0)}); + dlue.emplace(ue, make_dlue(ue)); std::vector rar_on_air; /* the RACH answers in the live beacon */ std::vector ho_on_air; /* the handover IEs in the live beacon */ std::vector rar_on_air_set; /* ... as answers, for accounting */ @@ -1891,6 +1939,16 @@ int run_du(IRadio *dev, const Cfg &c) { q.last_sn = dp->sn; q.sns.push_back(dp->sn); q.last_push = std::chrono::steady_clock::now(); + q.at_ms[dp->sn] = now_ms(); + if (q.at_ms.size() > 4 * kDlQueueMax) { + /* Forget the old: a PDU in HARQ flight may come back to the + * queue (an arrival's abandon), so recent ones are kept too. */ + const int64_t nq = now_ms(); + for (auto a = q.at_ms.begin(); a != q.at_ms.end();) + a = nq - a->second > 10000 && std::find(q.sns.begin(), q.sns.end(), a->first) == q.sns.end() + ? q.at_ms.erase(a) + : std::next(a); + } const auto du_it = dlue.find(dp->ue); if (q.offered || du_it == dlue.end() || du_it->second.arriving) { /* a DAPS target's queue (staged, arriving, or answering visits): @@ -2278,6 +2336,28 @@ int run_du(IRadio *dev, const Cfg &c) { if (d.arriving) arrival_abandon(d, ue); tx = d.harq.next(static_cast(sf), true); + /* A classed UE's retransmission past its discard timer (counted + * from the first attempt) is given up, not aired late. */ + if (auto pol = qos_pol.find(ue); pol != qos_pol.end()) + for (int k = 0; k < 32 && tx && tx->retx; ++k) { + auto f = d.first_ms.find(tx->seq); + const int64_t age = f == d.first_ms.end() ? 0 : now_ms() - f->second; + if (age <= pol->second.discard_ms) + break; + size_t n = 0; + if (auto pi = d.pdus.find(tx->seq); pi != d.pdus.end()) { + n = pi->second.second.size(); + d.pdus.erase(pi); + } + d.first_ms.erase(tx->seq); + d.harq.abandon(tx->seq); + char l[160]; + snprintf(l, sizeof l, + "{\"ev\":\"cell.dl.discard\",\"ue\":%u,\"sf\":%lld,\"n\":%zu,\"oldest_ms\":%lld,\"retx\":1}", + ue, (long long)sf, n, (long long)age); + emit(l); + tx = d.harq.next(static_cast(sf), true); + } } else { const uint32_t s = d.plain_seq++; tx = devourer::cell::HarqTx{s, s % devourer::cell::kHarqProcesses, s, 1, false}; @@ -2296,13 +2376,43 @@ int run_du(IRadio *dev, const Cfg &c) { * rest, still sent in case an answer was lost — the UE takes a * late copy within its reorder window. Without answers this is * plain queue order. */ + /* The QoS discard timer: a PDU queued past its UE's budget is + * dropped, not sent late (counted, logged per frame). */ + if (auto pol = qos_pol.find(ue); pol != qos_pol.end()) { + const int64_t now_q = now_ms(); + size_t n = 0; + int64_t oldest = 0; + auto &dq = qi->second.sns; + for (auto it = dq.begin(); it != dq.end();) { + auto a = qi->second.at_ms.find(*it); + if (a != qi->second.at_ms.end() && now_q - a->second > pol->second.discard_ms) { + oldest = std::max(oldest, now_q - a->second); + qi->second.at_ms.erase(a); + it = dq.erase(it); + ++n; + } else { + ++it; + } + } + if (n) { + qi->second.discarded += n; + char l[140]; + snprintf(l, sizeof l, "{\"ev\":\"cell.dl.discard\",\"ue\":%u,\"sf\":%lld,\"n\":%zu,\"oldest_ms\":%lld}", + ue, (long long)sf, n, (long long)oldest); + emit(l); + } + } pdus = pick_dl_pdus(qi->second.sns, qi->second.offered, kDlMaxPdus); if (harq_on && !pdus.empty()) d.pdus[tx->seq] = {pdu_epoch, pdus}; + if (harq_on) + d.first_ms[tx->seq] = now_ms(); } /* Resolved seqs (below the window's low edge) carry nothing more. */ for (auto pi = d.pdus.begin(); pi != d.pdus.end();) pi = devourer::cell::seq_lt(pi->first, tx->low) ? d.pdus.erase(pi) : std::next(pi); + for (auto fi = d.first_ms.begin(); fi != d.first_ms.end();) + fi = devourer::cell::seq_lt(fi->first, tx->low) ? d.first_ms.erase(fi) : std::next(fi); if (auto qi = dlq.find(ue); qi != dlq.end()) q_left = qi->second.sns.size(); } @@ -2564,7 +2674,7 @@ int run_du(IRadio *dev, const Cfg &c) { ta.emplace(u.ue, TaLoop(ta_gain, P / 4)); /* 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, DlUe{HarqSender(hc), LinkAdapt({}, dl_rate0)}); + 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; @@ -2585,7 +2695,7 @@ int run_du(IRadio *dev, const Cfg &c) { /* Fresh DL state; a joiner has no DL stream yet (the CU starts * one once this DU reports it): a queue left under its id is * another UE's. */ - dlue.insert_or_assign(u.ue, DlUe{HarqSender(hc), LinkAdapt({}, dl_rate0)}); + dlue.insert_or_assign(u.ue, make_dlue(u.ue)); dlq.erase(u.ue); last_ul_sf[u.ue] = sf; /* its silence clock starts at admission */ changed = true; diff --git a/src/cell/CellScheduler.h b/src/cell/CellScheduler.h new file mode 100644 index 00000000..be041d77 --- /dev/null +++ b/src/cell/CellScheduler.h @@ -0,0 +1,129 @@ +/* CellScheduler.h — M1 (#255) 5QI-style QoS classes for a cell's UEs. + * + * M1's grid is fixed (one DL and one UL opportunity per UE per superframe, + * no slot resizing), so a class cannot buy more airtime there. What it + * decides is HOW each UE is served, and who is served when there are more + * UEs than positions: + * - the packet delay budget (PDB) bounds the HARQ budget: the first + * attempt can take up to a superframe (the UE's DL slot), and a + * retransmission comes one HARQ round trip after the attempt before it + * (`retx_sf` superframes: the UE's feedback rides its uplink beacon, which + * takes a superframe or two to update), so the attempts that fit are + * 1 + floor((PDB - superframe) / (retx_sf * superframe)), at least one; + * and a DL PDU (or a retransmission) older than the budget is discarded + * rather than sent late (PDCP's discard timer). A PDB shorter than one + * superframe cannot be met by one DL opportunity per superframe: such a + * class is served with one attempt and flagged (`pdb_unmet`), not + * pretended; + * - the packet error rate (PER) target sets link adaptation: a stricter + * target steps the rate down sooner and up later; + * - the priority level orders admission, and decides which held UE a + * newcomer may preempt when the pool is full (lower value = higher + * priority, as in 5G). + * + * The classes are a subset of the standardized 5QI characteristics (3GPP TS + * 23.501, table 5.7.4-1): resource type, default priority level, PDB, PER. + * + * Headless; ctest cell_core. */ +#ifndef DEVOURER_CELL_CELLSCHEDULER_H +#define DEVOURER_CELL_CELLSCHEDULER_H + +#include +#include +#include +#include +#include + +#include "cell/LinkAdapt.h" + +namespace devourer::cell { + +struct QosClass { + uint8_t qi = 9; /* the 5QI */ + bool gbr = false; /* guaranteed bit rate (resource type) */ + uint8_t priority = 90; /* default priority level: lower = higher priority */ + uint32_t pdb_ms = 300; /* packet delay budget */ + double per = 1e-6; /* packet error rate target */ +}; + +/* The standardized characteristics of the 5QIs this cell knows. */ +inline std::optional qos_class(uint8_t qi) { + static const QosClass table[] = { + {1, true, 20, 100, 1e-2}, /* conversational voice */ + {2, true, 40, 150, 1e-3}, /* conversational video */ + {3, true, 30, 50, 1e-3}, /* real-time gaming, V2X, process automation monitoring */ + {5, false, 10, 100, 1e-6}, /* IMS signalling */ + {7, false, 70, 100, 1e-3}, /* voice, live video, interactive gaming */ + {9, false, 90, 300, 1e-6}, /* best effort (default bearer) */ + }; + for (const QosClass &c : table) + if (c.qi == qi) + return c; + return std::nullopt; +} + +/* How a UE of a class is served on a grid of superframe `period_us`. */ +struct UePolicy { + uint8_t qi = 9; + int harq_attempts = 1; /* first transmission included */ + int64_t discard_ms = 0; /* a DL PDU queued longer than this is dropped */ + LinkAdaptConfig la; + bool pdb_unmet = false; /* the PDB is shorter than one superframe */ +}; + +/* `base` is the cell's link adaptation; `max_attempts` its HARQ ceiling; + * `retx_sf` its HARQ round trip in superframes (HarqConfig::timeout_sf). + * The discard timer is never below one superframe plus `slot_wait_ms`: a PDU + * can wait that long for its UE's DL slot even on a perfect link. */ +inline UePolicy policy_for(const QosClass &q, int64_t period_us, const LinkAdaptConfig &base = {}, + int max_attempts = 4, int64_t slot_wait_ms = 5, int retx_sf = 3) { + UePolicy p; + p.qi = q.qi; + const int64_t pdb_us = static_cast(q.pdb_ms) * 1000; + p.pdb_unmet = pdb_us < period_us; + const int64_t rt_us = static_cast(std::max(1, retx_sf)) * period_us; + const int64_t retries = period_us > 0 && pdb_us > period_us ? (pdb_us - period_us) / rt_us : 0; + p.harq_attempts = static_cast(std::clamp(1 + retries, 1, std::max(1, max_attempts))); + p.discard_ms = std::max(q.pdb_ms, period_us / 1000 + slot_wait_ms); + p.la = base; + if (q.per >= 1e-2) { /* tolerant: hold a rate through more loss, climb sooner */ + p.la.down_loss = std::max(base.down_loss, 0.3); + p.la.up_loss = std::max(base.up_loss, 0.05); + } else if (q.per <= 1e-5) { /* strict: step down early, climb only with margin */ + p.la.down_loss = std::min(base.down_loss, 0.1); + p.la.up_loss = std::min(base.up_loss, 0.01); + p.la.hysteresis_db = base.hysteresis_db + 1.0; + } + return p; +} + +/* Admission order: by priority level, GBR before non-GBR at a tie, then the + * order given (first come). Returns indices into `cls`. */ +inline std::vector admission_order(const std::vector &cls) { + std::vector o(cls.size()); + for (size_t i = 0; i < o.size(); ++i) + o[i] = i; + std::stable_sort(o.begin(), o.end(), [&](size_t a, size_t b) { + if (cls[a].priority != cls[b].priority) + return cls[a].priority < cls[b].priority; + return cls[a].gbr && !cls[b].gbr; + }); + return o; +} + +/* A full pool and a newcomer: which held UE it may preempt — the one of the + * lowest priority, only if strictly lower than the newcomer's (a tie keeps + * whoever holds the position); the latest of equals. nullopt: deny. */ +inline std::optional preempt_for(const QosClass &newcomer, const std::vector &held) { + std::optional worst; + for (size_t i = 0; i < held.size(); ++i) + if (!worst || held[i].priority >= held[*worst].priority) + worst = i; + if (worst && held[*worst].priority > newcomer.priority) + return worst; + return std::nullopt; +} + +} /* namespace devourer::cell */ + +#endif /* DEVOURER_CELL_CELLSCHEDULER_H */ diff --git a/src/cell/Harq.h b/src/cell/Harq.h index d861a9ae..bc1becf7 100644 --- a/src/cell/Harq.h +++ b/src/cell/Harq.h @@ -251,6 +251,18 @@ class HarqSender { return out; } + /* Give up one frame in flight (its content expired: a QoS discard timer). + * Counted as abandoned; false if `seq` is not in flight. */ + bool abandon(uint32_t seq) { + for (auto it = q_.begin(); it != q_.end(); ++it) + if (it->seq == seq) { + q_.erase(it); + ++st_.abandoned; + return true; + } + return false; + } + /* Window low edge: the oldest unresolved seq, or the next new one. */ uint32_t low() const { return q_.empty() ? next_seq_ : q_.front().seq; } size_t inflight() const { return q_.size(); } diff --git a/tests/cell_m2_selftest.cpp b/tests/cell_m2_selftest.cpp index 0950d815..d1dab6b3 100644 --- a/tests/cell_m2_selftest.cpp +++ b/tests/cell_m2_selftest.cpp @@ -140,6 +140,17 @@ int main() { CHECK(t && !t->retx && t->seq == 3 && t->low == 3); } + /* --- abandon(seq): one expired frame given up, the rest untouched. --- */ + { + HarqConfig c; + c.timeout_sf = 10; + HarqSender s(c); + for (int i = 0; i < 3; ++i) + s.next(static_cast(i), true); + CHECK(s.abandon(1) && !s.abandon(1) && s.inflight() == 2 && s.stats().abandoned == 1); + CHECK(s.low() == 0); + } + /* --- Implausible feedback (claims seqs never sent) is ignored whole. --- */ { HarqSender s; diff --git a/tests/cell_selftest.cpp b/tests/cell_selftest.cpp index 8d4b1bf3..155a1622 100644 --- a/tests/cell_selftest.cpp +++ b/tests/cell_selftest.cpp @@ -7,6 +7,7 @@ #include #include +#include "cell/CellScheduler.h" #include "cell/GrantMap.h" #include "cell/SlotGrid.h" @@ -277,6 +278,41 @@ int main() { CHECK(r.current(1)->cell_id == 2 && r.current(1)->epoch == 3); } + /* --- CellScheduler: 5QI classes -> per-UE policy, admission. --- */ + { + using namespace devourer::cell; + CHECK(qos_class(9) && qos_class(9)->priority == 90 && !qos_class(9)->gbr && qos_class(9)->pdb_ms == 300); + CHECK(qos_class(1) && qos_class(1)->gbr && qos_class(1)->per == 1e-2); + CHECK(!qos_class(42)); + const int64_t P = 102400; /* 100 TU */ + /* best effort, 300 ms, a 3-superframe HARQ round trip: (300 - 102.4) / + * 307.2 -> no retry fits; with a 1-superframe round trip one does */ + auto be = policy_for(*qos_class(9), P); + CHECK(be.harq_attempts == 1 && be.discard_ms == 300 && !be.pdb_unmet); + CHECK(policy_for(*qos_class(9), P, {}, 4, 5, 1).harq_attempts == 2); + CHECK(be.la.down_loss == 0.1 && be.la.up_loss == 0.01 && be.la.hysteresis_db == LinkAdaptConfig{}.hysteresis_db + 1); + /* voice, 100 ms < one superframe: one attempt, flagged, discard not + * below a superframe + the slot wait; tolerant PER */ + auto vo = policy_for(*qos_class(1), P); + CHECK(vo.harq_attempts == 1 && vo.pdb_unmet && vo.discard_ms == 102 + 5); + CHECK(vo.la.down_loss == 0.3 && vo.la.up_loss == 0.05); + /* the same class on a 20 TU superframe: (100 - 20.48) / 61.44 -> 1 + * retry (2 attempts); with a 1-superframe round trip, 4 (capped) */ + auto vo20 = policy_for(*qos_class(1), 20480, {}, 4); + CHECK(vo20.harq_attempts == 2 && !vo20.pdb_unmet && vo20.discard_ms == 100); + CHECK(policy_for(*qos_class(1), 20480, {}, 4, 5, 1).harq_attempts == 4); + /* 1e-3: the cell's own link adaptation */ + CHECK(policy_for(*qos_class(7), P).la.down_loss == LinkAdaptConfig{}.down_loss); + /* admission: priority, GBR first at a tie, then first come */ + QosClass g{100, true, 50, 100, 1e-3}, n{101, false, 50, 100, 1e-3}; + CHECK(admission_order({*qos_class(9), *qos_class(5), n, g, *qos_class(1)}) == + std::vector({1, 4, 3, 2, 0})); + /* preemption: only a strictly lower priority, the lowest (latest of equals) */ + CHECK(preempt_for(*qos_class(5), {*qos_class(9), *qos_class(7), *qos_class(9)}) == 2u); + CHECK(!preempt_for(*qos_class(9), {*qos_class(9), *qos_class(7)})); + CHECK(!preempt_for(*qos_class(7), {*qos_class(5)})); + } + if (failures == 0) std::printf("cell_selftest: all checks passed\n"); return failures == 0 ? 0 : 1;