Skip to content

Link-health classifier: diagnose near-field saturation in plain language#193

Merged
josephnef merged 1 commit into
masterfrom
link-health-diagnostics
Jul 6, 2026
Merged

Link-health classifier: diagnose near-field saturation in plain language#193
josephnef merged 1 commit into
masterfrom
link-health-diagnostics

Conversation

@josephnef

Copy link
Copy Markdown
Collaborator

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 WiFiDriverDemo behind DEVOURER_LINKHEALTH=1 (rides the DEVOURER_RX_ENERGY_MS window):

<devourer-linkhealth>verdict=SATURATED rssi_dbm=-33 snr_db=19.5 evm_db=-22.5
  cause="strong RSSI but poor EVM — receiver front-end overload and/or the strong
  signal self-jamming via reflections (near-field). SNR alone can look fine here"
  fix="REDUCE TX power (SetTxPowerOffsetQdb, e.g. -40..-80 qdB), add an attenuator,
  or increase distance — do NOT add power/antenna"

Note that line: snr_db=19.5 looks healthy — but evm_db=-22.5 reveals the saturation. EVM, not SNR, is the tell.

The measurement behind it

tests/saturation_knee_sweep.sh walks TX power up while a second adapter reports per-frame metrics:

TX power RSSI (raw) SNR EVM
low → knee 50 → 60 18 dB −28 dB (improving)
knee → full 68 → 73 18 dB (flat) −20 → −13 dB (collapsing)

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-power rssi_max ~81 with EVM ~−42, backed-off ~77 with EVM ~−51).

Validation

  • Unit (ctest): tests/link_health_selftest.cpp — 8 cases drawn from real on-air data (the knee sweep + the AWGN interference sweep tests/j3_dig_penalty_sweep.sh). Registered in the mingw target list.
  • On-air (tests/link_health_onair.sh): the core near-field A/B — full power → SATURATED, backed off to the EVM knee → HEALTHY, 2/2. INTERFERENCE/WEAK can't be cleanly reproduced at bench range (a strong direct path inches away dominates any injected noise — the classifier then correctly reports SATURATED), 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 for DEVOURER_LINKHEALTH.

ctest green (incl. the new link_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

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>
@josephnef josephnef merged commit 686c42f into master Jul 6, 2026
15 checks passed
@josephnef josephnef deleted the link-health-diagnostics branch July 6, 2026 06:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant