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
14 changes: 13 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<stage>_us`…, total_us |
| `tx.fail` | L (send failure; regress.py keys on it) | {status, actual_len, timeout} or {rc, timeout} |
Expand All @@ -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) |
Expand Down
5 changes: 5 additions & 0 deletions examples/common/caps_event.h
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 22 additions & 3 deletions examples/rx/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<std::mutex> 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) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions src/AdapterCaps.h
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down
22 changes: 21 additions & 1 deletion src/jaguar1/RtlJaguarDevice.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
7 changes: 7 additions & 0 deletions src/jaguar2/RtlJaguar2Device.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
7 changes: 7 additions & 0 deletions src/jaguar3/RtlJaguar3Device.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
39 changes: 39 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,23 @@ sudo python3 tests/regress.py --encoding-matrix \
--vm-name devourer-testrig --vm-ssh <user>@<VM-IP>
```

`--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:

Expand Down Expand Up @@ -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
Expand Down
93 changes: 93 additions & 0 deletions tests/ldpc_waterfall.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading