Link-health classifier: diagnose near-field saturation in plain language#193
Merged
Conversation
The most common way a bench link misleads users: TX+RX inches apart at high power, link WORSE than at range, and they blame the NIC/antenna/interference. It's almost always the opposite — the signal is too strong and the receiver is saturating or self-jamming off wall reflections; adding power/antenna makes it worse. Devourer already exposes every sensor to see this; it was missing the interpretation layer. src/LinkHealth.h — a pure, unit-tested classifier mapping the RX sensor tuple to a verdict + cause + fix: SATURATED / INTERFERENCE / WEAK / MARGINAL / HEALTHY / NO_SIGNAL. The discriminator is EVM, not SNR: on-air measurement (tests/saturation_knee_sweep.sh) shows SNR sits flat while EVM improves then REVERSES at the front-end saturation knee — more signal making the constellation worse. RSSI splits strong-dirty (saturation: back OFF power) from weak-dirty (interference/range: the SNR/FA context decides). Wired into WiFiDriverDemo behind DEVOURER_LINKHEALTH=1 (rides the DEVOURER_RX_ENERGY_MS window): emits <devourer-linkhealth>verdict=... with a plain-language cause and fix. Uses rssi_max (window peak) as the strength signal — near-field saturation trashes frames and drags the mean down while the peak stays pegged at the PWDB ceiling (measured: full-power rssi_max ~81, backed-off ~77; EVM -42 vs -51). Thresholds calibrated from two on-air sweeps (the knee sweep + the AWGN interference sweep tests/j3_dig_penalty_sweep.sh), unit-guarded in tests/link_health_selftest.cpp (ctest + mingw target list), and the core SAT-vs-HEALTHY split verified on-air (tests/link_health_onair.sh: full power -> SATURATED, backed off -> HEALTHY, 2/2). INTERFERENCE/WEAK can't be cleanly reproduced at bench range (a strong direct path dominates injected noise) so they stay synthetic in the selftest. docs/bench-testing-near-field.md: the mechanism, the "is 25 mW too much next to each other" numbers (yes — -14 dBm at 10 cm is in compression), and the back-off recipe using the runtime TX-power knob (SetTxPowerOffsetQdb) instead of low-power firmware. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The most common way a bench link misleads a user: TX and RX inches (or a few feet) apart at high power, the link is worse than at range, and they blame the NIC, the antennas, or interference. It's almost always the opposite — the signal is too strong and the receiver is saturating or self-jamming off wall reflections, and adding power/antenna makes it worse. (This came out of a support thread about exactly this: "RSSI −10, parked next to each other, link dead.")
Devourer already exposes every sensor needed to see this (per-frame RSSI/SNR/EVM, frame-free FA/CCA/IGI) — it was missing the interpretation layer. This adds it.
What
src/LinkHealth.h— a pure, unit-tested classifier mapping the RX sensor tuple to a verdict + cause + fix:SATURATED/INTERFERENCE/WEAK/MARGINAL/HEALTHY/NO_SIGNAL.Wired into
WiFiDriverDemobehindDEVOURER_LINKHEALTH=1(rides theDEVOURER_RX_ENERGY_MSwindow):Note that line:
snr_db=19.5looks healthy — butevm_db=-22.5reveals the saturation. EVM, not SNR, is the tell.The measurement behind it
tests/saturation_knee_sweep.shwalks TX power up while a second adapter reports per-frame metrics:SNR sat flat across the whole sweep and told you nothing; EVM improved then reversed at the saturation knee — more signal making the constellation worse. That turnover is the signature, and it calibrated the classifier's thresholds. A second input,
rssi_max(window peak) rather than the mean, is used for strength because near-field saturation trashes a fraction of frames and drags the mean down while the peak stays pegged at the PWDB ceiling (measured: full-powerrssi_max~81 with EVM ~−42, backed-off ~77 with EVM ~−51).Validation
ctest):tests/link_health_selftest.cpp— 8 cases drawn from real on-air data (the knee sweep + the AWGN interference sweeptests/j3_dig_penalty_sweep.sh). Registered in the mingw target list.tests/link_health_onair.sh): the core near-field A/B — full power → SATURATED, backed off to the EVM knee → HEALTHY, 2/2.INTERFERENCE/WEAKcan't be cleanly reproduced at bench range (a strong direct path inches away dominates any injected noise — the classifier then correctly reportsSATURATED), so they stay synthetic in the selftest.Docs
docs/bench-testing-near-field.md— the two mechanisms, the "is 25 mW too much next to each other" numbers (yes: +14 dBm − 28 dB path loss at 10 cm = −14 dBm at the RX, in compression), and the back-off recipe using the runtime TX-power knob (SetTxPowerOffsetQdb) instead of low-power firmware. Plus a CLAUDE.md pointer forDEVOURER_LINKHEALTH.ctestgreen (incl. the newlink_health_classify); J1-only subset build verified (the classifier is generation-agnostic core). No new library env vars beyond the one demo-side opt-in; no behavior change when it's off.🤖 Generated with Claude Code