diff --git a/CLAUDE.md b/CLAUDE.md index eab9906..19d2bfb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -137,6 +137,12 @@ Both `WiFiDriverDemo` and `WiFiDriverTxDemo` honour: `0x8813` for RTL8814AU); without it, the demo iterates every Realtek PID. - `DEVOURER_VID=0xNNNN` — override VID (default `0x0bda`); needed for OEM-rebadged dongles like the TP-Link Archer T2U Plus (`2357:0120`). +- `DEVOURER_USB_BUS=N` (+ optional `DEVOURER_USB_PORT=a.b.c`) — select a device + by USB topology instead of first-match VID:PID. Needed when two adapters share + one VID:PID **and serial** and can't otherwise be told apart (two RTL8814AU + dongles, e.g. CF-938AC vs CF-960AC). `DEVOURER_USB_PORT` is the dotted libusb + port path (sysfs `devpath` / `lsusb -t`). Unset = the normal VID:PID open loop. + Used by `tests/compare_8814_decorrelation.sh`. - `DEVOURER_CHANNEL=N` — override monitor channel. - `DEVOURER_SKIP_RESET=1` — skip `libusb_reset_device` before claim; useful when picking up a chip whose firmware is already running. @@ -150,6 +156,13 @@ Both `WiFiDriverDemo` and `WiFiDriverTxDemo` honour: output. This is the entry point for the fused-FEC sub-block-salvage layer (see `docs/fused-fec.md`); opt-in, since a body with a corrupt tail is the worst-case input for an IP-stack consumer that didn't ask for it. +- `DEVOURER_RX_ALLPATHS=1` — emit a `` line per canonical-SA + frame carrying all four RX chains (A,B,C,D) of per-stream `rssi`/`snr`/`evm`, + where the canonical ``/`` lines surface only + A,B. Paths C/D are non-zero only on the 8814AU (4T4R); 0 on 2T2R parts. + Opt-in and on a distinct tag so the two-path format its regex consumers key on + is untouched. Consumed by `tests/antenna_decorrelation.py` to measure + inter-chain envelope correlation and realised diversity gain. - `DEVOURER_USB_DEBUG=1` — raise libusb log level from the default WARNING to DEBUG (produces ~7 MB per 15 s — has filled `/tmp` mid-capture and adds 0.5-0.8 s to init even with stderr discarded). `DEVOURER_USB_QUIET` is diff --git a/README.md b/README.md index 8dbf4b0..38c4967 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ varies run-to-run (bracketed = best clean reading). | ----------------------------- | ----------------- | ------------- | ------------- | ---------------- | ------------------------------------------- | | **RTL8812AU** | 2T2R | 56 | 52 | 52 | `0bda:8812`; reference part | | **RTL8811AU** | 1T1R | mirrors 8812 | mirrors 8812 | mirrors 8812 | 1T1R cut of 8812 silicon; rides the 8812 code path (`RFType=RF_TYPE_1T1R` from `REG_SYS_CFG` bit 27). Not benchmarked | -| **RTL8814AU** | 4T4R, 3-SS max | 65 | †(32) | †(32) | `0bda:8813`; 2-SS effective on USB-2 | +| **RTL8814AU** | 4T4R, 3-SS max | 65 | †(32) | †(32) | `0bda:8813`; tested on COMFAST CF-938AC (2 ext antennas) and CF-960AC (4 internal) — effective RX-diversity branches differ (N_eff ≈ 3.8 vs 2.6) despite identical silicon, see [`docs/effective-branches-rotation.md`](docs/effective-branches-rotation.md) | | **RTL8821AU** | 1T1R AC + BT | 54 | 32 | 28 | TP-Link Archer T2U Plus (`2357:0120`) | | **RTL8812CU** | 2T2R | 65 | 60 | 60 | LB-LINK WDN1300H (`0bda:c812`) | | **RTL8822CU** | 2T2R + BT | — | — | — | not benchmarked (`0bda:c82c`) | diff --git a/demo/main.cpp b/demo/main.cpp index 1b4f051..c6d1dd3 100644 --- a/demo/main.cpp +++ b/demo/main.cpp @@ -262,6 +262,27 @@ static void packetProcessor(const Packet &packet) { static const bool keep_corrupted = std::getenv("DEVOURER_RX_KEEP_CORRUPTED") != nullptr; const bool corrupted = packet.RxAtrib.crc_err || packet.RxAtrib.icv_err; + /* DEVOURER_RX_ALLPATHS=1: emit all four RX chains (A,B,C,D) of per-stream + * RSSI / SNR / EVM on a distinct line. Opt-in and + * separate so the canonical two-path / + * format its regex consumers key on stays untouched. Paths C/D are + * non-zero only on the 8814AU (4T4R); on 8812/8821 they read 0. Consumed + * by tests/antenna_decorrelation.py to measure inter-chain envelope + * correlation and realised diversity gain (spatial-diversity axis). */ + static const bool rxpath_out = + std::getenv("DEVOURER_RX_ALLPATHS") != nullptr; + if (rxpath_out && (!corrupted || keep_corrupted)) { + printf("seq=%u rssi=%d,%d,%d,%d snr=%d,%d,%d,%d " + "evm=%d,%d,%d,%d\n", + packet.RxAtrib.seq_num, packet.RxAtrib.rssi[0], + packet.RxAtrib.rssi[1], packet.RxAtrib.rssi[2], + packet.RxAtrib.rssi[3], packet.RxAtrib.snr[0], + packet.RxAtrib.snr[1], packet.RxAtrib.snr[2], + packet.RxAtrib.snr[3], packet.RxAtrib.evm[0], + packet.RxAtrib.evm[1], packet.RxAtrib.evm[2], + packet.RxAtrib.evm[3]); + fflush(stdout); + } if (stream_out && (!corrupted || keep_corrupted)) { /* 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 @@ -363,7 +384,44 @@ int main() { logger->info("DEVOURER_VID={:04x} (overriding default VID)", target_vid); } libusb_device_handle *dev_handle = nullptr; + + /* DEVOURER_USB_BUS (+ optional DEVOURER_USB_PORT) select a specific device by + * USB topology when several share one VID:PID and even the serial — e.g. two + * RTL8814AU dongles (CF-938AC vs CF-960AC) that enumerate identically, so only + * the bus/port tells them apart. DEVOURER_USB_PORT is the dotted libusb port + * path (as in sysfs `devpath` / `lsusb -t`, e.g. "2.3.2"). When bus is unset, + * the VID:PID open loop below runs as before. */ + if (const char *bus_env = std::getenv("DEVOURER_USB_BUS")) { + const auto want_bus = static_cast(std::strtoul(bus_env, nullptr, 0)); + const char *port_env = std::getenv("DEVOURER_USB_PORT"); + libusb_device **list = nullptr; + ssize_t n = libusb_get_device_list(ctx, &list); + for (ssize_t i = 0; i < n && dev_handle == NULL; ++i) { + libusb_device_descriptor dd{}; + if (libusb_get_device_descriptor(list[i], &dd) != 0) continue; + if (dd.idVendor != target_vid) continue; + if (target_pid != 0 && dd.idProduct != target_pid) continue; + if (libusb_get_bus_number(list[i]) != want_bus) continue; + if (port_env != nullptr) { + uint8_t ports[8]; + int pc = libusb_get_port_numbers(list[i], ports, sizeof(ports)); + std::string path; + for (int p = 0; p < pc; ++p) + path += (path.empty() ? "" : ".") + std::to_string(ports[p]); + if (path != port_env) continue; + } + if (libusb_open(list[i], &dev_handle) == 0) + logger->info("Opened device {:04x}:{:04x} on bus {} port {}", dd.idVendor, + dd.idProduct, want_bus, port_env ? port_env : "(any)"); + } + if (list != nullptr) libusb_free_device_list(list, 1); + if (dev_handle == NULL) + logger->error("DEVOURER_USB_BUS={} PORT={} matched no device", want_bus, + port_env ? port_env : "(any)"); + } + for (uint16_t pid : kRealtekProductIds) { + if (dev_handle != NULL) break; if (target_pid != 0 && pid != target_pid) continue; dev_handle = libusb_open_device_with_vid_pid(ctx, target_vid, pid); if (dev_handle != NULL) { diff --git a/docs/effective-branches-rotation.md b/docs/effective-branches-rotation.md new file mode 100644 index 0000000..14ec48a --- /dev/null +++ b/docs/effective-branches-rotation.md @@ -0,0 +1,188 @@ +# Measuring effective diversity branches by rotation + +A multi-chain adapter's spec sheet counts **RF chains** (2T2R, 4T4R). What a +diversity receiver actually gets is **effective branches** — the number of chains +whose fading is independent enough to add diversity. Those are not the same +number. Two adapters built on identical 4T4R silicon can deliver very different +effective diversity if one wires four decorrelated antennas and the other feeds +four chains from two antennas. This document is a lab procedure for finding the +truth experimentally, using nothing but a second adapter and your hands to rotate +the device under test. + +It is written as an instruction: the concept first, then the method, then how to +run it, read it, and not fool yourself. + +## Why chain count is not branch count + +Diversity works because independent antennas see independent fades — when one is +in a null, another usually is not, so combining them (selection or +maximal-ratio) fills the nulls. The benefit collapses as the antennas' fading +becomes **correlated**: two co-located, same-polarisation antennas ride the same +fades and combining them buys almost nothing. So the quantity that matters is the +**envelope correlation** between chains, and from it the **effective branch +count**: + +- **Envelope correlation** ρ — the correlation of the chains' signal *amplitude* + (not dB) over a fading channel. ρ near 0 = independent; ρ near 1 = redundant. + The classic rule of thumb is that diversity is still useful up to ρ ≈ 0.7. +- **Effective branches** N_eff — a single number summarising the whole + correlation matrix as "how many independent branches is this really." It is the + participation ratio of the correlation matrix: N chains that are mutually + uncorrelated give N_eff = N; N chains that all ride the same fade give + N_eff = 1. A 4-chain adapter whose chains pair up gives something in between. + +The array/combining gain a real receiver extracts tracks N_eff, not the chain +count on the box. + +## Why a static bench cannot measure it + +Envelope correlation is only defined over a channel that **fades** — that varies +in time. A transmitter and receiver sitting still on a bench a few inches apart +present a *static* channel: the per-chain levels barely move (fractions of a dB), +so any correlation you compute is correlation of measurement noise, and it means +nothing. This is the first trap, and the measurement tool refuses to report a +verdict when it detects it (it calls the run inconclusive). + +To measure decorrelation you must make the channel move. Three ways, cheapest +first: + +1. **Rotation** — physically rotate and tilt the receiving adapter through many + orientations during the capture. As it turns, each antenna's radiation pattern + and polarisation sweep against the fixed transmitter, so every chain traces its + own level curve. Antennas that are genuinely different trace *different* curves + (low ρ); antennas that share a feed or point the same way trace the *same* + curve (high ρ). Rotation is doable from one desk in a minute. +2. **Relocation into multipath** — put the transmitter in another room or on a + balcony so the link becomes non-line-of-sight through walls and reflections. + Ambient movement then fades it. This is the more deployment-representative + channel. +3. **A mobile link** — the real thing: one end actually moving, as in flight. + +Rotation measures **pattern/orientation decorrelation** under strong line of +sight. That is a *proxy* for the multipath envelope correlation a deployed link +sees — not identical to it, but it captures the property that dominates whether an +adapter's chains are redundant: do the antennas respond differently to the same +incoming wave. The *relative* comparison between two adapters on the same bench is +robust; the absolute ρ shifts in a real multipath field. Treat rotation as the +quick screen and relocation/mobility as the confirmation. + +## The method + +The receiver only needs a controlled, steady signal to measure against, and a way +to report each chain's level per frame: + +1. **A controlled beacon transmitter.** A second adapter continuously injects a + known frame (a fixed source address) on the test channel. Using our own beacon + — rather than ambient traffic — means every measured frame comes from one + steady source, so the only thing varying is the receiver's channel, which is + exactly what you want to isolate. +2. **Per-chain metrics from the receiver.** The receiver reports each RF chain's + received level (and SNR/EVM) for every beacon frame. Over the capture this + builds a per-chain time series. +3. **Rotate the receiver** continuously through the whole capture window — full + turns plus tilts and flips, not a gentle quarter-turn. Vigour matters (see + pitfalls). +4. **Compute** the pairwise envelope correlation matrix and the effective branch + count, plus the combining gain the chains would have delivered. + +### Reading the numbers + +- **Per-chain level spread.** First sanity check: did the channel actually move? + If the strongest chain's spread is tiny, the rotation was insufficient or the + link too static — the result is inconclusive, full stop. Rotate harder or add + multipath. +- **Correlation matrix.** Look for structure. Chains that pair up at high |ρ| are + sharing an antenna or pointing together. Both positive and negative correlation + count as dependence (a chain that always goes *down* when another goes *up* is + not independent). +- **Effective branches N_eff.** The headline number. Close to the chain count = + the adapter delivers its full diversity order; well below it = some chains are + redundant. +- **Combining gain.** Selection- and maximal-ratio-combining gain over the best + single chain, reported at the median and at the low-outage tail (the 10 % and + 1 % worst moments). Diversity shows up most at the tail — a well-decorrelated + multi-chain adapter buys many dB exactly when a single chain is deepest in a + fade, which is the moment a video link drops. + +## Running it + +The receiver's per-chain emission is opt-in (it does not disturb the normal +output): the RX demo publishes an all-chains line per beacon frame when asked, +and the analyser turns a capture into the report above. A capture is only valid +if the operator rotates the adapter for its full duration. + +- **One adapter, one shot.** `tests/run_antenna_decorrelation.sh` builds + everything, brings up the beacon transmitter (and waits for it to actually + start injecting before capturing — a dead beacon window is a common way to get + an empty capture), then records a rotation window and prints the report. Its + rotation mode announces a short countdown so you can start turning the adapter. +- **Comparing several identical adapters.** `tests/compare_8814_decorrelation.sh` + finds every matching adapter and measures each in turn against one beacon, so + the only variable between runs is the antenna front-end. When two adapters share + the same USB identity (same vendor/product/**serial**), it selects between them + by USB topology (bus/port) rather than identity. +- **The analysis alone.** `tests/antenna_decorrelation.py` runs on a saved + capture, and has a hardware-independent self-test that validates the correlation + estimator, the effective-branch metric, and the combining math against + synthesised correlated fading — run that first if you change the tool. + +The metric to capture is the received level; SNR and EVM are also emitted if you +prefer to correlate on those. + +## Pitfalls (each one has bitten this measurement) + +- **Gentle rotation lies.** A slow quarter-turn produces smooth, *correlated* + level sweeps on all chains and reports high correlation — the opposite of the + truth. Vigorous full-turn-plus-flip rotation explores enough orientations to + reveal the real (low) correlation of good antennas. Only trust a capture whose + per-chain spread is well above the tool's fading floor; if it is marginal, + rotate harder and re-run. +- **A static capture is not a measurement.** If you forget to rotate, the tool + will say inconclusive. Believe it. +- **A pinned/railed chain carries no information.** Under a strong static + line-of-sight signal, a chain's reported level can sit on an automatic-gain rail + (a constant value) and look dead — then vary normally once the adapter moves. + This is exactly why rotation is required, not optional. The tool flags a + near-constant chain so you don't mistake an AGC rail for a real reading. +- **The beacon must actually be on air.** Both ends take several seconds to + initialise, and a flaky transmitter can fail to claim on the first try. Confirm + the beacon is injecting before trusting a zero-frame capture as "no signal." +- **Identical adapters need topology selection.** Two dongles with the same + USB identity down to the serial can only be told apart by which bus/port they + are on — select the intended one explicitly, or you will measure the same device + twice. +- **Rotation ≠ multipath.** The rotation result is orientation/pattern + decorrelation under line of sight. It is the right quick screen and the right + *relative* comparator, but confirm an important conclusion with a + non-line-of-sight or mobile capture before treating an absolute correlation + figure as the deployed one. + +## Worked example: two 4T4R dongles, identical silicon + +Two USB adapters built on the same 4T4R chip, differing only in antenna +front-end, measured on one bench against one beacon on the 2.4 GHz test channel, +each rotated vigorously through a capture: + +| Adapter | Antennas | Worst \|ρ\| | Effective branches N_eff | MRC gain at 1 % outage | Verdict | +|---|---|---|---|---|---| +| 4-internal-antenna puck | 4 on-PCB internal | 0.19 | **3.8** of 4 | ~14 dB | well decorrelated | +| 2-external-dipole stick | 2 external dipoles | 0.64 | **2.6** of 4 | ~11 dB | partially correlated | + +The stick drives four RF chains from only two physical antennas, so two of its +chains are strongly coupled and its effective branch count sits near its antenna +count, not its chain count. The puck's four genuine antennas decorrelate on every +pair and it delivers close to its full four-branch diversity — and materially more +combining gain at the low-outage tail, which is where a long-range link lives. +Same silicon, different truth. That difference is invisible on the spec sheet and +obvious in five minutes of rotation. + +## Toward the gold standard + +Rotation answers "are these chains independent enough to matter" cheaply and +comparatively. To turn a relative screen into a deployment number, repeat the +capture with the transmitter relocated into genuine non-line-of-sight multipath +(another room, a balcony, across a floor) and, ultimately, with one end in +motion. The tooling is identical — only the stimulus changes; the beacon can run +on a second host anywhere the receiver can still hear it. Expect the *ordering* +between adapters to hold and the absolute correlation to rise toward the values a +flying link actually experiences. diff --git a/tests/antenna_decorrelation.py b/tests/antenna_decorrelation.py new file mode 100644 index 0000000..7fbb4d1 --- /dev/null +++ b/tests/antenna_decorrelation.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +"""antenna_decorrelation.py — measure inter-chain envelope correlation and the +realised diversity gain of a multi-chain Realtek adapter (spatial-diversity axis). + +Every spatial-diversity claim in the roadmap rests on the RF chains' antennas +being *decorrelated*. On a small airframe they may not be — closely spaced, +same-polarisation antennas see almost the same fading, and the theoretical +~10*log10(N) dB array gain / N-branch diversity order collapses. This tool +measures the decorrelation that a given antenna layout actually delivers, +instead of assuming it. + +Input is the per-chain PHY link metric emitted by ``WiFiDriverDemo`` under +``DEVOURER_RX_ALLPATHS=1`` — one ```` line per received frame: + + seq=N rssi=a,b,c,d snr=a,b,c,d evm=a,b,c,d + +Paths C/D are meaningful only on the 8814AU (4T4R); on 2T2R parts they read 0 +and are dropped automatically. The two-path ```` line is also +parsed as a fallback (rssi=a,b / snr=a,b) so a 2T2R capture works with no demo +flag. + +What it computes, per capture: + * per-chain sample count, mean level, spread; + * the pairwise *envelope* correlation matrix (Pearson on linear amplitude — + the standard diversity-relevant correlation, not on dB); + * combining analysis — empirical fade CDF of selection-combining (SC) and + maximal-ratio-combining (MRC) vs the best single chain, and the dB gain at + the 10 % and 1 % outage points; + * an effective diversity order estimated from the low-outage CDF slope; + * a verdict keyed off the worst pairwise correlation (rho < 0.7 is the classic + "diversity still useful" threshold). + +Usage: + # analyse a capture file (or stdin) + uv run python antenna_decorrelation.py capture.log + cat capture.log | uv run python antenna_decorrelation.py - + + # hardware-independent self-test: synthesise chains with a known correlation + # and confirm the estimator + combining math recover it + uv run python antenna_decorrelation.py --self-test + +The live end-to-end capture (build + run the demo + pipe here) is driven by +run_antenna_decorrelation.sh. +""" + +from __future__ import annotations + +import argparse +import re +import sys + +import numpy as np + +# seq=12 rssi=40,38,0,0 snr=25,24,0,0 evm=10,11,0,0 +_RXPATH = re.compile( + r"seq=(\d+)\s+rssi=([\-\d,]+)\s+snr=([\-\d,]+)\s+evm=([\-\d,]+)" +) +# fallback: canonical two-path stream line +_STREAM = re.compile( + r".*?rssi=(-?\d+),(-?\d+)\s+evm=(-?\d+),(-?\d+)\s+snr=(-?\d+),(-?\d+)" +) + +# Correlation bands for the verdict (worst pairwise |rho|). +GOOD, MODERATE = 0.3, 0.7 +# A valid envelope-correlation estimate needs a *fading* channel. A static +# near-field bench beacon barely varies (std ~0.5 unit), so the correlation just +# tracks quantisation noise and means nothing. Require at least this much +# per-chain spread (metric units) on the strongest-varying chain before trusting +# the estimate; below it the run is inconclusive and needs motion/multipath. +MIN_FADING_STD = 2.0 +# A chain pinned to a near-constant value (often a railed/saturated per-path +# reading, e.g. path C clamping high) carries no information. +RAIL_STD = 0.15 + + +def parse(lines, metric: str = "rssi"): + """Return an (n_frames, n_paths) float array of the chosen metric. + + Trailing all-zero columns (idle paths C/D on a 2T2R part) are trimmed. + """ + mi = {"rssi": 0, "snr": 1, "evm": 2}[metric] + rows: list[list[float]] = [] + for ln in lines: + m = _RXPATH.search(ln) + if m: + triplet = m.groups()[1:] # rssi, snr, evm CSV strings + vals = [int(x) for x in triplet[mi].split(",")] + rows.append([float(v) for v in vals]) + continue + m = _STREAM.search(ln) + if m: + g = [int(x) for x in m.groups()] + pick = {"rssi": g[0:2], "snr": g[4:6], "evm": g[2:4]}[metric] + rows.append([float(v) for v in pick]) + if not rows: + return np.empty((0, 0)) + width = max(len(r) for r in rows) + arr = np.array([r + [0.0] * (width - len(r)) for r in rows], dtype=float) + # drop trailing columns that are identically zero (unused chains) + active = arr.shape[1] + while active > 1 and np.all(arr[:, active - 1] == 0.0): + active -= 1 + return arr[:, :active] + + +def envelope_correlation(rssi_dbm: np.ndarray) -> np.ndarray: + """Pairwise Pearson correlation of the linear voltage envelope. + + RSSI in the descriptor is a dB-scaled level; envelope correlation is defined + on the linear amplitude, so convert dB -> linear amplitude first + (amp = 10**(dBm/20)). Returns an (N, N) matrix; diagonal is 1. + """ + amp = np.power(10.0, rssi_dbm / 20.0) + # guard against a dead/constant chain (zero variance -> nan correlation) + if amp.shape[0] < 2: + return np.full((amp.shape[1], amp.shape[1]), np.nan) + with np.errstate(invalid="ignore"): + c = np.corrcoef(amp, rowvar=False) + return np.atleast_2d(c) + + +def _outage_gain_db(single_snr_db, combined_snr_db, pct): + """dB gain of `combined` over `single` at the `pct` outage point. + + The outage level is the SNR that is exceeded (100-pct)% of the time — i.e. + the pct-th percentile of each series. The gain is how much more link margin + the combiner buys at that (low) percentile, which is where diversity pays. + """ + s = np.percentile(single_snr_db, pct) + c = np.percentile(combined_snr_db, pct) + return c - s + + +def combining_analysis(level_db: np.ndarray) -> dict: + """Selection- and maximal-ratio-combining gain over the best single chain. + + `level_db` is per-chain per-frame level in dB (RSSI or SNR). Treated as a + relative power proxy: linear power = 10**(dB/10). + SC = max over chains; MRC = sum over chains (coherent power sum). + """ + n_frames, n = level_db.shape + pwr = np.power(10.0, level_db / 10.0) + best_chain = int(np.argmax(level_db.mean(axis=0))) + single_db = level_db[:, best_chain] + sc_db = 10.0 * np.log10(pwr.max(axis=1)) + mrc_db = 10.0 * np.log10(pwr.sum(axis=1)) + out = {"n_paths": n, "best_chain": best_chain} + for pct in (10, 1): + out[f"sc_gain_p{pct}"] = _outage_gain_db(single_db, sc_db, pct) + out[f"mrc_gain_p{pct}"] = _outage_gain_db(single_db, mrc_db, pct) + out["mrc_mean_gain"] = float(mrc_db.mean() - single_db.mean()) + out["theoretical_array_gain"] = 10.0 * np.log10(n) + return out + + +def summarise(arr: np.ndarray, metric: str) -> dict: + n_frames, n = arr.shape + corr = envelope_correlation(arr) + # worst off-diagonal |rho| + off = corr.copy() + np.fill_diagonal(off, np.nan) + worst = float(np.nanmax(np.abs(off))) if n > 1 else float("nan") + combo = combining_analysis(arr) + std = arr.std(axis=0) + # Effective number of independent branches = participation ratio of the + # correlation matrix, N_eff = (Sum lambda_i)^2 / Sum lambda_i^2 = + # n^2 / Sum_ij rho_ij^2 (since trace(R)=n and trace(R^2)=Sum rho_ij^2). + # n uncorrelated chains -> N_eff = n; fully correlated -> N_eff = 1. This is + # the "how many chains actually buy me diversity" number. Only meaningful + # when the channel actually fades (see fading_ok). + neff = float(n * n / np.nansum(corr**2)) if n > 1 else 1.0 + return { + "n_frames": n_frames, + "n_paths": n, + "metric": metric, + "mean": arr.mean(axis=0), + "std": std, + "corr": corr, + "worst_rho": worst, + "combo": combo, + "max_std": float(std.max()), + "railed": [i for i in range(n) if std[i] < RAIL_STD], + "fading_ok": float(std.max()) >= MIN_FADING_STD, + "eff_branches": neff, + } + + +def verdict(s: dict) -> str: + if not s["fading_ok"]: + return (f"INCONCLUSIVE — channel too static (max per-chain std " + f"{s['max_std']:.2f} < {MIN_FADING_STD:.1f} units). Envelope " + f"correlation needs a fading channel: move an antenna / add " + f"multipath / vary the path, or measure a mobile link.") + worst = s["worst_rho"] + if np.isnan(worst): + return "N/A (single chain)" + if worst < GOOD: + return "GOOD — chains well decorrelated, near-full diversity available" + if worst < MODERATE: + return "MODERATE — partial correlation, diversity gain reduced" + return "POOR — chains highly correlated, diversity gain largely lost" + + +def report(s: dict) -> str: + L = [] + L.append(f"frames={s['n_frames']} active_chains={s['n_paths']} metric={s['metric']}") + if s["n_frames"] < 30: + L.append(" WARNING: <30 frames — correlation estimate is unreliable") + labels = ["A", "B", "C", "D"][: s["n_paths"]] + L.append("per-chain: " + " ".join( + f"{lb}: mean={m:6.2f} std={sd:5.2f}" + for lb, m, sd in zip(labels, s["mean"], s["std"]) + )) + if s["railed"]: + rl = ",".join(labels[i] for i in s["railed"]) + L.append(f" NOTE: chain(s) {rl} near-constant (std < {RAIL_STD}) — " + f"likely railed/saturated per-path reading, treat as no-signal") + if not s["fading_ok"]: + L.append(f" WARNING: max per-chain std {s['max_std']:.2f} < " + f"{MIN_FADING_STD:.1f} — channel too static for a valid " + f"correlation estimate (see VERDICT)") + if s["n_paths"] > 1: + L.append("envelope correlation matrix (linear amplitude):") + header = " " + "".join(f"{lb:>7}" for lb in labels) + L.append(header) + for lb, row in zip(labels, s["corr"]): + L.append(f" {lb:>3}" + "".join(f"{v:7.3f}" for v in row)) + L.append(f"worst pairwise |rho| = {s['worst_rho']:.3f}") + if s["fading_ok"]: + L.append(f"effective branches (N_eff) = {s['eff_branches']:.2f} " + f"of {s['n_paths']} chains") + c = s["combo"] + L.append( + f"combining (best chain = {labels[c['best_chain']]}, " + f"theoretical array gain = {c['theoretical_array_gain']:.1f} dB):" + ) + L.append( + f" MRC gain over best single: mean={c['mrc_mean_gain']:.2f} dB " + f"@10%out={c['mrc_gain_p10']:.2f} dB @1%out={c['mrc_gain_p1']:.2f} dB" + ) + L.append( + f" SC gain over best single: @10%out={c['sc_gain_p10']:.2f} dB " + f"@1%out={c['sc_gain_p1']:.2f} dB" + ) + L.append(f"VERDICT: {verdict(s)}") + return "\n".join(L) + + +# --------------------------------------------------------------------------- # +# Self-test: synthesise correlated Rayleigh-fading chains with a known rho and +# confirm the estimator (and the combining math) recover the expected values. +# --------------------------------------------------------------------------- # +def _synth_chains(n_frames, n_paths, rho_env, seed=0): + """Correlated complex-Gaussian fading -> Rayleigh envelope -> RSSI dBm. + + `rho_env` is the desired *envelope* correlation. For complex-Gaussian + (Rayleigh) fading the envelope/power correlation equals |field correlation|^2, + so the underlying complex field is coloured with rho_field = sqrt(rho_env): + an equicorrelation matrix (off-diagonals = rho_field) colours i.i.d. complex + Gaussians through its Cholesky factor. + """ + rng = np.random.default_rng(seed) + rho = float(np.sqrt(rho_env)) + R = np.full((n_paths, n_paths), rho) + (1.0 - rho) * np.eye(n_paths) + Lc = np.linalg.cholesky(R) + re = (rng.standard_normal((n_frames, n_paths)) @ Lc.T) + im = (rng.standard_normal((n_frames, n_paths)) @ Lc.T) + env = np.sqrt(re**2 + im**2) # Rayleigh envelope + # map to a plausible RSSI window (~ -70 dBm nominal + fading, as positive + # "gain_trsw"-style magnitude the demo reports) + rssi = 40.0 + 20.0 * np.log10(env / np.sqrt(2.0) + 1e-9) + return rssi + + +def self_test() -> int: + print("=== antenna_decorrelation self-test ===") + ok = True + + # 1) estimator recovers a known correlation (independent and correlated) + for target in (0.0, 0.5, 0.9): + rssi = _synth_chains(20000, 2, target, seed=1) + est = envelope_correlation(rssi)[0, 1] + err = abs(est - target) + status = "ok" if err < 0.05 else "FAIL" + ok &= err < 0.05 + print(f"[{status}] rho target={target:.2f} estimated={est:.3f} (err={err:.3f})") + + # 2) independent 4-chain MRC mean gain ~ theoretical 10log10(4)=6.0 dB + rssi4 = _synth_chains(40000, 4, 0.0, seed=2) + combo = combining_analysis(rssi4) + gain, theo = combo["mrc_mean_gain"], combo["theoretical_array_gain"] + # MRC mean-dB gain over the best single chain is at least the array gain + # 10log10(N), and legitimately exceeds it because summing branches reduces + # variance and so shrinks the log-bias of the single-branch mean. Require + # it >= theory and within a few dB above. + status = "ok" if (theo - 0.5) < gain < (theo + 3.5) else "FAIL" + ok &= (theo - 0.5) < gain < (theo + 3.5) + print(f"[{status}] indep 4-chain MRC mean gain={gain:.2f} dB (theory {theo:.1f} dB)") + + # 3) correlated chains yield less low-outage diversity gain than independent + indep = combining_analysis(_synth_chains(40000, 2, 0.0, seed=3))["mrc_gain_p1"] + corr = combining_analysis(_synth_chains(40000, 2, 0.9, seed=4))["mrc_gain_p1"] + status = "ok" if corr < indep else "FAIL" + ok &= corr < indep + print(f"[{status}] 1%-outage MRC gain: independent={indep:.2f} dB > " + f"correlated(rho=.9)={corr:.2f} dB") + + # 4) parser round-trips both line formats + sample = [ + "seq=1 rssi=40,38,20,18 snr=25,24,10,9 evm=5,6,7,8", + "junk line", + "rate=4 len=100 crc_err=0 icv_err=0 rssi=41,39 " + "evm=5,6 snr=26,25 seq=2 tsfl=0 bw=0 stbc=0 ldpc=0 sgi=0 body=deadbeef", + ] + a = parse(sample, "rssi") + status = "ok" if a.shape == (2, 4) else "FAIL" # stream row zero-padded to 4 + ok &= a.shape == (2, 4) + print(f"[{status}] parser: 2 rows x 4 cols from mixed formats -> {a.shape}") + + # 5) fading guard: a near-static channel (like a bench beacon) is flagged + # INCONCLUSIVE, and a pinned chain is flagged railed. + rng = np.random.default_rng(5) + static = 78.0 + 0.3 * rng.standard_normal((2000, 4)) + static[:, 2] = 82.0 # railed chain + s = summarise(static, "rssi") + cond = (not s["fading_ok"]) and (2 in s["railed"]) and \ + verdict(s).startswith("INCONCLUSIVE") + status = "ok" if cond else "FAIL" + ok &= cond + print(f"[{status}] fading guard: static channel -> INCONCLUSIVE, " + f"railed={s['railed']}, max_std={s['max_std']:.2f}") + + # 6) effective-branch metric: independent 4-chain ~ 4; correlated < 4. + indep_neff = summarise(_synth_chains(20000, 4, 0.0, seed=6), "rssi")["eff_branches"] + corr_neff = summarise(_synth_chains(20000, 4, 0.8, seed=7), "rssi")["eff_branches"] + cond = (indep_neff > 3.5) and (corr_neff < indep_neff) + status = "ok" if cond else "FAIL" + ok &= cond + print(f"[{status}] N_eff: independent 4-chain={indep_neff:.2f} (~4), " + f"correlated(rho=.8)={corr_neff:.2f} (< independent)") + + print("=== PASS ===" if ok else "=== FAIL ===") + return 0 if ok else 1 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("input", nargs="?", default="-", + help="capture file with lines, or '-' for stdin") + ap.add_argument("--metric", choices=("rssi", "snr", "evm"), default="rssi", + help="per-chain metric to analyse (default rssi)") + ap.add_argument("--self-test", action="store_true", + help="run the hardware-independent estimator self-test and exit") + args = ap.parse_args() + + if args.self_test: + return self_test() + + if args.input == "-": + lines = sys.stdin.readlines() + else: + with open(args.input, "r", errors="replace") as f: + lines = f.readlines() + + arr = parse(lines, args.metric) + if arr.size == 0: + print("no or lines found — did you " + "run WiFiDriverDemo with DEVOURER_RX_ALLPATHS=1 (and receive " + "frames)?", file=sys.stderr) + return 2 + print(report(summarise(arr, args.metric))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/compare_8814_decorrelation.sh b/tests/compare_8814_decorrelation.sh new file mode 100755 index 0000000..9d6acf3 --- /dev/null +++ b/tests/compare_8814_decorrelation.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Compare inter-chain antenna decorrelation across every plugged RTL8814AU. +# +# Two RTL8814AU dongles that enumerate identically (same VID:PID:serial) can only +# be told apart by USB topology, so this drives WiFiDriverDemo's +# DEVOURER_USB_BUS/PORT selector once per device. Each is measured as RX against +# one controlled beacon TX (the canonical SA), so the only variable between runs +# is the adapter's antenna front-end — e.g. 2 external dipoles (CF-938AC) vs 4 +# on-PCB internal antennas (CF-960AC). +# +# Env knobs: +# TX_PID=0x8812 beacon TX source PID (a *different* adapter; default 8812AU) +# CHANNEL=6 channel for TX and both RX runs +# DURATION=30 capture seconds per device +# METRIC=rssi rssi | snr | evm (forwarded to the analyser) +# +# Usage: sudo ./tests/compare_8814_decorrelation.sh +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" + +TX_PID="${TX_PID:-0x8812}" +CHANNEL="${CHANNEL:-6}" +DURATION="${DURATION:-30}" +METRIC="${METRIC:-rssi}" +OUTDIR="$(mktemp -d -t devourer-8814cmp.XXXXXX)" + +cleanup() { + for comm in WiFiDriverDemo WiFiDriverTxDemo; do + pkill -x "$comm" 2>/dev/null || true + done + rm -f "${TXLOG:-}" 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +echo "== building devourer ==" +[ -d "$ROOT/build" ] || cmake -S "$ROOT" -B "$ROOT/build" >/dev/null +cmake --build "$ROOT/build" -j >/dev/null +DEMO="$ROOT/build/WiFiDriverDemo" +TXDEMO="$ROOT/build/WiFiDriverTxDemo" + +# uv venv for the analyser (numpy only). +command -v uv >/dev/null 2>&1 || { echo "uv not found" >&2; exit 1; } +[ -d "$HERE/.venv" ] || uv venv "$HERE/.venv" >/dev/null +uv pip install --python "$HERE/.venv/bin/python" -q numpy >/dev/null +PY="$HERE/.venv/bin/python" + +# Discover every 0bda:8813 device as "bus port" pairs from sysfs. +mapfile -t DEVS < <( + for d in /sys/bus/usb/devices/*/; do + [ -f "$d/idProduct" ] || continue + [ "$(cat "$d/idProduct")" = "8813" ] || continue + echo "$(cat "$d/busnum") $(cat "$d/devpath")" + done +) +[ "${#DEVS[@]}" -gt 0 ] || { echo "no RTL8814AU (0bda:8813) found" >&2; exit 1; } +echo "== found ${#DEVS[@]} RTL8814AU device(s): ${DEVS[*]/#/[}" + +echo "== starting beacon TX on PID $TX_PID ch$CHANNEL ==" +TXLOG="$(mktemp -t devourer-txbeacon.XXXXXX.log)" +stdbuf -oL -eL env DEVOURER_PID="$TX_PID" DEVOURER_CHANNEL="$CHANNEL" "$TXDEMO" >"$TXLOG" 2>&1 & +# Wait for a confirmed inject rather than a fixed sleep — the 8812 TX needs +# ~10s to init and occasionally fails to claim on the first try, which would +# otherwise leave every device's capture empty. +for _ in $(seq 1 30); do + grep -q 'TX #.* rc=1' "$TXLOG" 2>/dev/null && break + sleep 1 +done +grep -q 'TX #.* rc=1' "$TXLOG" 2>/dev/null || { + echo "TX beacon did not confirm an inject within 30s — check $TXLOG" >&2; exit 3; } +echo "== TX beacon confirmed injecting ==" + +for dev in "${DEVS[@]}"; do + read -r bus port <<<"$dev" + tag="bus${bus}_port${port}" + cap="$OUTDIR/$tag.log" + echo + echo "===================================================================" + echo "== RX device bus=$bus port=$port (${DURATION}s, ch$CHANNEL) ==" + echo "===================================================================" + env DEVOURER_USB_BUS="$bus" DEVOURER_USB_PORT="$port" DEVOURER_PID=0x8813 \ + DEVOURER_RX_ALLPATHS=1 DEVOURER_CHANNEL="$CHANNEL" \ + "$DEMO" >"$cap" 2>/dev/null & + rxpid=$! + sleep "$DURATION" + kill "$rxpid" 2>/dev/null || true + wait "$rxpid" 2>/dev/null || true + + frames="$(grep -c '' "$cap" || true)" + echo "captured ${frames:-0} canonical-SA frames" + if [ "${frames:-0}" -gt 0 ]; then + "$PY" "$HERE/antenna_decorrelation.py" --metric "$METRIC" "$cap" + else + echo " (no beacon frames — check TX is flying and this adapter hears ch$CHANNEL)" + fi +done + +echo +echo "== per-device capture logs kept in $OUTDIR ==" +trap - EXIT +cleanup \ No newline at end of file diff --git a/tests/run_antenna_decorrelation.sh b/tests/run_antenna_decorrelation.sh new file mode 100755 index 0000000..e7206f9 --- /dev/null +++ b/tests/run_antenna_decorrelation.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Antenna-decorrelation measurement runner (spatial-diversity axis). +# +# Measures the inter-chain envelope correlation and realised diversity gain of a +# multi-chain Realtek adapter, against a *controlled* transmitter: devourer's RX +# demo only surfaces per-chain metrics for the canonical beacon SA +# (57:42:75:05:d6:00), so a second adapter must be injecting that beacon +# (WiFiDriverTxDemo) on the same channel — the standard two-adapter bench setup. +# +# It builds devourer, runs WiFiDriverDemo (RX) with DEVOURER_RX_ALLPATHS=1 for a +# fixed dwell capturing the lines, then feeds them to +# antenna_decorrelation.py. Optionally starts the TX beacon too (set TX_PID). +# +# Env knobs (all optional): +# CHANNEL=6 monitor channel for both RX and (if started) TX +# RX_PID=0xNNNN restrict the RX demo to one adapter PID (e.g. 0x8813 8814AU) +# TX_PID=0xNNNN if set, also start WiFiDriverTxDemo on this PID as the source +# DURATION=30 capture seconds +# METRIC=rssi rssi | snr | evm (forwarded to the analyser) +# +# Usage: +# sudo CHANNEL=6 RX_PID=0x8813 TX_PID=0x8812 DURATION=30 \ +# ./tests/run_antenna_decorrelation.sh +# # or, with the beacon already flying from another host/adapter: +# sudo RX_PID=0x8813 ./tests/run_antenna_decorrelation.sh +# +# Cleanup: on any exit (incl. Ctrl-C) the demo processes are killed by exact comm. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" + +CHANNEL="${CHANNEL:-6}" +DURATION="${DURATION:-30}" +METRIC="${METRIC:-rssi}" +CAP="$(mktemp -t devourer-decorr.XXXXXX.log)" + +cleanup() { + # Exact-comm kills only — never a broad pkill pattern. + for comm in WiFiDriverDemo WiFiDriverTxDemo; do + pkill -x "$comm" 2>/dev/null || true + done + rm -f "$CAP" "${TXLOG:-}" 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +echo "== building devourer ==" +if [ ! -d "$ROOT/build" ]; then + cmake -S "$ROOT" -B "$ROOT/build" >/dev/null +fi +cmake --build "$ROOT/build" -j >/dev/null + +DEMO="$ROOT/build/WiFiDriverDemo" +TXDEMO="$ROOT/build/WiFiDriverTxDemo" +[ -x "$DEMO" ] || { echo "WiFiDriverDemo not built at $DEMO" >&2; exit 1; } + +# uv venv for the analyser (numpy only; no system packages needed). +if ! command -v uv >/dev/null 2>&1; then + echo "uv not found — install it (https://docs.astral.sh/uv/)." >&2 + exit 1 +fi +[ -d "$HERE/.venv" ] || uv venv "$HERE/.venv" >/dev/null +uv pip install --python "$HERE/.venv/bin/python" -q numpy >/dev/null +PY="$HERE/.venv/bin/python" + +if [ -n "${TX_PID:-}" ]; then + [ -x "$TXDEMO" ] || { echo "WiFiDriverTxDemo not built" >&2; exit 1; } + echo "== starting TX beacon on PID $TX_PID ch$CHANNEL ==" + TXLOG="$(mktemp -t devourer-txbeacon.XXXXXX.log)" + stdbuf -oL -eL env DEVOURER_PID="$TX_PID" DEVOURER_CHANNEL="$CHANNEL" "$TXDEMO" >"$TXLOG" 2>&1 & + # Poll until the beacon actually injects — TX+RX each need ~7-10s to init + # (efuse read + settle), and the 8812 TX occasionally fails to claim on the + # first try when launched alongside the RX's USB reset. Waiting for a + # confirmed inject (a `TX #.. rc=1` line) instead of a fixed sleep is what + # makes a zero-frame capture (dead TX window) reliable to avoid. + for _ in $(seq 1 30); do + grep -q 'TX #.* rc=1' "$TXLOG" 2>/dev/null && break + sleep 1 + done + if ! grep -q 'TX #.* rc=1' "$TXLOG" 2>/dev/null; then + echo "TX beacon did not confirm an inject within 30s — check $TXLOG" >&2 + exit 3 + fi + echo "== TX beacon confirmed injecting ==" +fi + +RX_ENV=(DEVOURER_RX_ALLPATHS=1 DEVOURER_CHANNEL="$CHANNEL") +[ -n "${RX_PID:-}" ] && RX_ENV+=(DEVOURER_PID="$RX_PID") +# Optional USB-topology selection to pin one of several identical adapters. +[ -n "${USB_BUS:-}" ] && RX_ENV+=(DEVOURER_USB_BUS="$USB_BUS") +[ -n "${USB_PORT:-}" ] && RX_ENV+=(DEVOURER_USB_PORT="$USB_PORT") + +# ROTATE=1 turns this into a decorrelation-stimulus capture: a static bench +# channel can't be measured (see antenna_decorrelation.py's fading guard), so +# the operator rotates/tilts the adapter through the window to sweep each +# antenna's pattern against the fixed TX. A short countdown gives time to start. +if [ "${ROTATE:-0}" = "1" ]; then + echo "== ROTATION CAPTURE — start rotating/tilting the adapter continuously ==" + for s in 3 2 1; do echo " capture starts in $s..."; sleep 1; done +fi +echo "== capturing RX per-chain metrics for ${DURATION}s (ch$CHANNEL) ==" +env "${RX_ENV[@]}" "$DEMO" >"$CAP" 2>/dev/null & +RX_PID_RUNNING=$! +sleep "$DURATION" +kill "$RX_PID_RUNNING" 2>/dev/null || true +wait "$RX_PID_RUNNING" 2>/dev/null || true + +RXLINES="$(grep -c '' "$CAP" || true)" +echo "== captured $RXLINES frames ==" +if [ "${RXLINES:-0}" -eq 0 ]; then + echo "No canonical-SA frames captured. Is the beacon TX flying on ch$CHANNEL," \ + "and is RX_PID the right adapter?" >&2 + exit 2 +fi + +echo "== analysis ==" +"$PY" "$HERE/antenna_decorrelation.py" --metric "$METRIC" "$CAP"