Repository navigation
fix(gui): NB Blank works on a dBm waterfall row. Principle VIII. - #6117
Conversation
The waterfall impulse blanker (aethersdr#277) replaced a row when ringCount >= 8 && baseline > 0 && rowMean > baseline * threshold A Flex waterfall tile is a positive intensity, so that is a ratio test. A radio whose spectrum is computed on this host (HL2, ANAN, RTL-SDR) has no waterfall plane: RadioModel::onBackendSpectrumFrame reuses the pan frame as the row, so the row is negative dBm, `baseline > 0` is never true and the control did nothing. Dropping the guard would be worse: with a baseline of -147 and 1.15 the product is -169, every ordinary row is above it and the waterfall stands still. The decision moves to a pure helper, WaterfallImpulseBlanker::decide, which takes the row's unit. The unit is the declared capability RadioCapabilities::panBinsAbsolute() (m_panBinsAbsolute in the widget), not the sign of the data. - Tile rows: the ratio test and the 1.05 cap, unchanged. - dBm rows: rowMean - baseline > (threshold - 1) * 100 dB, which is one dB per step of the 5..95 control: 5 dB at the lowest setting, 15 dB at the default. A rejected row enters the ring at most 5 dB over the baseline. A baseline that is not finite fails open. The reference of 100 stands in for the tile's own level (about 96..120), which is what the ratio multiplies on a Flex. The widget colours both row kinds with one range width in the row's unit, so the same setting asks for the same step in colour on either radio. 10*log10(threshold) was not used: 0.2 to 2.9 dB is inside what one station keying up does to the mean of a narrow span. Test: waterfall_impulse_blanker_test, 47 checks. 121176 tile cases decide bit for bit as the original expression. Twelve mutations of the helper and the call site were each caught. Not observed in the app yet: the HL2 observation this answers is from before the change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No conflicts. The branch's long comments are cut to the five-line rule; the margin derivation stays in the original commit message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings the comment blocks this branch adds under the AGENTS.md rule: a comment says what is true now, in five lines or fewer. How the defect was found and the lab run it was seen in are in the PR body. The source-text block now names the claim it pins and why no behavioural seam reaches it (SpectrumWidget links into no test target), as docs/agents/tests-ci.md asks. One check's name loses a lab run number; no check's condition changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
1. Issue fit
Yes. No Fixes line — Refs #5678 / Refs #277 — and GOVERNANCE.md does not want an RFC for a bug fix with a clear root cause, which this is.
I verified the root cause rather than taking it from the body. RadioModel::onBackendSpectrumFrame (src/models/RadioModel.cpp:6659) re-emits the host-computed pan frame as the waterfall row ("the backend has no separate waterfall plane, so the pan row is the waterfall row", line 6706), and that lands in SpectrumWidget::updateWaterfallRow. The old baseline > 0.0f term therefore is false on every row of a negative-valued frame, so NB Blank is an inert control on HL2/ANAN/RTL-SDR. The author's "dropping the guard is not the fix" argument also holds: -147 * 1.15 = -169, so a bare ratio test blanks everything.
I also confirmed the load-bearing justification for the source-text pin: tests/tests.cmake names SpectrumWidget.cpp exactly once (in this PR's own comment), no test target compiles it, and the pattern has precedent in the file at tests.cmake:6622.
2. Scope
Everything in the diff is explained by the stated fix.
| File | What it changes | Claimed? | Verdict |
|---|---|---|---|
src/gui/WaterfallImpulseBlanker.h |
New header-only decide(); tile law verbatim, dBm law added |
✅ | In scope |
src/gui/SpectrumWidget.cpp |
+1 include; inline test → one call; two identical ring writes → one | ✅ | In scope. Behaviour-preserving for tiles — see below |
tests/tests.cmake |
Registers waterfall_impulse_blanker_test |
✅ | In scope |
tests/waterfall_impulse_blanker_test.cpp |
47 checks | ✅ | In scope |
Four files, nothing else. No CHANGELOG.md entry (correct — it must not have one). No new public/protocol/settings surface: RowKind and decide() are internal, no capability field is added, no default changes. No sibling call site left behind — m_wfBlankerThreshold has exactly one consumer and it is this one.
Deleted behaviour: the only - lines of substance are the two per-branch ring writes. They collapse correctly — decide() returns min(rowMean, baseline*1.05f) on a tile impulse and rowMean otherwise, which is byte-for-byte what the two deleted lines wrote, and the unconditional write sits in the same place the old pair did (no early return between).
Not a preference change. The old behaviour is a dead control on three families, with hardware evidence in the body. The direction (higher slider = less blanking) matches the tile law. Nothing user-visible changes on a Flex.
Socket tests: none added, removed, or modified. The new test is Qt-free and opens nothing — grep -E "QTcp|QUdp|QLocal|bind\(|listen\(|connectToHost" on it returns 0 hits.
3. Blockers
None.
4. Nits (non-blocking)
docs/architecture/radio-capabilities-map.md:529goes stale. It states: "panBinsAbsolute()is consumed too, and it is the second term of ONE gate." After this merges there are two consumers, and #6115's body says it "records the flag's second consumer" — so whichever lands second leaves the doc wrong either way. One line here is cheaper than coordinating the order. I looked for a canon sentence requiring a consumer to be documented and did not find one inAGENTS.md, so this is a convention, not a rule.- The source-text pin is whitespace-collapsed exact-match. A future
clang-formatrewrap of those five lines turns it red with no behaviour change. You flag it yourself and offer to drop it; I would keep it — there is genuinely no behavioural seam, and the precedent exists — but it is worth a comment in the test saying it is reformat-sensitive. - The test's
RinghardcodeskN = 32with nothing pinning it toSpectrumWidget::WF_BLANKER_N(currently 32,SpectrumWidget.h:1992). If that constant ever moves, the ring-level tests silently stop modelling the widget. RowKind::Dbmslightly overclaims the unit.RadioModel.cpp:6662calls the HL2 payload "DC-centred dBFS bins", and RTL bins are dBFS too. The law is a pure difference so it is unit-agnostic and correct either way — but the enum name asserts something the producers do not.- Pre-existing, not yours:
SpectrumOverlayMenu.cpp:2397tooltips the slider "Higher values blank more aggressively." That is backwards for both laws. Worth naming because this PR is the first thing to state the slider's meaning in dB. Alsom_wfBlankerMode("0=Fill, 1=Interpolate") is persisted and settable but read nowhere — a dead settings key from #277.
5. What I tried to break
The new test is Qt-free, so unusually I actually ran this one rather than only reasoning about it: c++ -std=c++20 -I src on the standalone test, plus my own probe harness against the shipped header.
- Reproduced the red-before-green table independently. I applied three of your mutations myself and got your exact counts: dBm branch inert → 11 failed, finite guard dropped → 3, rejected row uncapped → 4. The clean run is 47 checks, 0 failed, with 121176 tile cases / 27040 impulses and "75 rows held" — every number in the body reproduces. The red-before-green claim is real, not decorative.
- Attacked the threshold range for a divide-by-the-margin blow-up. If
thresholdcould reach 1.0,dbMarginis 0 and the dBm cap becomesmin(rowMean, baseline)— a baseline that can only fall, i.e. a permanent freeze. It cannot:setWfBlankerThresholdclamps to[1.05, 2.0](SpectrumWidget.cpp:4800) and the settings load clamps identically (:2511), and the slider is 5..95. Both doors are shut. No finding. - Periodic impulses, to see whether the cap lets the baseline creep until blanking dies. At 1/2, 1/3, 1/5 and 1/10 duty with a 37 dB burst over 1000 rows: 0 missed on either row kind, baseline settling at −142.0 (dBm) and 113.7 (tile). The cap does its job.
- A slow ramp, the classic false-positive for a margin test. +0.5 dB/row for 200 rows at slider 5: dBm blanks 188/200, tile blanks 187/200. That is #277's existing slow-ramp behaviour inherited exactly, not something the dBm law introduces. At 1.15 and above, 0/200 on both. Parity, no finding.
- Hold length on a sustained step — the one stimulus you actually measured on hardware. A 36 dB step that stays is held 108 rows (4.3 s) at slider 5 and 75 rows (3.0 s) at the default, released thereafter; the tile equivalent is 86 and 55 rows. Same regime, dBm slightly longer. Maintainer-visible: the LNA step in your own evidence table will now produce a ~3 s frozen stripe where it previously passed straight through. I think that is correct — it is what a Flex already does — but it is the first thing a user will notice and it deserves to be a conscious call.
- The transition no test covers: connect/disconnect across row kinds without a restart.
MainWindow.cpp:7561sets the flag asconnected && caps.panBinsAbsolute(), and the ring is never reset. Flex→HL2 leaves ~108 residue read under the dBm law:rowMean − baselineis hugely negative, never fires. HL2→Flex leaves ~−147 residue read under the tile law:baseline > 0.0fis false, never fires. Both fail open and self-heal within 32 rows (~1.3 s of under-blanking). Benign in both directions. - Non-finite handling both ways.
-infand NaN baselines fail open on the dBm path by the explicit guard, and on the tile path incidentally (NaN > 0is false,rowMean > +infis false). A-infrow mean still enters the ring and disables the blanker for 32 rows — your test pins exactly that and the recovery, and I could not turn it into a permanent freeze. - Equivalence of the tile path, beyond your grid: the collapsed ring write is the same value in the same position as the two deleted lines, with no early return between the branch and the write. I read the whole function (
SpectrumWidget.cpp:8450-8690) rather than the hunk. - Merge with #6115, which touches the same function:
git merge-treeproduces 0 conflict markers. Your "merges cleanly" claim holds. Worth the maintainer noticing that the two PRs split one unit bug across two new headers (WaterfallImpulseBlanker.handWaterfallLevelMap.h) with the same extraction-plus-source-pin shape — reviewable independently, but it is one problem in two parts. - CI is green on
63a73a35across all five checks, includingcheck-windows(so no MSVC surprise from the barenear()helper). - Could not check: anything at runtime in the GUI — no build, no radio. You state this yourself for ANAN and RTL-SDR and for the fix-in-app, and I cannot close any of those gaps. The call-site wiring is reasoned from code, since nothing links
SpectrumWidget.
6. Recommendation
Approve with nits. This is the most falsifiable PR body I have reviewed here — every number in it reproduced when I ran the test and the mutations myself, the tile path is provably unchanged, and the four attacks I thought would land (threshold underflow, baseline creep under periodic impulses, slow-ramp false positives, cross-radio ring residue) all failed against the code.
The one genuine judgment call is kDbPerThresholdUnit = 100, which you correctly hand to the maintainer. I would not escalate it to "needs a decision": your comparison table rules out 10*log10(t) and (t-1)*|baseline| for stated reasons, and my hold-length numbers independently show the resulting dBm margin lands in the same regime as the tile ratio rather than being arbitrary. It is one constant in one header if anyone wants it moved.
Next step: add the one-line radio-capabilities-map.md:529 correction for the second consumer, and let the maintainer rule on the sustained-step hold being the intended new behaviour on HL2. Nice work — 73.
🤖 aethersdr-agent · cost: $9.9012 · model: claude-opus-5
Review follow-up on aethersdr#6117, no arithmetic change. RowKind::Dbm becomes RowKind::AbsoluteDb: the rows HL2, ANAN and RTL-SDR hand the waterfall are dBFS under a dBm label, and the law only takes a difference. Same vocabulary as WaterfallLevelMap (rowsAreAbsoluteDb). The ring size is in the header as kRingRows. The test's ring takes it, and SpectrumWidget::updateWaterfallRow holds WF_BLANKER_N to it with a static_assert, which a source-text check pins. A static_assert in the .cpp rather than an include in SpectrumWidget.h keeps the widely included header untouched. The source-text block now says it is sensitive to a reformat. radio-capabilities-map.md names the flag's use as the row unit. The header states that the ring holds one unit at a time: the row kind changes only across a disconnect, where clearDisplay -> clearWaterfallRows -> clearCurrentWaterfallRows -> resetWfBlankerState empties it. Without that, absolute-dB residue read under the tile law would hold a tile waterfall for about 140 rows at the default (probe against this header; not a test, the widget has no seam). Fail-before, test compiled alone (48 checks): the static_assert against a literal 32 -> 1 fails (ring size); the call site with the old enum name -> 1 fails (call site). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ten9876
left a comment
There was a problem hiding this comment.
Issue fit
Yes, on the three families it targets, with one gap. There is no Fixes line (Refs #5678, Refs #277); a bug fix with a clear root cause needs no RFC (GOVERNANCE.md). The root cause holds. RadioModel::onBackendSpectrumFrame (src/models/RadioModel.cpp:6659-6706) reuses the backend's dB pan frame as the waterfall row. baseline > 0.0f is therefore false on every row, and NB Blank does nothing on HL2, ANAN and RTL-SDR. The fix keys the row unit on m_panBinsAbsolute. That flag picks out exactly those three families, but it does not pick out every backend whose rows are dB. See maintainer flag 1.
This review builds on @aethersdr-agent's review. I did not repeat what it reproduced. I did the three things it could not: built the branch, drove it through the bridge, and modelled cross-radio switches of the blanker ring.
Scope
| File | What it changes | Claimed? | Verdict |
|---|---|---|---|
src/gui/WaterfallImpulseBlanker.h (new) |
Pure decide(). The tile law is unchanged; a dB-margin law is added |
Yes | In scope |
src/gui/SpectrumWidget.cpp:8640-8678 |
+1 include. The inline test becomes one call, and the two ring writes become one | Yes | In scope. The tile path is unchanged (see below) |
tests/tests.cmake:3370-3381 |
Registers waterfall_impulse_blanker_test |
Yes | In scope |
tests/waterfall_impulse_blanker_test.cpp (new) |
47 checks, no Qt, no sockets | Yes | In scope |
Everything in the diff is explained by the stated fix. There is no new public, protocol or settings surface, no default change and no CHANGELOG.md entry. Commits 2891b138 (10-01) and 83c367b8 (10-03) are the fix and a comment trim, and the other two are merges from main. The test opens no socket (I read it: no QTcp/QUdp/QLocal/bind(/listen(/connectToHost).
Blockers
None.
Needs a maintainer decision
-
The predicate misses two of the five backends whose rows are dB. On an Icom (for example an IC-7300), the title's claim "NB Blank works on a dBm waterfall row" is still false. #6115 has the same gap, so between them the bug is fixed on the same three families twice.
WaterfallImpulseBlanker.h:7-10andSpectrumWidget.cpp:8646treatpanBinsAbsolute()as "this row is dB". That capability answers a different question: "do the bins hold still while the reference level moves" (RadioCapabilities.h:240-247, and the table atdocs/architecture/radio-capabilities-map.md:523-527). Every backend that feedsonBackendSpectrumFrameproduces dB rows:- Icom:
IcomCivBackend.cpp:1730,spectrumFrameReady(0, floatBytes(toDbm(...))) - the demo:
SimSignalSource.cpp:137,kFloorDbm = -120.0 - HL2, ANAN and RTL-SDR
Only the last three declare
binsAbsolute(Hl2Backend.cpp:1767,AnanBackend.cpp:386,RtlSdrBackend.cpp:117). Icom and the demo therefore still take the tile branch with a negative baseline, and NB Blank stays dead on both. The body says so ("Icom and the demo backend leave the flag false and keep the tile test") but presents it as intended. It is the same defect. I ran the shipped header against demo-shaped rows: a ring of -120 dB rows followed by a +40 dB step, throughRowKind::TileIntensity, gives 0 blanked rows in 100 at 1.05, 1.15, 1.50 and 1.95.#6115 (
WaterfallLevelMap, manual Black Level) uses the same predicate and writes the premise into canon: "A backend with absolute bins has no waterfall plane of its own". That is true, but the converse is not, and the converse is what the code relies on. After both PRs, the Black Level slider and NB Blank on Icom and the demo stay broken in the same way.The partition that matches the defect is "this row came through the backend seam, not a Flex tile". Options:
- (a) A separate declared row-unit capability, or a flag set at the place where
onBackendSpectrumFrameemits the row, used by both PRs. - (b) Declare
binsAbsoluteon Icom and the demo, if their bins are in fact absolute. That has to be decided per radio, because it also opens the auto-floor gate. - (c) Accept the three families now and open a follow-up that names Icom and the demo.
Whichever is chosen, #6115 and #6117 should make the same choice. I am not treating this as a blocker: nothing regresses, and the author disclosed the omission.
- Icom:
-
The user-visible change on HL2, ANAN and RTL-SDR is a held stripe on any sustained level step. The body's own stimulus is an LNA step. That step passed straight through before; with this PR it holds for 75 rows (about 3 s) at the default and 108 rows at 1.05. The existing review measured the same thing. A Flex already behaves this way, so this is probably correct. It is still the first thing an operator will notice, so it should be ruled on deliberately, together with
kDbPerThresholdUnit = 100(WaterfallImpulseBlanker.h:33).
Overlap with #6115
- No textual conflict.
git merge-tree --write-tree 63a73a35 a54dff9cis clean (exit 0), and so is a merge of this head with currentmain(579efd73). - The same root cause is fixed in two halves with matching shapes. Each PR adds a header extracted from
SpectrumWidget, keys on the samem_panBinsAbsoluteand adds a whitespace-collapsed source-text pin. The code is consistent: both branch on the same member, so they cannot disagree about a given radio. Point 1 above applies equally to both. - Naming has drifted between the two. After review, #6115 renamed its parameter to
rowsAreAbsoluteDb, because its body says the rows are "dBFS under a dBm label on all three families". This PR still names the same factRowKind::Dbm(WaterfallImpulseBlanker.h:16), and its comments say "dBm row" throughout. One name for one fact across both headers would help, for exampleRowKind::AbsoluteDb. - Docs. #6115 adds "It has a second consumer: the unit of a waterfall row" to
radio-capabilities-map.md, and that paragraph names only the Black Level. Once both PRs merge, NB Blank is a third consumer and the doc does not mention it. The fix is one sentence in whichever PR lands second, or one PR carries the paragraph for both. The existing review'sradio-capabilities-map.md:529nit becomes this point once #6115 is in.
Nits (non-blocking)
- Correction to the existing review: "self-heals within 32 rows" is wrong for HL2→Flex, though in practice the case cannot happen. I ran the shipped
decide()on a ring holding 32 rows at -147 followed by Flex tiles at 108. The baseline crosses zero at row 19, and from then on every tile row is an "impulse" capped atbaseline * 1.05. The result is 169 rows blanked at 1.05 and 138 at 1.15 (about 5.5 s). Flex→HL2 blanks 0. The same arithmetic applies tomaintoday, becausemainwrites HL2 row means into the ring too, so this PR does not introduce it. In a real radio swap,reprojectWaterfall(SpectrumWidget.cpp:7354) resets the ring on any change of centre or bandwidth, which is why I say it cannot happen in practice. Now that the ring's unit depends onm_panBinsAbsolute,setPanBinsAbsolute(SpectrumWidget.h:362) is the natural place to make that reset unconditional:void setPanBinsAbsolute(bool on) { if (m_panBinsAbsolute != on) resetWfBlankerState(); // the ring's unit follows this flag m_panBinsAbsolute = on; }
- The
Ringin the test hardcodeskN = 32(tests/waterfall_impulse_blanker_test.cpp:37), and nothing ties it toSpectrumWidget::WF_BLANKER_N. This was already raised; I agree with it. - The tooltip is backwards, and was before this PR.
SpectrumOverlayMenu.cpp:2397says "Higher values blank more aggressively". Under both laws a higher threshold blanks less. This PR is the first place the slider's meaning is written down, so it is a cheap moment to fix the sentence.
What I verified and what I tried to break
- Built the head (
63a73a35, Linux, Qt 6 / GCC, RelWithDebInfo) and ranwaterfall_impulse_blanker_test: 47 checks, 0 failed. The existing review already reproduced the mutation table, so I did not run it again. - Bridge session on the demo (
DEMO-0001). I confirmedget radioreturned"model":"AetherSDR Demo","family":"sim". The session used isolated settings, offscreen,NO_TX, and my own absolute socket. I opened the Display panel and setdisplayWfBlankerThreshSlider=5anddisplayWfBlankerBtnon, andget displayread backwfBlankerEnabled:true, wfBlankerThreshold:1.05. Both values were still there after a relaunch. - I could not grab the waterfall. The GPU renderer cannot start offscreen:
get rhireturned"rendererFailed":trueandgrab SpectrumWidgetreturned"grab produced an empty image".AETHER_NO_GPU=1failed the same way offscreen. A waterfall drawn on the CPU needs-DAETHER_GPU_SPECTRUM=OFF, which is a full rebuild I did not do. So "Flex and demo unchanged" is proven from the code, not from pixels. The demo leaves the flag false, so it runs the byte-identical tile branch, wherebaseline > 0.0fis false on its -120 dB rows, exactly as onmain. The PR's 121 176-case equivalence grid covers the Flex tile path, and I re-read the full ring-write path (SpectrumWidget.cpp:8625-8681): the value is the same and so is the position. The demo goes through the backend seam, so this is not a Flex-wire proof either way. - Cross-radio ring residue (nit 1), modelled on the shipped header in both directions and at four thresholds.
- Automated pass (
/code-review, medium) found exactly one issue, the same Icom/demo predicate gap as maintainer flag 1. It arrived there independently. It also confirmed that HL2 bins cannot be -inf or NaN, becauseHl2Spectrum.cppadds a 1e-24 floor before the log, and that the ring-unit mixing on a capability flip already happens onmain. - Threshold floor.
setWfBlankerThresholdclamps to[1.05, 2.0], so the dBm margin can never reach 0 and freeze the ring. This holds. - CI is green on
63a73a35, but the earliest run was created at 17:04Z, andmainhas since moved to579efd73(19:25Z, 6 commits). None of those commits touchSpectrumWidget.cpportests.cmake, and the merge with currentmainis clean. So the green is very probably still accurate, but it was not produced against currentmain.
Recommendation: approve with nits. No blockers, so this review is a COMMENT. It is correct for the three families it claims, and the author disclosed what it leaves out. Two calls are for the maintainer: (1) the scope of the predicate, meaning fix Icom and the demo here or in a follow-up, with #6115 following the same decision; (2) the new held stripe on a sustained step, together with kDbPerThresholdUnit.
Maintainer review follow-up on aethersdr#6117. setPanBinsAbsolute empties the blanker ring when the flag changes, as the review proposes: the ring's unit follows the flag. Absolute-dB residue read under the tile law turns the mean small and positive, so every tile row is an impulse: 138 rows held at the default, now a check. Tile residue under the dB law holds none. A disconnect already empties the ring through clearDisplay; this makes it hold for the flag itself. A source-text check pins the setter. The NB Blank threshold tooltip said higher values blank more. Under both laws a higher threshold blanks less; the tooltip now says so. Fail-before, test compiled alone (52 checks): the setter without the reset -> 1 fails (kind change: setPanBinsAbsolute empties the ring). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Bad news first: the Icom and demo gap is real and is not fixed here. Predicate (flag 1), yours. Confirmed:
(a) is the one that matches the defect; we build it on your word. Done in
Yours: the ~3 s hold on a sustained step, with The a11y scan now covers two more files: six existing warnings, none on a changed line. 73, ON8ST |
|
Maintainer ruling on #6115 and #6117: merge both as they stand. They're correct for HL2, ANAN and RTL-SDR, and the bodies now say Icom and the demo aren't covered. For the gap, I agree with you: option (a), a row-unit signal set where 73, Jeremy KK7GWY |
|
We're updating this branch against main and landing it (maintainer pass); please don't push to it meanwhile. New commits go on top, with no rebase or force-push. |
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Maintainer pass. Answers to the two reviews' points (Jeremy's review 5402519886 and aethersdr-agent's 5402028463, both body-only). The head is Needs a maintainer decision
Overlap, docs and nits: all done by on8st, verified at
Verification on Linux/GCC (Qt 6, RelWithDebInfo) at
Maintainer pass by Claude for @ten9876. |
ten9876
left a comment
There was a problem hiding this comment.
Approving at eeb52ce89 (maintainer pass, pr-land).
Maintainer ruling (2026-10-03): merge as it stands. It is correct for HL2, ANAN and RTL-SDR. Icom and the demo are not covered, and the body says so. Option (a), a row-unit signal emitted from onBackendSpectrumFrame, follows as on8st's own PR. kDbPerThresholdUnit = 100 and the sustained-step hold stay as they are.
Review findings, all fixed by on8st and verified here:
RowKind::AbsoluteDbnaming:efcbb06b.kRingRowsplus the widgetstatic_assert:efcbb06b. The full app build compiles with it.- Ring emptied on a row-kind change:
b75b8356. A mutation proves the test catches its removal: 52 checks, 1 failed. - Tooltip "Higher values blank less":
efcbb06b. radio-capabilities-map.mdnames NB Blank's use of the flag.- Comment on the source-text pin.
This pass: a signed merge of main 5fae2f863 (no conflicts) at eeb52ce89. All 6 threads were answered with evidence and resolved. Both body-only reviews were answered in a top-level comment.
Verified empirically (Linux/GCC, RelWithDebInfo, at eeb52ce89):
- Full build is clean.
waterfall_impulse_blanker_test: 52 checks, 0 failed.- ctest excluding socket-owning tests: 480 of 480 passed. 124 were excluded by name or by a source grep.
- All
Static checksscripts exit 0, and the colour ratchet is +0.
Reasoned, not observed: the in-app waterfall on HL2, ANAN and RTL-SDR. There was no radio, and the GPU waterfall can't be grabbed offscreen.
Freshness: the CI run on this head was created at 2026-10-03T23:08:54Z. main's tip 5fae2f863 dates from 22:49:15Z and is in the merged tree, so there is no drift.
|
Freshness note added after the approval. |
…ite 2. Principle III. (#6140) **Update, `d60f4660` (2026-10-05):** `main` `12df6943` merged in; one conflict, in `docs/architecture/radio-capabilities-map.md`, both sides kept. No code of this PR changed. **Update, `dabf83e3` (2026-10-05):** one fix on top of the reviewed head: the 3D floor resync after a zoom is no longer adopted or stored as the operator's range. Maintainer ruling of 2026-10-05: this PR owns `dbmRanges[pan]`, its helpers and its accepted range; #6129 rebases onto it. Line references below are at `7a5ca748`; `MainWindow_Wiring.cpp` gains four lines after `:4269` since. **Update after review, `7a5ca748` (2026-10-05).** The change request is applied, and it narrows this PR: - **Only the Hermes-Lite 2 is covered now.** Storing and restoring FFT FPS and the dBm range is gated on an owner the backend declares, not on backend identity. The HL2 declares both. Icom, ANAN, RTL-SDR and the demo backend declare none and behave as on `main`. A demo run that showed FFT FPS restored at `295bff6e` will, by the code, not reproduce at this head; that was not run. - **#6129 (open) adds the same `dbmRanges[pan] = {min, max}` table** with a different accept-set (`min < max`, -250..100), a different gate (`!radioOwnsDbmScale`: ANAN, Icom) and a restore into the widget instead of the pan model. With the HL2-only opt-in no radio is claimed by both PRs any more. The two accept-sets still differ and this PR adopts whichever the maintainer rules. A trial merge of `7a5ca748` with #6129 at `04762258` conflicts in `MainWindow_Wiring.cpp` and the two touchpoint files; `ClientDisplaySettings.h` merges as text and then holds two definitions of `DbmRange`, `dbmRange()` and `saveDbmRange()`. The comparison is in the review reply. - **A stored dBm value is validated before it is narrowed to a float** (NaN, the infinities and ±1e308 are refused through the real store and reader). - The source-text pins match whole call statements. Refs #5678 (HL2 parity; rows P16, the dBm range, and the FPS half of P7 in our status table). No issue exists for this. #5111 (the Flex side) is not touched. ## Limits, first - **Where it is stored, since you ruled on that for #6119:** in the radio's settings document, not a flat key. Two optional tables, `fftFps[pan]` and `dbmRanges[pan] = {min, max}`, in the existing per-radio `ClientDisplay` feature document that already holds `waterfallRates`. No schema bump. - **Who may store and restore is declared per feature, by the backend.** FFT FPS: a new optional record, `PanFrameRateShaping`, field `clientPersistsFrameRate`, read through `RadioCapabilities::clientPersistsPanFrameRate()`. The dBm range: a new field in the existing `PanAmplitudeModel`, `clientPersistsDbmRange`, read through `RadioCapabilities::clientPersistsDbmRange()`. Same shape as `BackendPanAveraging::clientPersistsAveraging` (#5924). No new top-level capability bool: the count stays at the frozen 70. - **Two second conditions remain, and you may want them changed.** FFT FPS also needs `RadioModel::shapesDisplayRatesLocally()`, as the averaging path does: the restore sits where the local shaper is seeded. The range also needs absolute bins, inside its accessor: the client writes the range into the pan model, which is wrong on a radio whose bins are scaled by it or whose backend publishes its own range. Neither condition grants anything without the declaration. - **The waterfall-rate gate on `main` is not touched.** It still reads `shapesDisplayRatesLocally()` alone. - **Half of what was observed.** FFT AVG and Wt Avg are not fixed on the HL2. #5924 gave them a table in this document (`fftAverages`), kept only for a backend that declares `clientPersistsAveraging`; the HL2 declares it false. - **Not seen in a running app at this head, on any radio.** The defect was (HL2, 2026-10-01, 2 of 2 restarts: FPS 12 came back 25; a 120 dB scale came back -40 / 90 dB). - **It changes what the pan model holds, on an HL2 only:** an operator's range is now written into `PanadapterModel`. That range is absolute dB (dBFS under a dBm label). - **Families:** the Hermes-Lite 2 only, for both settings. It is the radio the defect was observed on, and it stores nothing itself. Flex, Icom, ANAN, RTL-SDR and the demo backend declare no owner: nothing is stored or restored for them, as on `main`. Widening to another family is one declaration in that family's backend, for its owner or the maintainer to make; #6129 claims the ANAN side of the range. - **Yours to move:** the FPS bounds 5..60 (the slider's own) and the plausibility bounds, moved unchanged from `MainWindow_Wiring.cpp:168` on `main` to `core/DbmRangePlausibility.h`. ## What was wrong on `main` A Flex stores both and reports them on connect. A radio with no display engine reports nothing (observed on the HL2, and fixed here for the HL2 only; line references are at `7a5ca748`): - **FFT FPS** stays at the widget default (`SpectrumWidget.h:1666`, 25) and pan wiring seeds the shaper from the widget (`MainWindow_Wiring.cpp:3320`). - **The dBm range:** pan wiring primes the widget from the model (`MainWindow_Wiring.cpp:4074`, `MainWindow_Session.cpp:1855`), the model starts at -130 / -40 (`PanadapterModel.h:279`), and on a radio that does not own the scale nothing ever moves it: on an HL2 the range command is attempted and dropped at the command plane (the HL2 leaves `radioOwnsDbmScale` at its default `true`, `RadioCapabilities.h:439`, so the gate at `MainWindow_Wiring.cpp:3779` does not stop it), and nothing echoes. - Of a pan's display settings only the waterfall rate (`core/ClientDisplaySettings.h:90-132`) and, since #5924, FFT averaging for a backend that declares it (`:48-88`) have a client store. ## The change - **FFT FPS**, where the backend declares the client its owner, is saved by the Display slider (before the adaptive-throttle return), Clone to all Pans and Reset to Defaults, and restored into the widget before the shaper is seeded from it. - **The dBm range**, where the backend declares the client its owner, is saved on a scale request or a finished drag, and restored into the pan model before the widget is primed from it, at both prime sites. FFT Floor Auto's own moves are not stored. - The pending-write key gains the field. It was (radio, pan), so two fields of one pan edited inside the 250 ms window replaced each other. - `dbm_range_plausibility_test` now includes the real predicate instead of a hand copy. One row added to the touchpoint manifest (240 with `main` at `12df6943` merged in). - A Flex is unchanged, and so are Icom, ANAN, RTL-SDR and the demo backend: none declares an owner, so every new reader returns nothing and every new writer returns before the store. Save and restore of each setting ask one predicate (`MainWindow::clientPersistsFftFps()`, `MainWindow::clientOwnsPanDbmRange()`), each reading one `RadioCapabilities` accessor. - `docs/architecture/radio-capabilities-map.md` has the new record and field with each family's value. ## Tests `client_display_pan_settings_test` (new, socket-free, real store): round trip per radio and pan; the Flex-shaped scope; ownership of both settings read through the two accessors off real HL2, Icom, ANAN and Flex backends (the HL2 stores and restores; the others and an undeclared backend write nothing, read nothing and leave a held document byte for byte); refusals on write and read; a newer schema; coexistence with an `fftAverages` table in #5924's shape; two fields in one deferral window. `spectrum_overlay_fft_fps_bounds_test` (new, real widget, in a `TestSettingsProfile`): the slider runs the stored bounds. **One source-text pin block, and its claim:** the order and presence of the save and restore calls inside `MainWindow` methods, which link into no test, and that the save and the restore of each setting ask the one predicate. Fail-before, one build and one run per row. The two ownership rows are at `7a5ca748`; the other rows were measured at `295bff6e` and not run again: | mutation | failing checks | |---|---| | this branch | 0 | | `main`'s wiring, and a store that neither reads nor writes the new tables | 17 | | pan wiring does not restore FFT FPS | 1 (source text) | | the store ignores who owns the value on read | 1 | | the pending-write key has no field | 2 | | both ownership accessors return true for every backend | 33 (8 each for Icom, ANAN, Flex and an undeclared backend, and the relative-bins check) | | both ownership accessors return false for every backend | 8 (7 HL2, and the absolute-bins control) | | a stored range is not validated on read | 1 | | a scale request does not adopt the range | 1 (source text) | | the writer replaces the whole document | 6, `fftAverages` and the waterfall rate among them | | the stored FPS maximum is 61 | 1 (the slider bounds test) | Full ctest, macOS, Qt 6.12.0, at `d60f4660` (`main` `12df6943` merged in, no code of this PR changed): 645 of 646 pass at `-j8`; the one failure is `peripheral_auth_dialog_test` (#6008), which also fails alone. The run before it, at `7a5ca748` (`main` `85f2a124` merged in): 644 of 646 pass at `-j8`. Two failures, neither from this PR: `peripheral_auth_dialog_test` (#6008; diagnostic on #6134), which also fails alone, and `vkamp_connection_test` (#5254), which passed when rerun alone. `hl2_receiver_count_restart_paced_test` and `libre_radar_test` passed at `-j8` in this run. Static checks (`static-checks.yml`, stdin from `/dev/null`): the script steps of the `Static checks` job pass, including the touchpoint manifest (240, up to date) and `check_capability_records.py --strict` (70 booleans, at the frozen 70); colour ratchet +0. `check_a11y` on the three changed GUI files: no findings. The `Sanitizer option configures` job was not run locally. ## With the open PRs - **#5924** (merged, and merged in here at `295bff6e`): it conflicted in `ClientDisplaySettings.h`, `MainWindow.h` and `MainWindow_Wiring.cpp`, all resolved by keeping both sides. The tables do not overlap and its pending-write key has its own prefix. - **#5919** (merged) is in this branch through `main`. - #6117 and #6115 (both merged) are in this branch. - **#6129** (open, head `04762258`): see the update block at the top. - **#6139** (open, ours): both PRs add to `RadioCapabilities.h` and to each backend's `capabilities()`, in different places. A trial merge of `7a5ca748` with its head `98be49a1` is clean; the merged tree was not compiled. ## Not verified - The restore in a running app, on any radio, at this head. - The older rows of the fail-before table at this head (see Tests). - Linux and Windows: built and tested on macOS only. - The 3D view in a running app. Its floor resync after a zoom is excluded from the adopt and the store by a guard (`dabf83e3`) that is pinned as source text only; the path was not driven with frames. Reset to Defaults does not reset the dBm range; it did not before. **Known red on `main`:** `peripheral_auth_dialog_test` (#6008) fails on this Mac at this head, as it did on clean `main` at `22507580` ("Clear code or Status is clipped at the default dialog size": a serial-port name on this host widens the page; diagnostic on #6134). Clean `main` at `85f2a124` was not built here. `hl2_pan_create_async_test` passed; `vkamp_connection_test` (#5254) failed at `-j8` and passed alone. 73, ON8ST 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…I. (#6139) **Update after review, `ea2cf144` (2026-10-05):** ownership of the flag is now declared, not inferred. Only the Hermes-Lite 2 declares the client its owner (`RadioCapabilities::txWaterfallClientFlag`, `clientPersistsFlag` true). The demo, ANAN, Icom and RTL-SDR declare nothing and keep `main`'s behaviour: the toggle sends the wire text, the model stays false, nothing is stored or restored. That is narrower than this PR was. On the demo, `txwaterfall on` answers with the radio-echo note again. Full suite at this head: 644 of 645; the one failure is `peripheral_auth_dialog_test` (#6008), which fails on clean `main` too. Source line numbers below are `main` at `85f2a124`. Refs #5678 (HL2 parity; "Show TX in Waterfall", row P30 of our status table). No issue exists for this. ## Limits, first - **Nothing was keyed.** On an HL2 the toggle now sets the flag. What the waterfall draws during an over on an HL2 has not been seen, and what that radio's receive path reads while it transmits has not been measured. A `true` model flag shows neither the colour law nor real receive IQ while keyed; the demo cannot key at all. - **The fix has not been seen in a running app.** The dead toggle was (2026-10-01, bridge verb, 6 of 6 polls false). The fix is tested socket-free on the real models and dialog. - **It reaches the Hermes-Lite 2 only.** The request, the store and the restore are gated on one accessor, `RadioCapabilities::clientPersistsShowTxInWaterfall()`, which reads a declared per-feature record. Flex, Icom, ANAN, RTL-SDR and the demo leave the record absent. On Icom, ANAN, RTL-SDR and the demo the toggle is as dead as on `main`. Engaging another family is one declaration, for that family's owner or the maintainer to make. - **Yours to move:** the keyed rows take the colour law a Flex's keyed rows use (`dbmToWaterfallLevel`). On an HL2 they are absolute dB (dBFS under a dBm label); whether the law suits them is unknown until someone keys one. - **Persistence is in the radio's settings document**, not a flat key: one optional bool, `showTxInWaterfall`, in the existing per-radio `ClientDisplay` feature document. No schema bump. Written and read only where the backend declares the client its owner. ## What was wrong on `main` On a Flex the flag is the radio's: `transmit set show_tx_in_waterfall=` tells it, and its transmit status echoes it (`FlexBackend.cpp:1193`). That echo is the only writer of `TransmitModel::showTxInWaterfall()`. - The Radio Setup button sends the wire text unconditionally (`RadioSetupDialog.cpp:2717`), and so does the bridge verb (`AutomationServer.cpp:3599`). - A radio with no command plane drops it (`RadioModel.cpp:9764`) and nothing echoes, so the flag can never become true. The demo has a command plane and no handler for the flag, so nothing echoes there either. - While keyed, the pane holding the TX passband draws rows only when the flag is true (`SpectrumWidget.cpp:8488`). With it false that pane's waterfall stands still for the whole over. There is something to draw: the HL2 mutes its demodulator while keyed and the spectrum keeps running on real IQ (`Hl2RxDsp::setAudioMuted`). ## The change - `RadioModel::requestLocalShowTxInWaterfall(bool)`, the shape of the existing `requestLocalPanWeightedAverage`: where the backend declares the client the owner of the flag it applies the flag and returns true; everywhere else it returns false and the caller sends the wire text, which only a Flex answers. No raw command is added above the seam (`check_command_plane`: 400, baseline 400). - It writes the model through `TransmitModel::applyChanges`, the path an echo takes. - It stores the value per radio, on the same declaration. A disconnect clears the transmit model (`TransmitModel.cpp:43`) and no status puts the flag back, so `RadioModel::onConnected()` restores it, on the same declaration. - The Radio Setup button and the `txwaterfall` verb ask the model first. - `RadioCapabilities::txWaterfallClientFlag`: a new optional per-feature record with one field, `clientPersistsFlag` (no default). Engaged by the HL2 only. No top-level capability boolean is added (`check_capability_records`: 70 of 70). `SpectrumWidget` is not changed. Default unchanged: off. ## Tests `show_tx_in_waterfall_client_flag_test` (new, socket-free, real backends): on the real `Hl2Backend`, request, store per radio, restore on connect, and the bridge verb; on the real Icom, ANAN, RTL-SDR and demo backends, each with a `ClientDisplay` document that already holds the flag, nothing is restored, the request declines and the document is untouched; the same on a real `FlexBackend` (the echo still works) and on a backend that engages the record with the field false; the bridge verb's radio-echo note on Flex and on the demo; the store's refusals. `radio_setup_show_tx_waterfall_test` (new, production dialog, real backends): on an HL2 a click sets the model and sends no wire text; on an Icom, which declares no owner, and on a Flex the click sends the wire text and the model is not set. No source-text pin. Fail-before, one build and one run per row, failing checks in the two tests: | mutation | client flag test | dialog test | |---|---|---| | this branch | 0 | 0 | | `main`'s behaviour (request declines, connect restores nothing) | 11 | 1 slot | | the request does not write the model | 5 | 1 slot | | the connect edge does not restore | 1 | 0 | | the request is taken on a Flex too | 4 | 1 slot (Flex) | | the request does not store | 3 | 0 | | the button sends the wire text unguarded | 0 | 1 slot | | the bridge verb skips the local route | 2 | 0 | | the store ignores who owns the flag on read | 1 | 0 | | the ownership accessor is true for every backend | 40 | 2 slots | | the ownership accessor is false for every backend | 15 | 1 slot | The two accessor rows were measured at `ea2cf144`, one build and one full run each. The other rows were measured at earlier heads, before the opt-out checks existed, and were not re-run. Full ctest, macOS, Qt 6.12.0, at `ea2cf144`: 644 of 645 pass. The one failure is not ours; see below. Static checks (`static-checks.yml`, stdin from `/dev/null`): clean. `check_a11y` on `RadioSetupDialog.cpp`: 9 warnings, the same 9 as `main`, none on lines this PR touches. `check_capability_records`: 70 booleans, frozen at 70. The steps were run from this branch's `static-checks.yml`; `main`'s newer `check_build_options` steps are not on this branch and were not run. ## With the open PRs - #5924, #6117 and #6115 are merged and in this branch. #5919 is merged into `main` and is not in this branch. - #6140 (open, `7a5ca748`) adds its own records to `RadioCapabilities.h`; a trial merge with this head is clean and the merged header still counts 70 booleans. A trial merge with `main` `85f2a124` is clean. Neither merge result was built. ## Not verified - Anything keyed; see Limits. - Linux and Windows at this head. ANAN, Icom and RTL-SDR on a radio: they keep `main`'s behaviour by declaration, checked on their real backends, never connected. - A running app at this head, the demo included. - That the main window hands a restored flag to the spectrum widget. **Known red on `main`:** `peripheral_auth_dialog_test` (#6008) fails on this Mac at `ea2cf144` with the diagnostic it prints on clean `main` at `22507580` (pasted on #6134): "Clear code or Status is clipped at the default dialog size", `dialog 960x680, viewport 658, page minimum 784, horizontal scroll max 126` (a serial-port name on this host widens the page). Clean `main` was not re-run at `85f2a124`. `hl2_pan_create_async_test` and `vkamp_connection_test` passed here. 73, ON8ST 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Robbie Foust <rfoust@gmail.com>
Update after review,
efcbb06bandb75b8356(full suite 611 of 611):IcomCivBackend.cpp:1730,SimSignalSource.cpp:137) but they declare nobinsAbsolute, so NB Blank stays dead there. The predicate is the maintainer's to choose; the options are in the review reply.RowKind::AbsoluteDb. Read "dBm row" below as "absolute dB row".Summary
Refs #5678 (the HL2 parity rows). Refs #277 (the feature).
Read first.
kDbPerThresholdUnit = 100).RadioCapabilities::panBinsAbsolute(), which HL2, ANAN and RTL-SDR declare. Only the HL2 was observed.SpectrumWidget.cpp. It names its claim; if you would rather not carry it, it is four checks at the end of one file.On a Hermes-Lite 2 the waterfall's NB Blank is enabled, toggles, persists and does nothing.
Observed (the defect, before the change)
HL2 on a dummy load, receive only, 2026-10-01, on a local integration build over
25da98de(Qt 6.12.0). The floor was stepped up 35.0 to 36.8 dB with the LNA gain and the waterfall grabbed 2.00 s later; three cycles each.get displayconfirmedwfBlankerEnabledand the threshold on every leg.At 25 rows/s, 2.00 s is 50 rows, one px a row: the step is in the waterfall with no delay on 9 of 9 legs. On equals Off. There was no positive control: no working blanker was seen on any radio in that run.
Cause
SpectrumWidget::updateWaterfallRowreplaced a row whenA Flex tile is a positive intensity (about 96..120), so that is a ratio test. A radio whose spectrum is computed on this host has no waterfall plane:
RadioModel::onBackendSpectrumFramehands the pan frame on as the row. That frame is dBm, every bin negative, sobaseline > 0.0fis false on every row.Dropping the guard is not the fix. With a baseline of -147 and the default 1.15,
baseline * thresholdis -169; every ordinary row is above that, and the waterfall would stand still.The change
src/gui/WaterfallImpulseBlanker.h(new, no Qt):decide(kind, historyRows, baseline, rowMean, threshold)returns whether the row is an impulse and what it adds to the ring.SpectrumWidget.cpp: the inline test and the two ring writes become one call. The row kind ism_panBinsAbsolute, the declared capability the widget already holds for the auto-floor gate. It is not guessed from the sign of the data.historyRows >= 8androwMean - baseline > (threshold - 1) * 100 dB. A rejected row enters the ring at most 5 dB over the baseline (the tile cap through the same mapping). A baseline that is not finite is never an impulse: one row mean of -inf in the ring would otherwise make every later row an impulse and write -inf back.The design choice: what the threshold means in dB
The control is a slider 5..95 that becomes
1 + v/100, default 1.15. This PR reads it as one dB per slider step: 5 dB at the lowest setting, 15 dB at the default, 95 dB at the top.On a tile,
mean > baseline * tmeans a margin of(t - 1)times the tile's own level, about 96..120, so one slider step is about one intensity unit. A dBm row cannot use its own level, because the zero of a dBm scale is arbitrary. So the margin uses a fixed 100. One dB is taken as one intensity unit because the widget already colours both row kinds with one range width in the row's own unit (120 - 0.91 * gain). What one tile unit is worth in dB is not measured here.(t - 1) * 100(this PR)10 * log10(t)(t - 1) * abs(baseline)Another law is one constant or one line in
dbMargin.Tests
waterfall_impulse_blanker_test(new, no Qt, 47 checks):SpectrumWidget.cpptakes the row kind fromm_panBinsAbsolute, writes the helper's value to the ring, and keeps no inline ratio test. No behavioural seam reaches it:SpectrumWidgetis compiled only into the application, and no test target links it. It shows how the call is written, not that the widget runs.Red before green, re-run at this head. The helper is new, so the test cannot build on
main; "the dBm branch inert" ismain's behaviour.main)10 * log10(t)as the marginMutations were run with the test compiled on its own with Apple clang.
Full ctest on macOS (Qt 6.12.0, Ninja) at
63a73a35, which containsmainatc7513e12: 100% tests passed out of 611.Every script step of the
Static checksjob instatic-checks.ymlwas run locally with stdin from/dev/nulland is clean, the colour ratchet at +0.check_a11yon the two changed GUI files: no findings.Not verified
Beside this on main
Nothing on
mainoverlaps: the branch mergesc7513e12cleanly. The manual Black Level fix for the same unit problem (branchhl2/waterfall-manual-black) reads the same flag and merges cleanly with this one. No doc change here; that branch documents the row unit.73, ON8ST
🤖 Generated with Claude Code