From 7d1f3b4c49df6d7d940e31645fcf8a8827858fdf Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:31:01 +0300 Subject: [PATCH] Surface phy-level soft metrics on stream lines + BER-vs-SNR analyser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up A from #83. Adds per-path RSSI / EVM / SNR to every line so corruption_analysis.py can correlate BER with link quality on a per-frame basis instead of relying on aggregated statistics. * demo/main.cpp: rate=R len=L crc_err=X icv_err=Y rssi=A,B evm=A,B snr=A,B body=HEX. Same source as the Tier-2 diagnostics in ; no new RX-status fields, just surfacing what FrameParser already populates. * tools/precoder/corruption_analysis.py: parses the new fields, reports - SNR distribution (min/p25/med/p75/max) for chip-clean vs chip-corrupt populations - BER per 5-dB SNR bucket Uses max(snr_A, snr_B) as the "effective" SNR — on single-antenna 1T1R sticks path B reads 0 (no signal, not "0 dB"), so a naive min would always report 0 and the bucket view collapses; max picks the active path on 1T1R and the stronger path on 2T2R single-stream operation. * stream_rx.py / tun_p2p.py / precoder_stream_roundtrip.py: regex updated to tolerate the new optional rssi/evm/snr fields (none read them yet — pass-through compatibility). Verification Hardware (500 frames at default TX power, RTL8812AU → T2U Plus RTL8821AU, ch 6): phy SNR (stronger path, dB): chip-clean : n=467 min=0 p25=30 med=33 p75=38 max=51 chip-corrupt : n=0 BER by SNR bucket (stronger path, 5-dB buckets): bucket frames bits-cmp bit-err BER 0-5 dB 1 192 0 0.000e+00 20-25 dB 11 2112 0 0.000e+00 25-30 dB 76 14592 0 0.000e+00 30-35 dB 178 34176 0 0.000e+00 35-40 dB 122 23424 0 0.000e+00 40-45 dB 55 10560 0 0.000e+00 45-50 dB 19 3648 0 0.000e+00 50-55 dB 5 960 0 0.000e+00 Bench link is too clean for chip-corrupt events even at the SNR tails, which matches the post-PR-investigation finding for #83: at bench distance the loss is at PHY sync, not FCS. The analyser is ready for noisier deployments / range-extended captures (follow-up B). Offline smoke (synthetic 5-clean@28dB + 5-corrupt@5dB injection) correctly buckets BER=0 in the 25-30 dB bucket and BER=1.04e-2 in the 5-10 dB bucket — the per-bucket correlation works as designed. Co-Authored-By: Claude Opus 4.7 --- demo/main.cpp | 15 +++++- tests/precoder_stream_roundtrip.py | 3 ++ tools/precoder/corruption_analysis.py | 75 +++++++++++++++++++++++++++ tools/precoder/stream_rx.py | 3 ++ tools/precoder/tun_p2p.py | 3 ++ 5 files changed, 97 insertions(+), 2 deletions(-) diff --git a/demo/main.cpp b/demo/main.cpp index 8e5aaa0..be9a6e8 100644 --- a/demo/main.cpp +++ b/demo/main.cpp @@ -80,10 +80,21 @@ static void packetProcessor(const Packet &packet) { std::getenv("DEVOURER_RX_KEEP_CORRUPTED") != nullptr; const bool corrupted = packet.RxAtrib.crc_err || packet.RxAtrib.icv_err; if (stream_out && (!corrupted || keep_corrupted)) { - printf("rate=%u len=%zu crc_err=%u icv_err=%u body=", + /* Per-stream phy soft metrics (RSSI / EVM / SNR for paths A,B; on + * 8814AU paths C,D would also be non-zero but we surface only A,B + * here to stay aligned with 's format). These are + * link-quality measurements at the PHY before decoding — same + * source as the Tier-2 diagnostics — so a consumer like + * corruption_analysis.py can correlate BER with link quality on a + * per-frame basis instead of relying on aggregated statistics. */ + printf("rate=%u len=%zu crc_err=%u icv_err=%u " + "rssi=%d,%d evm=%d,%d snr=%d,%d body=", packet.RxAtrib.data_rate, packet.Data.size(), packet.RxAtrib.crc_err ? 1u : 0u, - packet.RxAtrib.icv_err ? 1u : 0u); + packet.RxAtrib.icv_err ? 1u : 0u, + packet.RxAtrib.rssi[0], packet.RxAtrib.rssi[1], + packet.RxAtrib.evm[0], packet.RxAtrib.evm[1], + packet.RxAtrib.snr[0], packet.RxAtrib.snr[1]); for (size_t i = 24; i < packet.Data.size(); ++i) printf("%02x", packet.Data[i]); printf("\n"); diff --git a/tests/precoder_stream_roundtrip.py b/tests/precoder_stream_roundtrip.py index 278bc4f..4f45281 100644 --- a/tests/precoder_stream_roundtrip.py +++ b/tests/precoder_stream_roundtrip.py @@ -54,6 +54,9 @@ r"rate=(?P\d+)\s+len=(?P\d+)" r"(?:\s+crc_err=(?P\d+))?" r"(?:\s+icv_err=(?P\d+))?" + r"(?:\s+rssi=(?P-?\d+,-?\d+))?" + r"(?:\s+evm=(?P-?\d+,-?\d+))?" + r"(?:\s+snr=(?P-?\d+,-?\d+))?" r"\s+body=(?P[0-9a-fA-F]*)" ) diff --git a/tools/precoder/corruption_analysis.py b/tools/precoder/corruption_analysis.py index 2862de2..a8acbfc 100644 --- a/tools/precoder/corruption_analysis.py +++ b/tools/precoder/corruption_analysis.py @@ -62,10 +62,45 @@ r"rate=(?P\d+)\s+len=(?P\d+)" r"(?:\s+crc_err=(?P\d+))?" r"(?:\s+icv_err=(?P\d+))?" + r"(?:\s+rssi=(?P-?\d+,-?\d+))?" + r"(?:\s+evm=(?P-?\d+,-?\d+))?" + r"(?:\s+snr=(?P-?\d+,-?\d+))?" r"\s+body=(?P[0-9a-fA-F]*)" ) +def _parse_pair(s: Optional[str]) -> Optional[tuple[int, int]]: + if not s: + return None + a, b = s.split(",") + return int(a), int(b) + + +def _effective_snr(snr: Optional[tuple[int, int]]) -> Optional[int]: + """Pick the SNR value that actually drove decode quality. + + The two-path field carries SNR for paths A and B; on single-antenna + USB sticks path B reads 0 (no signal, not "0 dB SNR"), so a naive + min(A,B) would always report 0 and the BER-vs-SNR view collapses. + `max(A,B)` works for both 1T1R (B is 0, A drives) and 2T2R single- + stream operation (the chip picks the stronger path for the only + stream). For an honest 2T2R two-stream analysis a finer model + would be needed; this is a single-stream PoC. + """ + if snr is None: + return None + return max(snr) + + +def _snr_bucket(snr: Optional[tuple[int, int]]) -> str: + """Group SNR into 5-dB buckets. Returns 'no-snr' when absent.""" + eff = _effective_snr(snr) + if eff is None: + return "no-snr" + base = (eff // 5) * 5 + return f"{base:>3d}-{base + 5} dB" + + def _expected_bodies(source: bytes, mtu: int, body_bytes: int, seq_start: int = 0) -> dict[int, bytes]: """Reproduce the TX side's encoded envelopes for `source`. Byte mode @@ -116,6 +151,13 @@ def main(argv: Optional[list[str]] = None) -> int: byte_pos_examined = collections.Counter() per_frame_byte_errs: list[int] = [] per_frame_bit_errs: list[int] = [] + # Per-frame phy metrics (parsed but only used when present). + snr_clean: list[int] = [] + snr_corrupt: list[int] = [] + # (snr_bucket, corrupted_or_not) -> count; for the BER-vs-SNR table. + bucket_frames: collections.Counter = collections.Counter() + bucket_bit_errors: collections.Counter = collections.Counter() + bucket_bits_compared: collections.Counter = collections.Counter() for line in sys.stdin: m = _STREAM_RE.search(line) @@ -124,6 +166,10 @@ def main(argv: Optional[list[str]] = None) -> int: total_captured += 1 crc_err = int(m.group("crc_err") or 0) icv_err = int(m.group("icv_err") or 0) + snr = _parse_pair(m.group("snr")) + eff = _effective_snr(snr) + if eff is not None: + (snr_corrupt if crc_err or icv_err else snr_clean).append(eff) if crc_err or icv_err: total_corrupted += 1 else: @@ -156,6 +202,10 @@ def main(argv: Optional[list[str]] = None) -> int: bit_errors += frame_bit_errs per_frame_byte_errs.append(frame_byte_errs) per_frame_bit_errs.append(frame_bit_errs) + bucket = _snr_bucket(snr) + bucket_frames[bucket] += 1 + bucket_bit_errors[bucket] += frame_bit_errs + bucket_bits_compared[bucket] += compare_len * 8 if not matched_seq: sys.stderr.write( @@ -197,6 +247,31 @@ def main(argv: Optional[list[str]] = None) -> int: pct = 100.0 * count / max(1, exam) print(f" {pos:3d} {count:5d}/{exam:5d} {pct:5.1f}%") + # Phy-metrics correlation. Two views: distribution of weakest-path SNR + # for chip-clean vs chip-corrupt frames, and per-SNR-bucket BER. + if snr_clean or snr_corrupt: + def _stat(xs: list[int]) -> str: + if not xs: + return "n=0" + xs = sorted(xs) + n = len(xs) + return (f"n={n} min={xs[0]} p25={xs[n // 4]} " + f"med={xs[n // 2]} p75={xs[(3 * n) // 4]} max={xs[-1]}") + print(f"\nphy SNR (stronger path, dB):") + print(f" chip-clean : {_stat(snr_clean)}") + print(f" chip-corrupt : {_stat(snr_corrupt)}") + + if bucket_frames and any(b != "no-snr" for b in bucket_frames): + print(f"\nBER by SNR bucket (stronger path, 5-dB buckets):") + print(f" bucket frames bits-cmp bit-err BER") + for bucket in sorted(bucket_frames): + n = bucket_frames[bucket] + bits = bucket_bits_compared[bucket] + errs = bucket_bit_errors[bucket] + ber = errs / max(1, bits) + print(f" {bucket:>11s} {n:6d} {bits:8d} {errs:6d} " + f"{ber:.3e}") + return 0 diff --git a/tools/precoder/stream_rx.py b/tools/precoder/stream_rx.py index f30ba79..f5352c3 100644 --- a/tools/precoder/stream_rx.py +++ b/tools/precoder/stream_rx.py @@ -46,6 +46,9 @@ r"rate=(?P\d+)\s+len=(?P\d+)" r"(?:\s+crc_err=(?P\d+))?" r"(?:\s+icv_err=(?P\d+))?" + r"(?:\s+rssi=(?P-?\d+,-?\d+))?" + r"(?:\s+evm=(?P-?\d+,-?\d+))?" + r"(?:\s+snr=(?P-?\d+,-?\d+))?" r"\s+body=(?P[0-9a-fA-F]*)" ) diff --git a/tools/precoder/tun_p2p.py b/tools/precoder/tun_p2p.py index 2d895da..ad3e76f 100644 --- a/tools/precoder/tun_p2p.py +++ b/tools/precoder/tun_p2p.py @@ -88,6 +88,9 @@ r"rate=(?P\d+)\s+len=(?P\d+)" r"(?:\s+crc_err=(?P\d+))?" r"(?:\s+icv_err=(?P\d+))?" + r"(?:\s+rssi=(?P-?\d+,-?\d+))?" + r"(?:\s+evm=(?P-?\d+,-?\d+))?" + r"(?:\s+snr=(?P-?\d+,-?\d+))?" r"\s+body=(?P[0-9a-fA-F]*)" )