fix(anan): compensate the real ANAN-G2 DDC0 edge droop, in-app calibrated - #5357
fix(anan): compensate the real ANAN-G2 DDC0 edge droop, in-app calibrated#5357tropo1234 wants to merge 12 commits into
Conversation
The panadapter trace rolled off near the edges of the displayed span -- real signal/noise attenuation baked into the raw IQ samples by the Saturn FPGA's DDC0 decimation chain (two cascaded CIC decimators plus a halfband FIR, per reference/saturn/New_protocol_FPGA_Block_diagrams.pdf), not a rendering artifact. An earlier session's fix only faded the edge pixels to the background color; the operator explicitly wanted the underlying magnitude corrected. Add a "radiocert spectrum" bring-up phase and a "pan bandwidth <ksps>" bridge verb to capture live FFT frames at each of the 6 DDC0 rates, and tools/anan_droop_calibration.py to turn a bench sweep (antenna disconnected, dummy load, flat noise-floor reference) into a per-bin dB correction table per rate -- median-in-linear-power across captures, clamped at 15dB so bins near the true CIC null don't get an amplified-noise "correction" applied past the point of being physically recoverable. AnanDroopCorrection applies that table as a straight dB addition in AnanRxDsp::processIqBlock(), right after the FFT and before the existing EMA smoothing. SpectrumWidget's cosmetic edge taper shrinks from a flat 5% guess to 9%, derived from the sweep's own clamped-bin fraction (~8% at every rate) -- now covering only the genuinely unrecoverable sliver instead of the whole droop region. Bench-verified on the real ANAN-G2 with a dummy load: the trace went from a clear rolloff at 1.536 MHz span to essentially flat across the full width, with no zoom regression afterward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New, additive, and inert on its own -- nothing yet calls it. Adds the pure-math core of an in-app DDC0 edge-droop calibration sweep, mirroring Hl2FreqCal's shape: static, unit-testable methods (medianPowerCurve across captures, a central-window referenceLevel, computeCorrection's clamp), plus RadioSettingsScope-backed persistence (loadTables), plus the sweep state machine itself (phase enum + poll timer, mirroring MainWindow_SwrSweep's async-hardware-confirmation shape) and the AgcTCalibrator-style started/progress/rateSampled/finished signal vocabulary a live UI will bind to. computeCorrection's capDb defaults to 90 dB, not a smaller "safe" number picked in isolation: the sweep measures with a dummy load, so the curve it corrects IS the noise floor, and bench measurement on a real ANAN-G2 shows that floor sagging deeply at the worst edge bins. The cap exists as a guard against a corrupted measurement, not as a "past this point it's unrecoverable" line -- see the comment on computeCorrection() for the full reasoning. Bench iteration (15 -> 70 -> 90 dB) also showed the cap alone cannot fully close the gap at the true edge: the last few bins are noisy enough, run to run, that no finite cap produces a clean result -- that residual is handled by a separate cosmetic fade downstream (AnanDroopCorrection.h's applyEdgeFade()), not by chasing the cap further. Unit tests cover the outlier-rejecting median, the regression case for a floor-clamping bug this design deliberately avoids (a bin whose captures agree on a deep noise-floor value must not be floored to a higher number), the central-window reference, and the clamp's edges. Principle V. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cuts over from the compiled-table approach (a Python offline script generating AnanDroopCorrectionTables.inc) to a runtime-settable table set: AnanRxDsp::setDroopCorrectionTable(rateKsps, table) stores one DroopCorrectionTable per valid DDC0 rate in a QMap, ignoring a wrong-size table or an unrecognized rate rather than misaligning bin k against the wrong correction. processIqBlock() now looks up droopTableForRate(m_config.inputSampleRateHz / 1000) instead of the deleted compiled switch. The offline script, its test, and the generated .inc file are all removed -- AnanDroopCorrection.h/.cpp keep only the pure apply math (applyDroopCorrectionDb(), kDroopCorrectionZero), which stays exactly as tested before. Also fixes a real bug this cutover surfaces: installRebuiltChannel() (the path a live DDC0 rate change takes -- including an ordinary panadapter zoom, which snaps to a new rate and rebuilds the DSP chain in the background) swapped in the new WdspChannel/AnanSpectrum but never updated m_config.inputSampleRateHz, so the droop lookup above kept reading whatever rate was live at the last configure() forever, applying one rate's correction curve to a different rate's spectrum after every zoom past the first. RebuildResult now carries the rate buildChannel() actually built for, and installChannel() syncs m_config.inputSampleRateHz from it on every swap, not just the first configure(). Root-caused by reading the rebuild path, not by capturing a live "before" measurement at this specific rate pair -- the staleness was clear enough from the code alone (RebuildResult never carried the rate it was built for; installChannel() never wrote m_config outside the very first configure()). Bench-confirmed FIXED on a real ANAN-G2 across multiple live rate changes (48->384 ksps and 1536->96 ksps): the corrected spectrum is rate-appropriate and flat at every step, not mismatched or lumpy. anan_rxdsp_handedness_test's Group 5 now pins the insertion point via a synthetic setDroopCorrectionTable() call instead of the removed compiled lookup. New Group 6 rebuilds 48->96 ksps like a live rate change and asserts the emitted spectrum uses the 96 ksps table, not the stale 48 ksps one -- the regression test for the bug above. Principle VII. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… path Adds RadioCapabilities::hostDroopCalibration next to hostFrequencyCalibration, with the same "NOT does this radio have a droop -- what varies is whether the client has measured and can correct it" framing. Set true only in AnanBackend::capabilities(); Flex, HL2, and Sim all declare it explicitly false with a one-line reason, per this struct's own "set every field explicitly" rule. Documented in docs/architecture/radio-capabilities-map.md alongside hostFrequencyCalibration's row. AnanBackend gains a per-radio identity (m_radioSerial, set at connectRadio()) and two things that use it: a connect-time seed that loads any previously-persisted droop tables via AnanDroopCalibrator::loadTables() and pushes them to AnanRxDsp before the first spectrum frame renders, and an "anan"/"droop.apply" invokeExtension() handler that is the single place a measured table gets pushed live AND persisted via RadioSettingsScope -- never duplicated between whatever UI or bridge verb calls it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds AnanDroopCalibrator m_droopCalibrator and its droopCalibrator() accessor to RadioModel, plus a connectionStateChanged(false) -> stop() wire so a radio disconnecting mid-sweep can't leave it polling a panadapter/rate that no longer exists. Flagging this diff for review on its own, deliberately: it grows the shared RadioModel class with an object that, today, only ANAN uses -- the same shape as the existing FlexWaveformModel member, but not the lightest possible option. The alternative is owning the calibrator inside AnanBackend and reaching it through the extension seam the way Hl2FreqCal does (pure static functions, zero RadioModel footprint). That path was not taken here because invokeBackendExtension() is a synchronous, fire-and-forget call correlated only by requestId -- it cannot carry this sweep's live progress/rateSampled signals from a background bridge verb call to a UI tab watching the same sweep, which only a persistent, directly-reachable object can do. Mechanically the calibrator is family-agnostic (it drives only RadioModel::setPanBandwidth() and panFeedSpectrumReady, the same generic surface RadioCertification::stageSpectrum() already uses), and its one live consumer (RadioSetupDialog's Droop Correction tab) is gated on the hostDroopCalibration capability, so a non-ANAN backend never touches it -- but the member itself is unconditional. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
doDroopCal(action, value), registered as droopcal <status|start|stop|apply>, mirrors freqcal's shape exactly: refuses if the connected radio's capabilities don't declare hostDroopCalibration, refuses start with no radio identity yet, and reads the live RadioModel::droopCalibrator() directly for status rather than a round trip through the backend extension seam -- unlike Hl2FreqCal's state, this object is directly reachable. start/stop/apply are fire-and-forget, matching pan bandwidth's own convention (returns once issued, not once it lands); a caller polls droopcal status the same way this feature's own bench verification polled pan.bandwidthMhz. docs/automation-bridge.md regenerated via tools/gen_bridge_docs.py (66 verbs, up from 65). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
buildDroopCalibrationTab() mirrors buildCalibrationTab()'s structure exactly: same addPage()/page-index/reseed-lambda registration shape, same hostDroopCalibration-gated visibility set at registration and re-set on RadioModel::connectionStateChanged, same showEvent() reseed, same "Connect the radio first" no-identity guard. Intro text carries the antenna-disconnect instruction the sweep needs (it measures the receiver's own noise floor as a flat reference). A single Start/Stop toggle, a progress bar plus status text fed by the calibrator's started/progress/rateSampled/finished signals, and a per-rate text summary (min/max correction, sample count) rather than a live curve plot -- the simpler of the two UI options, matching AGC-T's own simpler cousin more than its full AgcCurveWidget. Bench-verified the UI mechanics directly: opened the tab, ran Start Sweep (progress bar and per-rate status text updated live through "Sweeping -- rate N of 6..."), stopped it mid-sweep with a partial 3-rate result, and Discard cleared it correctly -- confirmed via a follow-up spectrum capture that discarding left the already-good, bridge-applied table untouched. Did NOT run a full UI-driven sweep to completion and Apply for a bit-for-bit comparison against a bridge-driven run: the antenna was live at the time, and completing a UI sweep under those conditions would have overwritten the verified calibration with live-signal-biased data. That comparison is still open. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…gain
Adds applyEdgeFade() to AnanDroopCorrection.h and wires it into
AnanRxDsp::processIqBlock() right after applyDroopCorrectionDb(), for
any rate with a real (non-zero) calibration table.
The measured droop at the true edge of the span is deep enough, and
noisy enough bin to bin, that no per-bin dB correction produces a
clean result -- bench iteration on a real ANAN-G2 raised
AnanDroopCalibrator's capDb from 15 to 70 to 90 dB, and the outermost
bins were unchanged or worse from one calibration sweep to the next
at every step, because the limiting factor there is measurement noise
near the ADC's effective floor, not correction headroom. Chasing more
gain just amplifies that noise instead of recovering real signal, and
on the panadapter it showed up as a dark, unpredictable band right at
the edges of the waterfall -- sometimes there, sometimes not, frame to
frame.
applyEdgeFade() does not try to recover that data. It overwrites the
outermost ~3% of bins on each side with a deterministic raised-cosine
fade from the corrected value at the tail boundary down to a fixed
offset below it, replacing whatever noisy value the real droop plus
correction produced -- so the display always shows the same smooth
roll-off at the true edge instead of an unpredictable one. This is the
same judgment call WDSP's own Display/Analyzer API makes: SetAnalyzer's
`clp` parameter exists specifically to clip a decimation filter's
roll-off rather than display it ("It is generally not desirable to
display the roll-off area... A primary use of this capability is to
clip off those bins", WDSP_Guide Rev 2.00 Section 7.2, reference/wdsp/).
We fade instead of literally clipping bins because changing bin
count/reported bandwidth already broke zoom-out once -- see
AnanBackend::emitPanState()'s own comment.
Bench-confirmed on a real ANAN-G2: the previously visible dark band at
both panadapter/waterfall edges is now a smooth, gentle roll-off with
no hard cutoff.
anan_droop_correction_test.cpp adds direct coverage of the new
function (middle untouched, continuous at the boundary, exact fade at
the true edge, monotonic, safe no-op on a too-small array).
anan_rxdsp_handedness_test.cpp's Groups 5 and 6 -- which pin the
correction's insertion point and the rate-change regression via exact
bin-for-bin comparison -- now exclude the tail bins from their main
check and add a second check that the tail matches applyEdgeFade() run
on raw+table, so the fade's own behavior stays covered rather than
just excluded.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Issue fit
Part of #4970. The engineering core is genuinely good: a real, bench-measured DDC0 droop, corrected per-radio from an in-app sweep rather than an assumed model; the capability field's own comment ("what varies is whether the client has measured it, not whether the radio droops") is exactly the right framing; Principle V persistence via RadioSettingsScope; three socket-free tests with a regression row pinning a real bug the author found and fixed (kLog10Floor). The blockers are all in the sweep's lifecycle, not the math.
Also: the body's stacking note appears stale — anan-pr1-rx-skeleton looks like #5143, which is already main's tip commit, and this PR's 8 commits sit cleanly on it with green CI. Worth refreshing the body (and rebasing if anything drifted) so reviewers stop treating this as blocked.
Scope
All 28 files map to the feature, with one exception flagged for a maintainer call rather than waved through: RadioCertification::Phase::Spectrum and the pan bandwidth bridge verb (~130 lines of new public automation surface) exist to serve tools/anan_droop_calibration.py — which is not in the tree, and which AnanDroopCalibrator.h's own comment says was superseded by the in-app engine. Six comments across four files still point readers at the missing file. Three independent analysis passes converged: the in-app calibrator needs neither (it drives setPanBandwidth and taps panFeedSpectrumReady directly). New public verbs with zero in-tree consumers outlive the fix — recommend dropping both from this PR, or landing them separately with a real consumer.
Blockers
1. Re-running the sweep destroys a good calibration (inline). The sweep taps panFeedSpectrumReady, which carries bins after applyDroopCorrectionDb() and applyEdgeFade() — and nothing zeroes or bypasses the live tables for the sweep's duration, while connectRadio() re-seeds them every session. Second sweep → flat measured curve → near-zero tables → Apply replaces and persists zeros → the droop returns, silently. Worse at the edges: the synthetic 12 dB fade is baked into the new table as if it were hardware. "The same action twice" is the first thing an operator will do after wondering if the first sweep took. Fix: push kDroopCorrectionZero for each rate at sweep start (restore on abort), or tap a pre-correction feed.
2. The persist path violates the feature-document rules three ways (inline). A partial Apply builds the doc from only this sweep's rates and setFeature() replaces the whole document — wiping previously calibrated rates from disk while the DSP keeps them live, so the radio is correct until the next connect and then regresses with no indication (the header explicitly advertises partial sweeps as safe). The write result is discarded — the exact #4621 "worst failure shape" AGENTS.md names, while the UI reports "live and saved" (the PR's own cited precedent, Hl2FreqCal, checks and warns). And the handler never reads the existing row via featureExact() nor refuses a newer schema_version, both required by the same section. And a fourth facet: applyResult() reports success unconditionally — a radio dropped between sweep and Apply makes invokeBackendExtension a documented no-op, yet the dialog prints "Applied — the measured correction is now live and saved" and the bridge returns ok:true; the no-serial branch likewise warns and then reports success. Merge into the existing document, check the write, honor the schema guard, and thread the actual outcome back to the UI/bridge.
3. The Sampling phase has no timeout (inline). Only WaitingForRateLanded is bounded; Sampling breaks out every poll tick while m_haveLatestFrame is false, forever. A stalled feed (hidden/paused panadapter, a frame size ≠ 1024, a quiet network drop) leaves isRunning() latched — so start() and Apply are permanently no-ops — with the radio parked at the sweep's rate instead of m_originalRateKsps. Bound it like the rate-wait, and restore the original rate on the failure path.
4. Correction tables leak across radios (inline). m_dsp is constructed once and survives disconnect/reconnect; the connect-time seed only inserts, and no clear/erase path exists. Connect calibrated G2 #1, disconnect, connect G2 #2 in the same session: #2's panadapter renders with #1's per-bin corrections (plus the edge fade applied on top), with no UI indication — the Droop tab shows measuredTables(), which is empty. Clear the DSP tables on disconnect (or key the seed by serial and clear on mismatch).
5. The settings-search filter bypasses the capability gate (RadioSetupDialog.cpp:888; added after the cross-file trace completed). The per-keystroke search filter re-hides rows itself and exempts the capability-gated rows by name — the new Droop row is not in that exemption list (the write-only m_droopCalibrationPageIndex was the tell). Typing anything (or typing and clearing) un-hides "Droop Correction" on a Flex/HL2/Icom, and Start Sweep is family-agnostic: it drives setPanBandwidth through six zoom levels on a radio the correction can never apply to. Add the droop row to the gate expression.
6. "Rate landed" is inferred from a signal that lies on the failure path (AnanBackend.cpp:829 vs AnanDroopCalibrator.cpp:228). finishRateChange(ok=false)'s own comment says the old channel keeps running at the OLD rate, yet it emits pan state carrying the requested rate — and the calibrator's only landed test is that bandwidth matches the target. A failed 768 k channel build means eight frames of 384 k data are measured into the 768 k table and persisted: one rate's droop curve applied to another rate's spectrum — the exact cross-rate corruption the handedness test's Group 6 exists to prevent, reintroduced through the measurement side. Land detection needs the DSP's actual configured rate, not the requested pan state.
Nits (non-blocking, condensed)
- Layering (EB3, rule text quotable):
RadioModel.hnow includes a vendor header and holdsAnanDroopCalibratorby value — every GUI TU compiles ANAN headers, every non-ANAN session constructs the timers/buffers, and the second family with droop (HL2's chain is architecturally similar, as the caps-map row itself says) can't reuse the seam. A forward-declaredunique_ptrconstructed when the capability is true keeps the shared-observer property; the backend already speaksinvokeExtensionfor the apply half. - Captures aren't gated on new frames — the 8-sample median can be eight copies of one frame (spectrum FPS ≤ 3) or contain previous-rate frames still in flight, defeating the documented robustness; clear
m_haveLatestFrameon capture and stamp frames per rate. droopcal startreturnsok:true, running:falsewhenstart()refused (no panadapter) — the refusal is invisible to automation; the #5263 loud-drop shape.- The edge fade rewrites emitted dBm bins, not pixels — every data consumer (markers, the new
radiocert spectrumcapture that this PR documents as calibration-grade, the calibrator itself) receives fabricated values; and the renderer taper now disagrees between GPU (0.09) and software (0.05) paths on the same radio, with the software comment pointing at a comment that calls its value superseded. Pick one owner for the cosmetic fade. radiocert spectrumalso inheritsrun()'s epilogue (unkey + re-tune slice 0) though it is capture-only, and defaultsfrequencyMhzto 14.200 and silently retunes slice 0 there when the argument is omitted — the adjacent phase parse in the same PR deliberately fails closed on omitted arguments; this stage should too (the comment above it even warns about exactly this).- The DDC0 rate set is now spelled in five places (backend caps,
nearestDdc0RateKsps, calibrator, DSP validity loop, ConnectionPanel); one shared constant prevents the silently-rejected-table failure when a rate is added. - Table marshalling round-trips four representations with two hand-copied validators (
loadTablesvs the apply handler) — a symmetricsaveTables()besideloadTables()(theHl2FreqCalshape) leaves one codec. The median helper is the tree's seventh copy and the onlysort-based one; the per-framecoswindow andQMaplookup in the DSP hot path cache trivially;m_droopCalibrationPageIndexis write-only; the new settings-page widgets lack accessible names (a11y rules forsrc/gui/); the dB→linear→dB median round trip is a documented no-op that exists only to keepkLog10Floor— and that floor caused the bug the regression test pins.
What was verified vs read
- Verified by me: both sweep-lifecycle blockers (tap point, absent bypass,
setFeaturewhole-doc replace, unchecked write result), the taper's capability gating — which refuted two angles' claim that the 0.09 change affects all backends (m_edgeTaperEnabledrequireshasDdcPanEdgeRolloff, ANAN-only) — and the stale-stacking evidence (#5143 at main's tip). - From the automated pass (all 8 angles; the cross-file tracer reported after initial posting and its two blocker-tier findings were added to this review as items 5 and 6), verified before adoption; the Sampling-timeout blocker was confirmed from the quoted phase logic.
- Not run: no hardware (the author's live G2 sweep covers the happy path; every blocker is a lifecycle argument reproducible from the code), no bridge session, and the three new tests were read, not executed — CI ran them green.
| m_rateIdx = 0; | ||
|
|
||
| m_clock.start(); | ||
| m_spectrumConn = connect(m_radio, &RadioModel::panFeedSpectrumReady, |
There was a problem hiding this comment.
Blocker 1 — this tap is downstream of the correction it is trying to measure. panFeedSpectrumReady carries bins after applyDroopCorrectionDb() and applyEdgeFade(), nothing zeroes or bypasses the live tables for the sweep, and connectRadio() re-seeds them every session — so the second sweep an operator ever runs measures a flat curve, computes ~zero tables, and Apply persists them: the good calibration is silently destroyed and the droop returns (with the synthetic 12 dB edge fade baked into the new table as if it were hardware). Push kDroopCorrectionZero per rate at sweep start and restore on abort, or tap a pre-correction feed.
| "no radio identity yet; applying for this session only"); | ||
| } else if (!doc.isEmpty()) { | ||
| RadioSettingsScope(QStringLiteral("anan"), m_radioSerial) | ||
| .setFeature(QLatin1String(AnanDroopCalibrator::kFeature), |
There was a problem hiding this comment.
Blocker 2 — the persist path fails the feature-document rules four ways. (1) doc holds only this sweep's rates and setFeature() replaces the whole document — a partial Apply (which stop()'s own comment blesses as "a safe, real partial improvement") wipes the other rates from disk while the live DSP keeps them: correct until restart, then silent regression. (2) The write result is discarded — AGENTS.md's #4621 rule verbatim ("a mutation that silently doesn't persist while the UI repaints from the store is the worst failure shape… Log loudly at minimum"), and the precedent this PR cites, Hl2FreqCal, checks and warns. (3) No featureExact() read, no newer-schema_version refusal — both required by the same section. (4) applyResult() reports success unconditionally: a radio dropped before Apply makes the extension a documented no-op while the dialog prints "live and saved" and the bridge returns ok:true.
| break; | ||
|
|
||
| case Phase::Sampling: { | ||
| if (!m_haveLatestFrame) |
There was a problem hiding this comment.
Blocker 3 — Sampling has no timeout. Only WaitingForRateLanded is bounded; this branch breaks out every 200 ms tick forever if the feed stalls (hidden/paused panadapter, frame size ≠ 1024, quiet network drop). isRunning() stays latched — start() and Apply become permanent no-ops — and the radio is left parked at the sweep rate instead of m_originalRateKsps. Bound it like the rate-wait and restore the original rate on the failure path. Related, same loop: captures aren't gated on a new frame (m_haveLatestFrame is never cleared on capture), so the 8-sample median can be eight copies of one frame at low spectrum FPS, or contain the previous rate's frames.
| // just below, and before anything else in this function needs it, | ||
| // matching Hl2Backend's own m_radioSerial assignment ordering. | ||
| m_radioSerial = request.serial; | ||
| if (m_dsp) { |
There was a problem hiding this comment.
Blocker 4 — tables outlive the radio they were measured on. m_dsp is constructed once and survives disconnect/reconnect; this seed only inserts, and no clear/erase path exists. Calibrated G2 #1 → disconnect → G2 #2 in the same session: #2 renders with #1's per-bin corrections plus the edge fade on top, and the Droop tab shows nothing (it displays measuredTables(), which is empty). Clear the DSP's tables on disconnect, or key the seed by serial and clear on mismatch.
Two review findings from aethersdr#5357, both about surface that should not be there. RadioCertification::Phase::Spectrum and the `pan bandwidth` bridge verb existed to serve tools/anan_droop_calibration.py -- an offline prototype that was never in this tree and that AnanDroopCalibrator superseded before either landed. The in-app engine needs neither: it drives RadioModel::setPanBandwidth() and taps RadioModel::panFeedSpectrumReady directly, both generic RadioModel surfaces. New public automation surface with no in-tree consumer outlives the fix that motivated it, so it goes now rather than becoming a compatibility obligation nobody asked for. RadioCertification.cpp/.h return to exactly their origin/main content; what remains in AutomationServer is only the `droopcal` verb, which does have a consumer. Six comments naming the absent script are reworded -- one of them justified this class's design by pointing at stageSpectrum(), a justification that no longer exists. docs/automation-bridge.md regenerated by tools/gen_bridge_docs.py (72 verbs, unchanged count: droopcal is the one net-new verb). The settings-search filter recomputed setHidden() from the keyword match alone and exempted capability-gated rows by name -- a list the new Droop Correction row was missing from. Typing anything, or typing and then clearing, un-hid it on a Flex, HL2 or Icom, and Start Sweep is family-agnostic: it would drive setPanBandwidth through six zoom levels on a radio whose DDC0 the correction can never reach. Same failure shape the adjacent comment already describes for the HL2-only Calibration page, so the fix is the same one line in the same expression. This also gives m_droopCalibrationPageIndex its first read. Build clean (191/191). anan_droop_correction_test, anan_droop_calibrator_test, anan_rxdsp_handedness_test and anan_backend_test all pass standalone on this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`finishRateChange(ok=false)`'s own comment says the old channel and old session keep running at the OLD rate -- but the pending fields had already been overwritten with the requested one, and `emitPanState()` reports from `m_pendingDspConfig.inputSampleRateHz`. So a rate change that failed still told every consumer of pan bandwidth that it had succeeded. Principle II: the radio is authoritative on live state, and this was the seam telling the GUI a rate the radio was not running. Capture the live rate at the top of `beginRateChange()`, before the overwrite, and put it back on the failure path before the emit. The sharp edge is the calibration sweep. `AnanDroopCalibrator` decides "the rate landed" by watching pan bandwidth reach its target, so a failed 768 k channel build would have it measure eight frames of 384 k data into the 768 k correction table and persist that -- one rate's droop curve applied to another rate's spectrum, the exact cross-rate corruption `anan_rxdsp_handedness_test`'s Group 6 exists to prevent, reintroduced through the measurement side rather than the display side. Zoom math and anything else reading pan bandwidth were wrong on that path too; they just failed less visibly. Found in review of aethersdr#5357 (blocker 6). Build clean. anan_droop_correction_test, anan_droop_calibrator_test, anan_rxdsp_handedness_test and anan_backend_test all pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nciple XIV. Four review blockers from aethersdr#5357. All of them are about the sweep's lifecycle rather than its math, and they share one shape: a failure that looks like success until the next connect. BLOCKER 1 -- the sweep measured its own output. AnanDroopCalibrator taps panFeedSpectrumReady, which carries bins AFTER applyDroopCorrectionDb() and applyEdgeFade(), and nothing suspended the live tables for the sweep's duration while connectRadio() re-seeds them every session. The second sweep an operator ever ran therefore saw an already-flat curve, computed a near-zero table from it, and Apply persisted that over the good one -- with the synthetic 12 dB edge fade baked in as if it were hardware. Fixed with AnanRxDsp::setDroopCorrectionBypassed(), armed for the sweep and lifted in finishSweep() (so completion, stop() and abort all restore it), routed through a new `droop.bypass` extension verb because m_dsp lives on another thread. A flag, not "push a zero-valued table for each rate": pushing zeros stores a COPY, so droopTableForRate() would stop returning the kDroopCorrectionZero OBJECT and processIqBlock()'s `&droopTable != &kDroopCorrectionZero` identity test would still read true -- the fade would stay on and be measured as hardware droop. It is also non-destructive, so an abort or a crash mid-sweep cannot lose a calibration that was only ever hidden. BLOCKER 2 -- the persist path failed the feature-document rules four ways: it built the document from only the current sweep's rates and setFeature()'d the lot (wiping previously calibrated rates from disk while the DSP kept them live), discarded the write result (AGENTS.md's aethersdr#4621 rule verbatim), never read the row back via featureExact() nor refused a newer schema_version, and reported success unconditionally. Now AnanDroopCalibrator::saveTables() -- symmetric with loadTables(), so the float<->JSON codec and its validity rules live in one place instead of being hand-copied between reader and writer -- reads the exact row, refuses a newer schema, MERGES, checks the write, and returns the reason when it fails. AnanBackend::persistDroopTables() keeps only the identity guard it alone can make. BLOCKER 3 -- Phase::Sampling had no timeout. Only WaitingForRateLanded was bounded, so a stalled feed (hidden or paused panadapter, a frame size that is not kDroopCorrectionFftSize, a quiet network drop) span forever: isRunning() stayed latched, making start() and applyResult() permanent no-ops, with the correction bypassed and the radio parked at the sweep's rate. Bounded like the rate wait, and every exit already restores the rate. Captures are now gated on a genuinely NEW frame too -- the 8-sample median could otherwise be eight copies of one frame at a low spectrum FPS, defeating the outlier rejection medianPowerCurve() exists to provide -- and anything in hand is dropped when Sampling begins, since frames for the previous rate are still in flight then. BLOCKER 4 -- tables outlived the radio they were measured on. m_dsp is constructed once and survives disconnect/reconnect while the seed only ever inserted. Calibrated G2 aethersdr#1, disconnect, connect G2 aethersdr#2, and aethersdr#2 rendered through aethersdr#1's per-bin corrections with the Droop tab showing nothing. AnanRxDsp::clearDroopCorrectionTables(), called on disconnect and again before each connect's seed. And the outcome now reaches the operator. applyResult() correlates a real requestId against the backend's extensionResult/extensionError rather than firing and forgetting, so a radio dropped between the sweep and Apply -- which makes invokeBackendExtension() a documented no-op -- no longer prints "Applied — the measured correction is now live and saved". The dialog keeps the specific reason instead of overwriting it with generic finished() text (an aborted sweep had the same bug), `droopcal apply` returns the failure, and `droopcal start` no longer answers ok:true when start() refused for want of a panadapter. Tests: anan_rxdsp_handedness_test grows Group 7, which pins that a bypassed and a cleared table both emit bins equal to the raw FFT across the WHOLE frame, tails included -- the tails are what catch the zero-table trap, since the edge fade would survive it -- plus a negative control and a check that lifting the bypass restores the identical frame. anan_droop_calibrator_test grows four saveTables cases against a real settings store (temporary home): a partial save merges, a re-measured rate overwrites only itself, a newer schema is refused without writing anything, and empty/invalid inputs report why. Build clean. anan_droop_correction_test, anan_droop_calibrator_test, anan_rxdsp_handedness_test, anan_backend_test, radio_capability_gating_test and anan_settings_test all pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two review nits from aethersdr#5357, both small enough that deferring them cost more than doing them. THE RATE SET. The six DDC0 rates were spelled out independently in five places. anan::kDdc0RatesKsps now lives in P2Protocol.h -- socket-free, Qt-free, already home to kDspClockHz and to buildDdcSpecific(int ddc0RateKsps), and already included by the one GUI consumer -- and every site reads from it: capabilities().sampleRatesHz, nearestDdc0RateKsps(), AnanRxDsp::setDroopCorrectionTable()'s validity check, AnanDroopCalibrator's sweep, and ConnectionPanel's picker. The failure this prevents is specific and silent: add a seventh rate to the capability list and the picker but not to AnanRxDsp's check, and every correction table for that rate is rejected by a `return` with no log line. To the operator that is "the calibration didn't take", with nothing anywhere to explain it. A sixth site turned up that the review did not list -- the hardcoded 48'000.0 / 1'536'000.0 in the pan-bandwidth-limits emit, whose own comment already claimed it matched capabilities().sampleRatesHz's endpoints. It reads from the constant now, so the comment is true. ACCESSIBLE NAMES, but deliberately not uniformly: two of these widgets are made WORSE by a name, and the a11y rules for src/gui/ are about what a screen reader actually announces, not about coverage. - startStopBtn toggles its text between "Start Sweep" and "Stop". A button takes its accessible name from its text unless one is set explicitly, so a fixed name would freeze the announcement at "Start sweep" while the button reads "Stop". It gets a DESCRIPTION, which supplements the live text instead of replacing it. - statusLbl is the same trap inverted: a QLabel's accessible name IS its text, and that text is the live sweep status -- including the failure reasons the calibrator now reports. Naming it would hide exactly the content worth hearing. Description again. - progressBar has setTextVisible(false), so it had no text at all and was announced as an unlabelled progress bar. It gets a real name. - applyBtn/cancelBtn have static text, and "Apply"/"Discard" on their own say nothing about what is being applied or discarded. Names. Deliberately still open, and called out in the PR reply rather than skipped quietly: the RadioModel layering (a shared-seam change whose lazily-constructed calibrator would invalidate RadioSetupDialog's build-once lambda captures), the cosmetic edge fade's owner (a design decision needing bench confirmation), deduplicating the median helper (six call sites outside ANAN, a conflict magnet for zero behaviour change), and caching the DSP hot path (no measurement says it matters). Build clean. anan_droop_correction_test, anan_droop_calibrator_test, anan_rxdsp_handedness_test, anan_backend_test, radio_capability_gating_test and connection_panel_size_test all pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — this was a genuinely useful review. All six blockers are fixed, the scope objection is actioned, and the stale stacking note is gone. Four commits on top of the original eight. One item is still outstanding — a G2 re-run, detailed at the end of Tests. Blockers1 — the sweep measured its own output. Fixed, but not the way the review suggested, and the difference matters. Pushing 2 — the persist path. All four facets. The write half moved into The fourth facet — reporting success unconditionally — is fixed end to end. 3 — no 4 — tables outliving the radio. 5 — the search filter bypassed the capability gate. The Droop row is now in the 6 — "rate landed" inferred from a signal that lies. Fixed at the source rather than in the calibrator. ScopeDropped, as recommended. You were right about the stacking note — the branch had already been rebased onto #5143. The body is updated. PR is now 26 files / +1712 −29, down from 28 / +1799 −32. Tests
Full Hardware: re-run pending, and I'd rather flag it than let it pass. The original sweep was bench-confirmed on a real G2, but that was before these fixes, and two of them change what the sweep actually measures — blocker 1 bypasses the correction for the sweep's duration, and blocker 6 changes when a rate counts as landed. The unit tests pin the mechanisms (Group 7 proves a bypassed table returns the whole frame to the raw FFT), but they cannot prove the bypass arrives before the first capture, since it crosses to the DSP thread on a queued connection. Only the radio can show that. Three things I'm running on the bench, with the store backed up first:
I'll post the numbers as a follow-up. Happy to add to that list if there's something else you'd want to see from the bench. The Test plan checkbox for this is deliberately left unchecked until then. Nits — takenThe
Nits — not taken, with reasons
Ready for another look. |
Summary
The ANAN-G2's DDC0 CIC/half-band decimation chain has a measurable
amplitude droop toward the edges of the passband — real, bench-measured
attenuation, not a display artifact. This PR adds host-side compensation
for it, calibrated per-radio rather than assumed from a fixed model:
AnanDroopCorrection— the correction table shape/application(
kDroopCorrectionZeroas the identity/no-op table), applied insideAnanRxDsp::processIqBlock(), looked up per rate viadroopTableForRate().AnanDroopCalibrator— the in-app sweep engine that measures theactual droop on the connected radio (a live instance owned by
RadioModel, wired throughAnanBackend::connectRadio()'sloadTables()call).hostDroopCalibration— the persisted settings scope(
AnanBackend's apply path), and adroopcalautomation-bridge verbso the sweep can be driven/scripted.
RadioSetupDialog— the operator-facingUI to run the sweep and see the result.
of gain there, since fully flattening the extreme edge would mean
amplifying a region with very little real signal-to-noise margin left.
Part of #4970.
Constitution principle honored
bench-measured sweep data on real hardware (this project's own
AnanDroopCalibratorsweep), not ported from another project'scalibration table.
hostDroopCalibrationis persisted as its ownsingle nested-JSON settings object (per-radio, via
RadioSettingsScope), not flat keys.nothing here keys or drives the transmitter.
Test plan
cmake --build build -j8)sweep run, correction table loaded and applied, edge response visibly
flattened on the panadapter without the earlier over-gained edge
artifact
what the sweep measures — the correction is now bypassed for its
duration, and a failed rate change no longer reports as landed — so
the original bench run does not cover them. Two things to confirm
specifically: a second sweep reproduces the first one's table
rather than collapsing to near-zeros, and Apply after a partial
sweep leaves previously calibrated rates intact across a reconnect.
branch (now rebased onto
main, no longer stacked):anan_droop_correction_test,anan_droop_calibrator_test,anan_rxdsp_handedness_test,anan_backend_test,radio_capability_gating_test,connection_panel_size_test— allreport
all checks passed, exit 0anan_rxdsp_handedness_testGroup 7 — abypassed and a cleared table each return the emitted frame to the raw
FFT across the whole frame, tails included, which is what catches
a zero-table "bypass" leaving the edge fade running — plus four
saveTablescases inanan_droop_calibrator_testagainst a realsettings store: a partial save merges, a re-measured rate overwrites
only itself, a newer schema is refused without writing anything, and
empty/invalid inputs report why
ctest: 2 failures out of 334, both confirmed pre-existing bychecking out
202d876eand rebuilding —hl2_state_restore_test(3 CW-passband checks) and
phone_tx_filter_numeric_entry_test(aborts inside Qt's own
testlib/qasciikey.cpp). Neither isreachable from this branch.
Checklist
hostDroopCalibrationis a single nested-JSONAppSettingsobject(Principle V), not flat keys
02-working-plan.md's Step 2b entries