[hlth] Fix false HLTH SWR warnings during transmit transitions#4268
Conversation
There was a problem hiding this comment.
Nicely scoped fix with a clear root-cause writeup. I traced the data flow and the design holds: directionalPowerMetersChanged carries m_fwdPowerInstant (raw instantaneous forward power) and is emitted right after txMetersChanged from the same MeterModel, so updateRadioDirectionalMeters reliably overwrites swrQualifyingPowerWatts with the instantaneous value. That makes the unkey case behave — m_fwdPower is still decaying ≥0.75 W while m_fwdPowerInstant≈0, so the SWR sample fails kActivePowerWatts qualification and never poisons the baseline. The admittedSwr lower-quartile (index (n-1)/4 = 1 at n=5) does require 4 of 5 elevated samples as claimed, and severity is correctly forced to 0 until swrTrusted. No null-deref, leak, QSettings, or RAII concerns, and all 7 checks are green. The health_applet_test covering key-up / unkey / outliers / sustained-fault is a welcome addition.
Polish (non-blocking)
- The new tests wait on wall-clock elapsed time and the 50 ms GUI tick timer — inherently timing-sensitive under CI load. It passed here, so this is just a heads-up on the pattern, not a request to change it.
Non-blocking notes
admittedSwr's comment hardcodes "four of five" / "250 ms"; those numbers are only exact at the currentkSwrAdmissionWindow/kSwrSettleFrames/kTickMs. Consider phrasing it in terms of the constants so it doesn't drift if they're tuned later.
Thanks for the thorough live-bridge proof and the deterministic test — looks good to me.
🤖 aethersdr-agent · cost: $7.0732 · model: claude-opus-4-8
NF0T
left a comment
There was a problem hiding this comment.
Good root-cause diagnosis and a well-designed core idea — decoupling instantaneous forward power (for SWR gating) from the smoothed display power is the right direction, and the four new tests demonstrate the false-positive fix works for the reported case. CI is green and the commit is signed.
Two issues that make this riskier than the bug it fixes:
1. The admission-window reset can permanently trap HLTH in "never trusted," masking a real sustained fault. m_swrAdmissionWindow clears on any single inactive tick or source switch — unlike every other piece of state in this class, which tolerates up to 24 frames (~1.2s) before resetting. bestSnapshot() recomputes the winning meter source every tick from two freshness thresholds (900ms/1700ms) with no hysteresis; if an amplifier or tuner's meter cadence straddles that boundary, bestSnapshot() can oscillate between sources, clearing the 5-sample window each time. Once trapped in that oscillation, swrTrusted never becomes true and sample.severity stays forced to 0.0f — even during a genuine antenna fault. That's the opposite of what #4243 needs. None of the four new tests exercise more than one meter source, so this wouldn't be caught. Would suggest either not clearing the window on a bare source switch (only on genuine inactivity), or giving it the same dropout tolerance the rest of the class already has.
2. The fix's correctness depends on an undocumented, and not-always-true, cross-signal assumption. directionalPowerMetersChanged and txMetersChanged both emit from MeterModel::updateValues(), but directionalChanged is gated by three branches (FWDPWR/REFPWR/SWR) while txChanged is gated by two (FWDPWR/SWR) — so a REFPWR-only packet fires directionalPowerMetersChanged alone, with no txMetersChanged in the same cycle, on any radio that reports REFPWR. It's harmless today only because the timestamps happen to reflect staleness correctly in that case, but nothing in HealthApplet.cpp comments this dependency — a future MeterModel.cpp refactor (splitting FWDPWR/SWR into independently-gated signals, a plausible cleanup) could silently reintroduce the exact bug this PR fixes, with no compile error and no guaranteed test failure. Worth at least a comment at the connection site explaining why emission order matters here.
Also worth addressing, lower priority:
- The
swrTrustedgate (4-frame settle + 5-sample quorum, ~200-250ms+) applies uniformly to all three meter sources, but the root cause — smoothed power gating unsmoothed SWR — only exists for Radio (MeterModel.cppconfirms Tuner/Amplifier forward power is never smoothed). This adds an unnecessary detection delay for a genuine Tuner/Amplifier fault that previously flagged immediately. Untested by the new suite (Radio-source meters only). pushRecent()/baseline-averaging is now gated behindswrTrusted, not justactive— health telemetry freezes for the first ~250ms+ of every TX over, so a fast real fault confined to that window is invisible once per over, systematically.
Non-blocking:
admittedSwr()re-implements a sort-based order-statistic pattern that exists asnth_element-based helpers in 4+ other files (AdaptiveFilterEngine.cppeven documents a prior review making this exact tradeoff) — worth reusing one of those instead.cacheMeters()'s writes to the new "qualifying" fields are dead code for Radio (immediately overwritten) and redundant for Tuner/Amp (duplicate the already-instantaneous value) — a comment explaining the asymmetry would help the next reader.
Happy to re-review once the admission-window fragility and the cross-signal dependency are addressed — the core approach is sound, it just needs to fail safer.
…le (aethersdr#4268) Addresses @NF0T's review. The core decoupling of instantaneous forward power from smoothed display power is kept; these changes make it fail SAFE and cover the source/mode cases the original missed. - Admission-window fragility (NF0T #1, the blocker): the window cleared on any single inactive tick or bestSnapshot() source flip. bestSnapshot() has no hysteresis at its 900ms/1700ms freshness boundary, so a second meter whose cadence straddles it oscillates the source and could trap HLTH in "never trusted" — masking a real fault. Now the window tolerates kSwrDropoutFrames (~1.2s, matching the class's idle-baseline tolerance) of inactivity or source mismatch before resetting, admits only samples from the tracked source, and still acquires the first source immediately. Net direction is fail-safe: the window only ever persists longer, never shorter. - Radio-only settle (NF0T #3): only Radio forward power is smoothed, so only Radio SWR carries the key/unkey transient. Tuner/Amplifier now trust their raw SWR immediately instead of eating the ~250ms settle delay before a genuine amp/tuner fault can flag. - Cross-signal dependency (NF0T #2): documented at the connect site why directionalPowerMetersChanged and txMetersChanged must stay co-emitted. - Telemetry gate (NF0T aethersdr#4): documented the intentional Radio settle blind spot (Tuner/Amp have none now). - admittedSwr() uses std::nth_element (O(n)) instead of a full sort; cacheMeters qualifying-field asymmetry documented; unkey test margin widened for CI headroom. health_applet_test 4/4 across 10 runs; full app builds clean. (During development an initial version of the tolerance delayed first-source acquisition by 24 frames and MASKED the sustained-fault test — caught and fixed; the suite has teeth.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ten9876
left a comment
There was a problem hiding this comment.
Maintainer approval (Tier 1) — @NF0T's issues fixed, fail-safe
Full high-effort pass. I independently confirmed NF0T's #1 is a real fail-unsafe trap: bestSnapshot() has no hysteresis at its 900ms/1700ms boundary (verified in code), so a second meter cadence straddling it oscillates the source and the old m_swrAdmissionWindow.clear() on every flip could pin swrTrusted false and force severity to 0 during a genuine fault. Pushed 7d4bdbf5 to resolve all of it:
- #1 (admission-window fragility): the window now tolerates
kSwrDropoutFrames(~1.2s, matching the class's idle-baseline tolerance) of inactivity or source mismatch before resetting, admits only samples from the tracked source, and still acquires the first source immediately. The change is fail-safe by construction — it can only make the window persist longer (more trust → more detection), never shorter. - #3 (Tuner/Amp settle delay): only Radio forward power is smoothed, so Tuner/Amplifier now trust raw SWR immediately — no ~250ms delay before a real amp/tuner fault flags.
- #2 / #4: documented the directionalPowerMetersChanged/txMetersChanged co-emission dependency at the connect site, and the intentional Radio settle blind spot.
- Non-blocking:
admittedSwr()→std::nth_element(O(n)); cacheMeters asymmetry documented; unkey-test margin widened for CI headroom.
Verification: health_applet_test 4/4 across 10 runs, full app builds clean. Notably, my first cut of the tolerance delayed first-source acquisition by 24 frames and masked the sustained-fault test — I caught and fixed it, so the suite genuinely has teeth against the fail-unsafe direction.
Honest coverage note: I did not add a dedicated multi-source oscillation test — reproducing the 900ms-straddle deterministically in the current wall-clock harness would be flaky (the exact concern behind NF0T's test note), and a flaky test is worse than none. The fix is instead validated by the fail-safe reasoning above + the regression I caught. A clock-injection test seam is a reasonable follow-up.
Approving. Thanks for the sharp fail-unsafe catch, @NF0T, and the solid root-cause work, @jensenpat.
## Summary - bump the CMake and README versions from 26.7.2 to 26.7.3 - add the v26.7.3 CHANGELOG entry for 25 commits since v26.7.2 - credit 7 contributors plus Dependabot, including two squash commits and one direct docs asset commit recovered during the attribution audit ## Release facts - content cutoff: `48203274` — PR #4268 - range: `v26.7.2..4820327` (25 commits) - signed prep commit: `9634c35f` - release theme: cross-needle metering, radio-state fidelity, and operating polish The v26.7.3 tag and published release will point directly at the signed prep commit so tag-triggered platform builds carry the correct embedded version while this PR awaits the required code-owner review. ## Verification - [x] fetched upstream tags and confirmed the cutoff still matches `upstream/main` - [x] confirmed no existing v26.7.3 tag or GitHub release - [x] verified exactly three tracked files changed - [x] `git diff --check` - [x] CMake and README both report 26.7.3 - [x] commit signature reports `Good "git" signature` - [x] cross-checked contributor counts against `git shortlog` - [x] audited missed PRs #4283 and #4284 plus the direct README screenshot commit - [ ] tag-triggered Linux, macOS, Windows, signing, and Stream Deck workflows (start after tag push) Screenshots: not applicable — this PR changes release metadata only; screenshots for the shipped features live on their originating PRs. 👨🏼💻 Generated with OpenAI Codex (GPT-5.6 Sol) and tested by @jensenpat Co-authored-by: Codex <noreply@openai.com>
Summary
health_applet_testcovering key-up, unkey, intermittent low-power outliers, healthy load, and sustained high SWRRoot cause
HLTH combined MeterModel's smoothed forward-power value with unsmoothed SWR reports, then retained each report in its 3.5-second variance history. Near key/unkey—and especially at low forward power—the radio can emit isolated upward SWR samples even though the current main meter is normal. Those samples poisoned HLTH's baseline and variance score, producing a stale
WATCHorGROUND?state.The bridge trace on the ANT2 dummy load at a 2 W setpoint observed instantaneous SWR reports as high as 1.79 among predominantly 1.00 readings. The fix admits only distinct, power-qualified reports and requires four of the latest five reports to be elevated before scoring the rise.
Verification
cmake --build build --target health_applet_test AetherSDR -j8ctest --test-dir build -R 'health_applet_test|meter_model_test' --output-on-failure— 2/2 passedpython3 tools/check_a11y.py— no new findings (existing baseline warnings only)python3 tools/check_engine_boundary.py --strict— 0 blockersgit diff --checkAgent automation bridge proof on the rebuilt app:
ANT2IDLE, SWR 1.00OKIDLEsuccessful, HLTH hiddenFixes #4243
👨🏼💻 Generated with OpenAI Codex (GPT-5.6 Sol) and tested by @jensenpat