From 5dc244519ecde3686f9362cf3a34448841889f73 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:32:15 +0300 Subject: [PATCH] LDPC as a first-class primitive: bench-derived RX caps, encoding proof on rx.txhit, per-encoding counters, coding-gain waterfall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bench campaign (encoding-matrix devourer<->devourer cells, 8812AU reference, ch6/ch36): every plugged chip — 8812AU, 8814AU, 8822BU, 8812CU, 8812EU — passes HT-LDPC / HT-LDPC+STBC / VHT-LDPC both directions at full delivery. The vendor HAL_DEF_RX_LDPC table (all-false on Jaguar1, "IOT issue" C-cut TX disable) is 2013-era interop-advertisement policy, not hardware truth. - AdapterCaps: ldpc_rx_ht / ldpc_rx_vht / ldpc_rx_flag — the bench-derived LDPC RX truth table, split HT/VHT because the decoders are separate silicon paths (the 8821A field failure is VHT-only). ldpc_rx_flag=false on the 8814A: it decodes LDPC but the vendor wired no per-frame indicator (rxdesc offsets 16/20 unparsed, no ldpc bit in the Jaguar1 phy-status report) — RxAtrib.ldpc reads 0 on frames it decoded fine. - rx.txhit gains rate/bw/stbc/ldpc so an encoding-matrix pass proves what encoding was actually decoded, not just that frames arrived. - rx.energy gains frames_ldpc/frames_stbc (DEVOURER_RX_AGG_SA-filtered) — the live per-encoding delivery split an adaptive controller compares. - regress.py --modes txside:rxside filter: LDPC/STBC truth-table runs want --modes devourer:devourer (kernel-TX rows strip those bits and double the runtime). - tests/ldpc_waterfall.{sh,py}: delivery-vs-TXAGC-index waterfalls for BCC vs LDPC at the same MCS; measured on 8812AU->8822BU at MCS7/20MHz: +3.0 dB LDPC gain at the 10%-delivery crossing, and in the near-field saturation regime LDPC delivered ~80% where BCC rolled over to 0-19%. Hardware-validated: caps events (8812A 1/1/1, 8814A 1/1/0), frames_ldpc == frames on a pure LDPC feed, ldpc=1 txhits confirmed on 8822BU/8812CU/ 8812AU RX. ctest 20/20. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 14 +++- docs/logging.md | 6 +- examples/common/caps_event.h | 5 ++ examples/rx/main.cpp | 25 +++++- src/AdapterCaps.h | 14 ++++ src/jaguar1/RtlJaguarDevice.cpp | 22 ++++- src/jaguar2/RtlJaguar2Device.cpp | 7 ++ src/jaguar3/RtlJaguar3Device.cpp | 7 ++ tests/README.md | 39 +++++++++ tests/ldpc_waterfall.py | 93 +++++++++++++++++++++ tests/ldpc_waterfall.sh | 134 +++++++++++++++++++++++++++++++ tests/regress.py | 45 +++++++++-- 12 files changed, 398 insertions(+), 13 deletions(-) create mode 100755 tests/ldpc_waterfall.py create mode 100755 tests/ldpc_waterfall.sh diff --git a/CLAUDE.md b/CLAUDE.md index 3f432d7..d9e3107 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,7 +131,9 @@ cells read 0 on every channel (`aircrack-ng/88XXau` doesn't emit host-pushed Layered modes: `--full-matrix` (every ordered DUT pair × 4 driver combos), `--encoding-matrix --tx-pid --rx-pid` (radiotap encoding combos; the kernel-TX -rows are not authoritative for LDPC/STBC — that driver strips those bits), +rows are not authoritative for LDPC/STBC — that driver strips those bits — +so truth-table runs add `--modes devourer:devourer`; per-cell `rx.txhit` +events carry the decoded `rate`/`ldpc`/`stbc` as proof of what flew), `--sniffer-iface IFACE` (3rd-adapter capture, intended for AR9271). `--keep-logs` puts per-cell logs at `/tmp/devourer-regress-last/`. Full semantics: `tests/README.md`. @@ -191,6 +193,16 @@ transport / chip-id), TX/RX chain counts, the composed `GetTxCaps` + characterized frequency spans, and feature flags (per-packet TX power, narrowband, fast retune, per-chain RSSI) — resolved at construction, thread-safe, callable pre-`Init`. The demos emit it as the `adapter.caps` JSONL event. +The `ldpc_rx_ht`/`ldpc_rx_vht`/`ldpc_rx_flag` flags are the bench-derived LDPC +RX truth table (deliberately NOT the vendor `HAL_DEF_RX_LDPC`, which is +2013-era interop-advertisement policy — all-false on Jaguar1 while the 8812A +demonstrably decodes LDPC): every supported chip decodes HT+VHT LDPC except +the 8821A (VHT-LDPC RX broken — field-reported, HT fine), and the 8814A +decodes LDPC but reports no per-frame flag (`ldpc_rx_flag=0`, +`RxAtrib.ldpc` reads 0). LDPC TX is per-packet radiotap-driven on all +generations (`TxCaps.ldpc_ok`); bench-measured coding gain ≈ +3 dB at the +10%-delivery crossing, MCS7/20 MHz (`tests/ldpc_waterfall.sh`) — prefer +`/LDPC` on any link whose RX side can decode it. `GetActiveRxPaths()` is the live companion: a best-effort per-chain-RSSI estimate of which antennas actually carry signal (needs an RX loop + traffic). The 5 GHz synthesizer tunes past the UNII channels (extended range ~5080–6165 MHz, chan up diff --git a/docs/logging.md b/docs/logging.md index bf8c722..e4fc9ee 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -77,7 +77,7 @@ Emitters: L = library, RX/TX/... = demo. Optional fields in [brackets]; | ev | emitter | fields | |---|---|---| | `init.timing` | L (`src/InitTimer.h`) + demos | stage ("scope.stage", e.g. "demo.first_rx_frame", "txdemo.first_tx_submit"), ms | -| `adapter.caps` | RX, TX, doctor, txpower (`examples/common/caps_event.h`) | supported, chip, names, chip_id "0x..", gen, variant, transport, tx_chains, rx_chains, n_ss, stbc, ldpc, sgi, bw_max, bw[] (MHz), txpwr_max, txpwr_step_qdb, txpwr_step_measured, txpwr_min_qdb, txpwr_max_qdb, tune_2g4[]\|null, tune_5g[]\|null, char_2g4[]\|null, char_5g[]\|null, per_pkt_txpwr, narrowband, fastretune, per_chain_rssi | +| `adapter.caps` | RX, TX, doctor, txpower (`examples/common/caps_event.h`) | supported, chip, names, chip_id "0x..", gen, variant, transport, tx_chains, rx_chains, n_ss, stbc, ldpc, sgi, bw_max, bw[] (MHz), txpwr_max, txpwr_step_qdb, txpwr_step_measured, txpwr_min_qdb, txpwr_max_qdb, tune_2g4[]\|null, tune_5g[]\|null, char_2g4[]\|null, char_5g[]\|null, ldpc_rx_ht, ldpc_rx_vht, ldpc_rx_flag, per_pkt_txpwr, narrowband, fastretune, per_chain_rssi | | `debug.wreg` | L (`DEVOURER_LOG_WRITES`) | addr "0x0nnn", width, val "0x…" | | `hop.prof` | L (`DEVOURER_HOP_PROF`) | gen, ch, `_us`…, total_us | | `tx.fail` | L (send failure; regress.py keys on it) | {status, actual_len, timeout} or {rc, timeout} | @@ -89,12 +89,12 @@ Emitters: L = library, RX/TX/... = demo. Optional fields in [brackets]; | `rx.frame` | RX (`DEVOURER_STREAM_OUT`), duplex | rate, len, crc, icv, rssi[2], evm[2], snr[2], seq, tsfl, bw, stbc, ldpc, sgi, paggr, ppdu, fc1 (FC flags byte; bit3 = 802.11 RETRY), body hex; `tx_tsf` (sender's hardware egress TSF) on beacons/probe-responses only | | `rx.body` | RX (`DEVOURER_DUMP_BODY`) | rate, rssi[2], evm[2], snr[2], crc, len, body hex | | `rx.corrupt` | RX (`DEVOURER_RX_DUMP_ALL`) | len, crc, icv, rate, bw, stbc, ldpc, sgi, rssi[2], evm[2], snr[2] | -| `rx.txhit` | RX, TX | hits, total_rx, len, seq, paggr, ppdu — canonical-SA (57:42:75:05:d6:00) matcher | +| `rx.txhit` | RX, TX | hits, total_rx, len, seq, paggr, ppdu, rate, bw, stbc, ldpc — canonical-SA (57:42:75:05:d6:00) matcher; rate/ldpc prove what encoding was decoded (8814A reports ldpc=0 always — no HW indicator) | | `rx.count` | TX (its RX thread) | total, len | | `rx.path` | RX (`DEVOURER_RX_ALLPATHS`) | seq, rssi[4], snr[4], evm[4] | | `rx.path_mask` | L (toggle spec) | t, mask "0xNN" | | `rx.scrambler` | RX (`DEVOURER_DUMP_SCRAMBLER`) | seed "0xNN", rate, hits, len | -| `rx.energy` | RX (`DEVOURER_RX_ENERGY_MS` / sweep) | t, [ch], cca_ofdm\|null, cca_cck\|null, fa_ofdm\|null, fa_cck\|null, igi\|null, [retune_us], frames, rssi_mean, rssi_max, snr_mean, snr_min, evm_mean | +| `rx.energy` | RX (`DEVOURER_RX_ENERGY_MS` / sweep) | t, [ch], cca_ofdm\|null, cca_cck\|null, fa_ofdm\|null, fa_cck\|null, igi\|null, [retune_us], frames, frames_ldpc, frames_stbc, rssi_mean, rssi_max, snr_mean, snr_min, evm_mean | | `rx.nhm` | RX | [ch], peak, busy, dur, hist[12] | | `rx.quality` | RX (`DEVOURER_RXQUALITY`) | verdict, frames, rssi_mean_dbm, rssi_max_dbm, snr_mean_db, snr_min_db, evm_db\|null, noise_floor_dbm\|null, igi | | `adapter.rxpaths` | RX (`DEVOURER_RXQUALITY`) | active_mask "0xNN", n_active, n_chains, frames, rssi_dbm[] — GetActiveRxPaths live per-chain activity (the caps rx_chains companion) | diff --git a/examples/common/caps_event.h b/examples/common/caps_event.h index cc2518d..39db619 100644 --- a/examples/common/caps_event.h +++ b/examples/common/caps_event.h @@ -63,6 +63,11 @@ inline void emit_adapter_caps(EventSink &sink, IRtlDevice *dev) { band("char_2g4", c.characterized_2g4); band("char_5g", c.characterized_5g); + /* FEC RX truth table (ldpc above is the TX side, TxCaps.ldpc_ok). */ + ev.f("ldpc_rx_ht", c.ldpc_rx_ht ? 1 : 0) + .f("ldpc_rx_vht", c.ldpc_rx_vht ? 1 : 0) + .f("ldpc_rx_flag", c.ldpc_rx_flag ? 1 : 0); + ev.f("per_pkt_txpwr", c.per_packet_txpower ? 1 : 0) .f("narrowband", c.narrowband_ok ? 1 : 0) .f("fastretune", c.fastretune_ok ? 1 : 0) diff --git a/examples/rx/main.cpp b/examples/rx/main.cpp index 125b8ce..0bcd829 100644 --- a/examples/rx/main.cpp +++ b/examples/rx/main.cpp @@ -409,13 +409,19 @@ struct RxAgg { int32_t rssi_sum = 0, rssi_max = -128, snr_sum = 0, snr_min = 127; int32_t evm_sum = 0; uint32_t evm_n = 0; - void add(int rssi, int snr, int evm) { + /* Per-encoding split — what an adaptive controller compares live (BCC vs + * LDPC delivery on the same link). Counts what the chip *reports*: on the + * 8814A ldpc reads 0 even on LDPC frames (AdapterCaps.ldpc_rx_flag). */ + uint32_t n_ldpc = 0, n_stbc = 0; + void add(int rssi, int snr, int evm, bool ldpc = false, bool stbc = false) { ++n; rssi_sum += rssi; if (rssi > rssi_max) rssi_max = rssi; snr_sum += snr; if (snr < snr_min) snr_min = snr; if (evm != 0) { evm_sum += evm; ++evm_n; } + if (ldpc) ++n_ldpc; + if (stbc) ++n_stbc; } }; static RxAgg g_rxagg; @@ -564,7 +570,8 @@ static void packetProcessor(const Packet &packet) { if ((g_rx_energy_ms > 0 || !g_rx_sweep.empty()) && agg_sa_match(packet)) { std::lock_guard lk(g_rxagg_mu); g_rxagg.add(packet.RxAtrib.rssi[0], packet.RxAtrib.snr[0], - packet.RxAtrib.evm[0]); + packet.RxAtrib.evm[0], packet.RxAtrib.ldpc != 0, + packet.RxAtrib.stbc != 0); } if (g_rx_count == 1) { @@ -622,13 +629,21 @@ static void packetProcessor(const Packet &packet) { static int hits = 0; ++hits; if (hits <= 10 || hits % 100 == 0) { + /* rate/bw/ldpc/stbc mirror the rx.frame fields: for encoding-matrix + * runs the txhit event alone must prove what encoding was decoded + * (a pass with ldpc=0 means the TX fell back to BCC, not that the + * RX decoded LDPC). */ devourer::Ev(*g_ev, "rx.txhit") .f("hits", hits) .f("total_rx", g_rx_count) .f("len", packet.Data.size()) .f("seq", packet.RxAtrib.seq_num) .f("paggr", packet.RxAtrib.paggr ? 1 : 0) - .f("ppdu", packet.RxAtrib.ppdu_cnt); + .f("ppdu", packet.RxAtrib.ppdu_cnt) + .f("rate", packet.RxAtrib.data_rate) + .f("bw", packet.RxAtrib.bw) + .f("stbc", packet.RxAtrib.stbc) + .f("ldpc", packet.RxAtrib.ldpc); } #if defined(DEVOURER_HAVE_JAGUAR1) /* F2: BB-dbgport sweep on the first kCsiMaxFrames canonical-SA frames. @@ -1138,6 +1153,8 @@ int main() { else ev.f("igi", nullptr); ev.f("frames", agg.n) + .f("frames_ldpc", agg.n_ldpc) + .f("frames_stbc", agg.n_stbc) .f("rssi_mean", rssi_mean) .f("rssi_max", agg.n ? agg.rssi_max : 0) .f("snr_mean", snr_mean) @@ -1458,6 +1475,8 @@ int main() { ev.f("igi", nullptr); ev.f("retune_us", retune_us) .f("frames", agg.n) + .f("frames_ldpc", agg.n_ldpc) + .f("frames_stbc", agg.n_stbc) .f("rssi_mean", rssi_mean) .f("rssi_max", agg.n ? agg.rssi_max : 0) .f("snr_mean", snr_mean) diff --git a/src/AdapterCaps.h b/src/AdapterCaps.h index 5980e08..81e17b5 100644 --- a/src/AdapterCaps.h +++ b/src/AdapterCaps.h @@ -111,6 +111,20 @@ struct AdapterCaps { BandRange tune_2g4, tune_5g; /* synthesizer-tunable spans */ BandRange characterized_2g4, characterized_5g; /* txpwr-table-backed spans */ + /* --- FEC RX (bench-derived truth table — deliberately NOT the vendor + * driver's HAL_DEF_RX_LDPC, which is 2013-era interop-advertisement policy: + * all-false on Jaguar1 while the 8812A baseband demonstrably decodes LDPC + * on-air. The TX side lives in TxCaps.ldpc_ok. HT and VHT are separate + * decoder paths in silicon, so the flags split: the RTL8821A field failure + * ("PixelPilot can't RX LDPC from Eachine Sphere Link") is VHT-only. --- */ + bool ldpc_rx_ht = false; /* baseband decodes LDPC-coded HT PPDUs */ + bool ldpc_rx_vht = false; /* baseband decodes LDPC-coded VHT PPDUs */ + /* Per-frame LDPC *reporting*: RxAtrib.ldpc is populated (RX-descriptor bit + * on the 8812A die, PHY-status byte7[5] on Jaguar2/3). False on the 8814A — + * it decodes LDPC fine but the vendor wired no per-frame indicator (rxdesc + * offsets 16/20 unparsed, and the Jaguar1 phy_status_rpt has no ldpc bit). */ + bool ldpc_rx_flag = false; + /* --- feature flags --- */ bool per_packet_txpower = false; /* Jaguar2 descriptor TXPWR_OFSET LUT only */ bool narrowband_ok = false; /* 5/10 MHz re-clock (Jaguar2/Jaguar3) */ diff --git a/src/jaguar1/RtlJaguarDevice.cpp b/src/jaguar1/RtlJaguarDevice.cpp index e136b2a..a191cb3 100644 --- a/src/jaguar1/RtlJaguarDevice.cpp +++ b/src/jaguar1/RtlJaguarDevice.cpp @@ -1555,21 +1555,41 @@ devourer::AdapterCaps RtlJaguarDevice::GetAdapterCaps() { devourer::set_standard_freq_ranges(c); /* Identity from the EFUSE version-id. The die name is refined by the RF-type: - * the 8812 die shipped as both the 2T2R 8812AU and the 1T1R 8811AU cut. */ + * the 8812 die shipped as both the 2T2R 8812AU and the 1T1R 8811AU cut. + * + * LDPC RX truth per die (bench: encoding-matrix devourer↔devourer cells, + * HT-LDPC / HT-LDPC+STBC / VHT-LDPC at full delivery, RX-side ldpc flag + * cross-checked where the chip reports one): + * - 8812A (incl. 8811A cut): decodes HT+VHT LDPC, reports the RX-desc bit. + * - 8814A: decodes HT+VHT LDPC but has NO per-frame indicator (vendor + * rxdesc parse leaves offsets 16/20 empty; the Jaguar1 phy-status report + * carries no ldpc bit) — RxAtrib.ldpc reads 0 on LDPC frames. + * - 8821A: VHT-LDPC RX broken in the field (Eachine Sphere Link → + * PixelPilot reports); the HT decoder is a separate silicon path and + * passed a prior HT-LDPC bench cell. */ switch (_eepromManager->version_id.ICType) { case CHIP_8814A: c.chip_name = "RTL8814A"; c.marketing_names = "RTL8814AU"; c.chip_id = 0x08; c.variant = "8814A"; + c.ldpc_rx_ht = true; + c.ldpc_rx_vht = true; + c.ldpc_rx_flag = false; break; case CHIP_8821: c.chip_name = "RTL8821A"; c.marketing_names = "RTL8821AU"; c.chip_id = 0x05; c.variant = "8821A"; + c.ldpc_rx_ht = true; + c.ldpc_rx_vht = false; + c.ldpc_rx_flag = true; break; default: /* CHIP_8812 */ + c.ldpc_rx_ht = true; + c.ldpc_rx_vht = true; + c.ldpc_rx_flag = true; if (_eepromManager->version_id.RFType == RF_TYPE_1T1R) { c.chip_name = "RTL8811A"; c.marketing_names = "RTL8811AU/RTL8811AR"; diff --git a/src/jaguar2/RtlJaguar2Device.cpp b/src/jaguar2/RtlJaguar2Device.cpp index b7f37a4..3c52817 100644 --- a/src/jaguar2/RtlJaguar2Device.cpp +++ b/src/jaguar2/RtlJaguar2Device.cpp @@ -978,6 +978,13 @@ devourer::AdapterCaps RtlJaguar2Device::GetAdapterCaps() { : (_hal.efuse_logical_byte(0xB9) & 0x3f); c.fastretune_ok = true; c.per_packet_txpower = true; /* TX descriptor TXPWR_OFSET LUT — Jaguar2 only */ + /* LDPC RX: both variants decode HT+VHT LDPC (bench: encoding-matrix + * devourer↔devourer cells at full delivery, 8822BU cross-checked reporting + * ldpc=1) and report it per-frame from PHY-status byte7[5] + * (parse_phy_sts_jgr2). */ + c.ldpc_rx_ht = true; + c.ldpc_rx_vht = true; + c.ldpc_rx_flag = true; devourer::set_standard_freq_ranges(c); if (_variant == jaguar2::ChipVariant::C8821C) { diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index 9a5e515..1972576 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -1179,6 +1179,13 @@ devourer::AdapterCaps RtlJaguar3Device::GetAdapterCaps() { c.hw_beacon_txtsf = true; /* StartBeacon: MAC inserts the egress TSF into beacons */ c.xtal_cap_max = 0x7f; /* 7-bit AFE crystal-cap trim (0x1040) */ c.xtal_cap_default = 0x20; + /* LDPC RX: both variants decode HT+VHT LDPC (bench: encoding-matrix + * devourer↔devourer cells at full delivery, 8812CU + 8812EU-paired 8812AU + * cross-checked reporting ldpc=1) and report it per-frame from PHY-status + * byte7[5] (parse_phy_sts_jgr3). */ + c.ldpc_rx_ht = true; + c.ldpc_rx_vht = true; + c.ldpc_rx_flag = true; devourer::set_standard_freq_ranges(c); if (_variant == jaguar3::ChipVariant::C8822E) { diff --git a/tests/README.md b/tests/README.md index 432f5d7..c0aa579 100644 --- a/tests/README.md +++ b/tests/README.md @@ -268,6 +268,23 @@ sudo python3 tests/regress.py --encoding-matrix \ --vm-name devourer-testrig --vm-ssh @ ``` +`--modes` restricts the driver-mode rows to a comma list of `txside:rxside` +entries — LDPC/STBC truth-table runs want `--modes devourer:devourer`, since +the kernel-TX rows are never authoritative for those bits (the kernel driver +strips them; see the caveat below) and the kernel rows double the runtime: + +```bash +sudo python3 tests/regress.py --encoding-matrix --modes devourer:devourer \ + --tx-pid 0x8812 --rx-pid 0x012d --channel 6 --duration 8 +``` + +Each devourer-RX cell's `rx.txhit` events carry the received frame's +`rate`/`bw`/`stbc`/`ldpc` so a pass proves the encoding actually flew (a pass +with `ldpc:0` means the TX fell back to BCC, not that the RX decoded LDPC). +Exception: the RTL8814AU reports `ldpc:0` even on LDPC frames it decoded — +the chip has no per-frame LDPC indicator (`AdapterCaps.ldpc_rx_flag=0`); +judge its RX by hit count, its TX by the paired 8812AU's flag. + Encoding combos iterated by default — 6 cells per driver mode × 4 driver modes = 24 cells total per run: @@ -323,6 +340,28 @@ The `d/k` and `d/d` rows are not affected by the kernel-TX caveat — chip's bulk-OUT buffer, so the `DEVOURER_TX_*` env vars are ground truth for what flies on devourer TX. +### `ldpc_waterfall.sh`: measured LDPC coding gain + +Traces delivery-vs-TX-power waterfalls for the same MCS with BCC vs LDPC +coding and reads the horizontal shift = coding gain in dB. The emitter steps +a flat TXAGC index per point (`DEVOURER_TX_PWR`, 0.5 dB/step Jaguar1/2, +0.25 dB Jaguar3); the ground counts canonical-SA FCS-clean frames per +`rx.energy` window (`DEVOURER_RX_AGG_SA=canon`); PER denominators come from +the emitter's exact final `tx.stats`. + +```bash +sudo tests/ldpc_waterfall.sh --emit-pid 0x8812 --ground-pid 0x012d \ + --ground-vid 0x2357 --channel 6 --rate MCS7 --pwr-start 22 --pwr-stop 52 +python3 tests/ldpc_waterfall.py /tmp/devourer-ldpc-waterfall/points.jsonl \ + --step-qdb 2 --thresholds 0.1,0.5 +``` + +Sweep the noise-limited rising edge — at high index near-field, both curves +distort (the BCC MCS7 curve rolls over and dies while LDPC keeps decoding; +compare curves only on the rising edge). Bench-measured on an 8812AU→8822BU +pair at MCS7/20 MHz: **+3.0 dB LDPC gain at the 10%-delivery crossing**, and +in the saturation regime LDPC delivered ~80% where BCC delivered 0–19%. + ## Supported DUTs Listed in `SUPPORTED_DUTS` at the top of `regress.py`. Extend the dict diff --git a/tests/ldpc_waterfall.py b/tests/ldpc_waterfall.py new file mode 100755 index 0000000..b579dc8 --- /dev/null +++ b/tests/ldpc_waterfall.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Analyze an ldpc_waterfall.sh points.jsonl: per-encoding delivery-vs-index +curves and the interpolated horizontal shift between them = measured LDPC +coding gain in dB. + + python3 tests/ldpc_waterfall.py /tmp/devourer-ldpc-waterfall/points.jsonl \ + [--step-qdb 2] [--thresholds 0.5,0.9] + +--step-qdb: qdB per TXAGC index step (2 = 0.5 dB on Jaguar1/2, 1 = 0.25 dB +on Jaguar3 — read it off the emitter's txpwr.caps event if unsure). + +The gain is read where each curve crosses a delivery-ratio threshold +(linear interpolation between bracketing points). Points where the curve is +non-monotonic near-field garbage (AGC saturation at high index) only matter +if they bracket a threshold — sweep the noise-limited regime. +""" +import argparse +import json +import sys +from collections import defaultdict + + +def crossing(curve: list[tuple[int, float]], thr: float): + """First index (interpolated) where delivery ratio rises through thr. + curve: sorted [(idx, ratio)].""" + prev_i, prev_r = None, None + for i, r in curve: + if prev_r is not None and prev_r < thr <= r: + # linear interp between (prev_i, prev_r) and (i, r) + return prev_i + (thr - prev_r) * (i - prev_i) / (r - prev_r) + prev_i, prev_r = i, r + return None + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("points", help="points.jsonl from ldpc_waterfall.sh") + ap.add_argument("--step-qdb", type=float, default=2.0, + help="qdB per TXAGC index (default 2 = 0.5 dB, Jaguar1/2)") + ap.add_argument("--thresholds", default="0.5,0.9", + help="delivery-ratio crossings to compare (default 0.5,0.9)") + args = ap.parse_args() + + curves: dict[str, list[tuple[int, float, int, int]]] = defaultdict(list) + for line in open(args.points, errors="replace"): + line = line.strip() + if not line: + continue + p = json.loads(line) + sent, delivered = p["sent"], p["delivered"] + if sent <= 0: + print(f"! dropping point enc={p['enc']} idx={p['idx']}: " + f"sent={sent} (no final tx.stats — TX died?)", + file=sys.stderr) + continue + ratio = min(1.0, delivered / sent) + curves[p["enc"]].append((p["idx"], ratio, sent, delivered)) + + for enc, pts in curves.items(): + pts.sort() + print(f"\n== {enc}") + print(" idx | sent | delivered | ratio") + for idx, ratio, sent, delivered in pts: + bar = "#" * int(ratio * 40) + print(f" {idx:3d} | {sent:5d} | {delivered:9d} | {ratio:5.1%} {bar}") + + thrs = [float(t) for t in args.thresholds.split(",")] + encs = sorted(curves) + if len(encs) < 2: + print("\n(need two encodings for a gain readout)") + return + # Convention: compare each /LDPC spec against its base spec. + for enc in encs: + if not enc.upper().endswith("/LDPC"): + continue + base = enc[: -len("/LDPC")] + if base not in curves: + continue + print(f"\n== gain: {enc} vs {base} (step {args.step_qdb / 4:.2f} dB/idx)") + for thr in thrs: + cb = crossing([(i, r) for i, r, *_ in sorted(curves[base])], thr) + cl = crossing([(i, r) for i, r, *_ in sorted(curves[enc])], thr) + if cb is None or cl is None: + print(f" @{thr:.0%} delivery: not bracketed " + f"(base={cb}, ldpc={cl}) — widen/lower the sweep") + continue + gain_db = (cb - cl) * args.step_qdb / 4.0 + print(f" @{thr:.0%} delivery: base idx {cb:.1f} vs ldpc idx " + f"{cl:.1f} -> LDPC gain {gain_db:+.2f} dB") + + +if __name__ == "__main__": + main() diff --git a/tests/ldpc_waterfall.sh b/tests/ldpc_waterfall.sh new file mode 100755 index 0000000..23450f6 --- /dev/null +++ b/tests/ldpc_waterfall.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# LDPC coding-gain waterfall — measure the dB gap between BCC and LDPC at the +# same MCS by tracing delivery-vs-TX-power curves and reading the horizontal +# shift. The emitter steps a flat TXAGC index (DEVOURER_TX_PWR → +# SetTxPowerIndexOverride, 0.5 dB/step on Jaguar1/2, 0.25 dB on Jaguar3); the +# ground adapter counts canonical-SA frames per rx.energy window +# (DEVOURER_RX_AGG_SA=canon, FCS-clean only — corrupt frames never reach the +# aggregate). Per point: delivered = Σ rx.energy frames across the txdemo +# lifetime, sent = the final tx.stats submitted (exact, final:1). +# +# Bench notes (docs/bench-testing-near-field.md): sweep the noise-limited +# low-index regime — near-field at high index both curves sit at PER≈0 and +# the waterfall is invisible. If even index 0 delivers ~100%, add physical +# attenuation/distance or raise the MCS. +# +# sudo tests/ldpc_waterfall.sh --emit-pid 0x8812 --ground-pid 0x012d \ +# --channel 6 --rate MCS7 --pwr-start 0 --pwr-stop 24 --pwr-step 2 +# +# Output: $OUT/points.jsonl (one JSON per point) + per-point raw logs. +# Analysis: python3 tests/ldpc_waterfall.py $OUT/points.jsonl --step-qdb 2 +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" +TXDEMO="$ROOT/build/txdemo" +RXDEMO="$ROOT/build/rxdemo" + +EMIT_VID=0x0bda EMIT_PID="" GROUND_VID=0x0bda GROUND_PID="" +CHANNEL=6 RATE=MCS7 EXTRA_ENCS="" +PWR_START=0 PWR_STOP=24 PWR_STEP=2 SECS=6 +OUT=/tmp/devourer-ldpc-waterfall + +while [ $# -gt 0 ]; do + case "$1" in + --emit-vid) EMIT_VID="$2"; shift 2 ;; + --emit-pid) EMIT_PID="$2"; shift 2 ;; + --ground-vid) GROUND_VID="$2"; shift 2 ;; + --ground-pid) GROUND_PID="$2"; shift 2 ;; + --channel) CHANNEL="$2"; shift 2 ;; + --rate) RATE="$2"; shift 2 ;; # base spec; run pairs it with /LDPC + --encs) EXTRA_ENCS="$2"; shift 2 ;; # comma list overriding the BCC/LDPC pair + --pwr-start) PWR_START="$2"; shift 2 ;; + --pwr-stop) PWR_STOP="$2"; shift 2 ;; + --pwr-step) PWR_STEP="$2"; shift 2 ;; + --secs) SECS="$2"; shift 2 ;; + --outdir) OUT="$2"; shift 2 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done +[ -n "$EMIT_PID" ] && [ -n "$GROUND_PID" ] || { + echo "need --emit-pid and --ground-pid" >&2; exit 2; } +[ -x "$TXDEMO" ] && [ -x "$RXDEMO" ] || { echo "build first" >&2; exit 2; } + +mkdir -p "$OUT" +RXLOG="$OUT/ground.rx.jsonl" +POINTS="$OUT/points.jsonl" +: > "$POINTS" + +RXPID="" +cleanup() { + [ -n "$RXPID" ] && kill "$RXPID" 2>/dev/null + pkill -x txdemo 2>/dev/null + wait 2>/dev/null +} +trap cleanup EXIT INT TERM + +# Ground RX up once for the whole sweep (bring-up is ~6 s; per-point windows +# are carved out of its log by byte offset). +DEVOURER_VID="$GROUND_VID" DEVOURER_PID="$GROUND_PID" \ +DEVOURER_CHANNEL="$CHANNEL" \ +DEVOURER_RX_AGG_SA=canon DEVOURER_RX_ENERGY_MS=500 \ +DEVOURER_LOG_LEVEL=warn \ +"$RXDEMO" > "$RXLOG" 2>"$OUT/ground.rx.stderr" & +RXPID=$! +sleep 8 +kill -0 "$RXPID" 2>/dev/null || { echo "ground RX died — see $OUT/ground.rx.stderr" >&2; exit 1; } + +ENCS="$RATE,$RATE/LDPC" +[ -n "$EXTRA_ENCS" ] && ENCS="$EXTRA_ENCS" + +IFS=',' read -ra ENC_ARR <<<"$ENCS" +for ENC in "${ENC_ARR[@]}"; do + IDX="$PWR_START" + while [ "$IDX" -le "$PWR_STOP" ]; do + TAG="$(echo "$ENC" | tr '/' '-')_idx$IDX" + TXLOG="$OUT/tx_$TAG.jsonl" + OFF0=$(stat -c%s "$RXLOG") + echo "[$(date +%H:%M:%S)] point enc=$ENC idx=$IDX ..." + DEVOURER_VID="$EMIT_VID" DEVOURER_PID="$EMIT_PID" \ + DEVOURER_CHANNEL="$CHANNEL" \ + DEVOURER_TX_RATE="$ENC" DEVOURER_TX_PWR="$IDX" \ + DEVOURER_TX_GAP_US=2000 DEVOURER_LOG_LEVEL=warn \ + timeout --signal=TERM $((SECS + 12)) "$TXDEMO" \ + > "$TXLOG" 2>"$OUT/tx_$TAG.stderr" & + TXPID=$! + # Let bring-up finish, then measure for SECS, then graceful stop (the + # final tx.stats needs a clean SIGTERM exit, not SIGKILL). + sleep $((SECS + 6)) + kill -TERM "$TXPID" 2>/dev/null + wait "$TXPID" 2>/dev/null + sleep 1 + OFF1=$(stat -c%s "$RXLOG") + python3 - "$TXLOG" "$RXLOG" "$OFF0" "$OFF1" "$ENC" "$IDX" >> "$POINTS" <<'EOF' +import json, sys +txlog, rxlog, off0, off1, enc, idx = sys.argv[1:7] +sent = failed = -1 +for line in open(txlog, errors="replace"): + if not line.startswith('{"ev":"tx.stats"'): + continue + try: + ev = json.loads(line) + except ValueError: + continue + sent, failed = int(ev.get("submitted", -1)), int(ev.get("failed", 0)) +delivered = 0 +with open(rxlog, "rb") as f: + f.seek(int(off0)) + blob = f.read(int(off1) - int(off0)).decode(errors="replace") +for line in blob.splitlines(): + if not line.startswith('{"ev":"rx.energy"'): + continue + try: + ev = json.loads(line) + except ValueError: + continue + delivered += int(ev.get("frames") or 0) +print(json.dumps({"enc": enc, "idx": int(idx), "sent": sent, + "failed": failed, "delivered": delivered})) +EOF + tail -1 "$POINTS" + IDX=$((IDX + PWR_STEP)) + done +done + +echo "done — analyze: python3 tests/ldpc_waterfall.py $POINTS" diff --git a/tests/regress.py b/tests/regress.py index 75d6f49..84c2aa2 100755 --- a/tests/regress.py +++ b/tests/regress.py @@ -1327,13 +1327,21 @@ def run_encoding_matrix( tmpdir: Path, kh: KernelHost, sniffer_iface: Optional[str] = None, + modes: Optional[list[tuple[str, str]]] = None, ) -> dict[tuple[str, str, str], CellResult]: """For one ordered (TX, RX) pair, iterate every driver-mode × encoding - combination. Returns dict keyed by (tx_side, rx_side, encoding_label).""" + combination. Returns dict keyed by (tx_side, rx_side, encoding_label). + + `modes` (from --modes) restricts the driver-mode rows — e.g. + [("devourer", "devourer")] for LDPC/STBC truth-table runs, where the + kernel rows are non-authoritative anyway (the kernel TX path strips + those radiotap bits) and just double the runtime.""" results: dict[tuple[str, str, str], CellResult] = {} - total = len(FULL_MATRIX_MODES) * len(ENCODING_COMBOS) + run_modes = [m for m in FULL_MATRIX_MODES + if modes is None or (m[0], m[1]) in modes] + total = len(run_modes) * len(ENCODING_COMBOS) idx = 0 - for tx_side, rx_side, _label in FULL_MATRIX_MODES: + for tx_side, rx_side, _label in run_modes: for enc_label, enc in ENCODING_COMBOS: idx += 1 cell_hdr = ( @@ -1385,6 +1393,9 @@ def emit_encoding_markdown( out.append(header) out.append(sep) for tx_side, rx_side, _ in FULL_MATRIX_MODES: + # Skip driver-mode rows the run didn't cover (--modes filter). + if not any(k[0] == tx_side and k[1] == rx_side for k in results): + continue row = f"| {tx_side[0]}/{rx_side[0]} |" for enc_label, _ in ENCODING_COMBOS: r = results.get((tx_side, rx_side, enc_label)) @@ -1481,6 +1492,16 @@ def main(): "(driver-mode × radiotap encoding combo). Surfaces chip-specific " "asymmetries like the RTL8821AU LDPC-RX-no limitation.", ) + ap.add_argument( + "--modes", + default="", + help="comma list of driver-mode rows to run in --encoding-matrix, " + "each as txside:rxside (e.g. 'devourer:devourer' or " + "'devourer:devourer,devourer:kernel'). Default: all 4. The " + "kernel-TX rows are never authoritative for LDPC/STBC (that " + "driver strips those radiotap bits), so truth-table runs want " + "--modes devourer:devourer.", + ) ap.add_argument( "--vm-name", default=os.environ.get("DEVOURER_VM_NAME", ""), @@ -1563,11 +1584,24 @@ def pick(pid_arg, default_idx): print(f" RX adapter: {rx_dut.vidpid} ({rx_dut.chipset}) at {rx_dut.sysfs_id}") print(f"Kernel host: " f"{'VM ' + kh.vm_name + ' (' + kh.ssh_target + ')' if kh.is_remote else 'local'}") - n_cells = len(FULL_MATRIX_MODES) * len(ENCODING_COMBOS) + modes: Optional[list[tuple[str, str]]] = None + if args.modes: + valid = {(m[0], m[1]) for m in FULL_MATRIX_MODES} + modes = [] + for tok in args.modes.split(","): + parts = tok.strip().split(":") + if len(parts) != 2 or (parts[0], parts[1]) not in valid: + sys.stderr.write( + f"--modes: bad entry {tok!r} (want txside:rxside, " + f"sides devourer|kernel)\n") + sys.exit(2) + modes.append((parts[0], parts[1])) + n_modes = len(modes) if modes is not None else len(FULL_MATRIX_MODES) + n_cells = n_modes * len(ENCODING_COMBOS) print(f"Channel: {args.channel} Duration/cell: {args.duration}s " f"Pass threshold: ≥{args.pass_threshold} hits") print(f"Total cells: {n_cells} " - f"({len(FULL_MATRIX_MODES)} driver modes × " + f"({n_modes} driver modes × " f"{len(ENCODING_COMBOS)} encoding combos)\n") kh.release_all_known_duts([tx_dut, rx_dut]) @@ -1580,6 +1614,7 @@ def pick(pid_arg, default_idx): threshold=args.pass_threshold, tmpdir=tmpdir, kh=kh, sniffer_iface=args.sniffer_iface or None, + modes=modes, ) print() md = emit_encoding_markdown(