diff --git a/CMakeLists.txt b/CMakeLists.txt index f8c29bb7a..d5b68d502 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -626,6 +626,8 @@ set(CORE_SOURCES src/core/backends/anan/AnanDiscovery.cpp # aetherd ANAN P2 Phase 1b (P2 discovery -> picker) src/core/backends/anan/AnanSpectrum.cpp # aetherd ANAN P2 Phase 1b (FFT panadapter, FFTW) src/core/backends/anan/AnanRxDsp.cpp # aetherd ANAN P2 Phase 1b (IQ -> WdspChannel demod + spectrum) + src/core/backends/anan/AnanDroopCorrection.cpp # ANAN DDC0 droop compensation (pure apply math) + src/core/backends/anan/AnanDroopCalibrator.cpp # live, in-app DDC0 droop calibration sweep src/core/backends/anan/AnanBackend.cpp # aetherd ANAN P2 Phase 1b (IRadioBackend impl) src/core/backends/anan/AnanSettings.cpp # owned config object, "Anan" root key (Principle V) src/core/backends/icom/IcomProtocol.cpp # IcomCIV Phase 0 (RS-BA1 UDP transport) diff --git a/docs/architecture/radio-capabilities-map.md b/docs/architecture/radio-capabilities-map.md index 2ca2b6bc6..c6b2544bc 100644 --- a/docs/architecture/radio-capabilities-map.md +++ b/docs/architecture/radio-capabilities-map.md @@ -93,6 +93,7 @@ traps and why the DAX crash guard is deliberately *not* the DAX capability. | `hasPaCurrentTelemetry` | ❌ | ❌ | ❌ | `MainWindow::applyCapabilitiesToUi` | Calibrated PA drain-current face in Radio Vitals, used only when PA-temperature telemetry is unavailable. Icom: ✅ only for the IC-9700 profile's documented 0–20 A Id calibration. Flex remains ❌ because its PACURRENT meter is known to clip below real full-power draw | | `hasMainFanTelemetry` | ✅ | ❌ | ❌ | `MainWindow::applyCapabilitiesToUi` | Main Fan gauge in Radio Vitals. All current Icom models are ❌ because the backend does not publish fan-speed telemetry | | `hostFrequencyCalibration` | ❌ | ✅ | ❌ | `RadioSetupDialog` (Calibration page), `AutomationServer::doFreqCal` | Shows the Calibration page and enables the `freqcal` bridge verb. Means "**the client** owns the frequency-error correction", not "this radio has an error" — every radio does. Flex is ❌ because it calibrates itself (`radio set cal_freq` / `pll_start`), and that surface stays in the Frequency Offset group on the Receive page. HL2 is ✅ because its 76.8 MHz NCO scale is a `localparam` in the bitstream (`radio.v` M2) and no register in the HPSDR map accepts a correction — see `docs/architecture/hl2-frequency-calibration.md` | +| `hostDroopCalibration` | ❌ | ❌ | ❌ | `RadioSetupDialog` (Droop Correction page), `AutomationServer::doDroopCal` | Shows the Droop Correction page and enables the `droopcal` bridge verb. Means "the client has measured and can correct a real DDC edge droop on this radio", not "this radio has no droop" — HL2's own DDC decimation chain looks architecturally similar and has never been characterised, so its ❌ is "not yet measured", not "known absent". ANAN: ✅ — the Saturn FPGA's DDC0 CIC/decimation chain has a real, bench-measured sin(x)/x droop near the edges of the displayed span (`AnanDroopCorrection.h`), corrected in-app via `AnanDroopCalibrator` | | `persistsMemories` | ✅ | ❌ | ❌ | `LocalMemoryBank` engagement (#4590) | host-side memory bank vs radio-side slots — the bank's ONE shared document lives at `radio_settings (local, '', MemoryBank)` since RFC #4603 PR 6, covered by settings backup/export; legacy `memories.json` is a frozen import source. Icom is profile-gated for IC-705, IC-7300MK2, and IC-9700; unprofiled Icom models remain ❌. | | `canWriteMemories` | ✅ | ❌ | ❌ | `RadioModel::memoriesWritable`, memory dialog and panadapter memory panel | Separates radio ownership from mutation support. The initial Icom implementation is read-only, so Add, Import, inline edits, and Remove cannot leak vendor-incompatible memory commands. | | `canApplyMemories` | ✅ | ❌ | ❌ | `RadioModel::tryMemoryCommand` | True means the backend accepts its native memory-apply command. Initial Icom support is ❌ and applies recallable cached fields through the existing neutral slice setters instead of entering vendor Memory mode; split/RPS/DV/DD records are display-only. | diff --git a/docs/automation-bridge.md b/docs/automation-bridge.md index e79e4a6ef..b77669954 100644 --- a/docs/automation-bridge.md +++ b/docs/automation-bridge.md @@ -3762,7 +3762,7 @@ receiver capacity. It never enables transmit and remains available without The complete registry, generated from the `add(...)` table in `AutomationServer.cpp` by `tools/gen_bridge_docs.py`. CI fails if this drifts from the code. - + | Verb | Aliases | Description | |---|---|---| @@ -3799,6 +3799,7 @@ The complete registry, generated from the `add(...)` table in `AutomationServer. | `waveform` | — | waveform [args] — digital-voice service | | `tune` | — | tune [sliceId] — set a slice frequency (default: the active slice) | | `freqcal` | — | freqcal [get\|set \|from_vfo \|reset] — manual frequency calibration (radios that cannot calibrate themselves) | +| `droopcal` | — | droopcal [status\|start\|stop\|apply] — ANAN-G2 DDC0 droop calibration sweep (radios with a measured DDC edge droop) | | `targettune` | — | targettune — absolute tune through band-stack preselection | | `memory` | — | memory activate [panId] — recall a radio memory | | `cwx` | — | cwx [args] — CWX keyer (send is TX-gated) | @@ -3820,7 +3821,7 @@ The complete registry, generated from the `add(...)` table in `AutomationServer. | `audioCapture` | — | audioCapture [args] — RN2 probe accepts rate=Legacy24k\|Native48k output=PreserveRxStereo\|ProcessedMono blocks= | | `txwaterfall` | — | txwaterfall — show keyed TX in the waterfall | | `liveness` | — | liveness — per-class data ages and the producer->consumer meter join | -| `civ` | — | civ \|trace [all]\|session\|scheduler> — CI-V inject, frame trace, RS-BA1 lease health, or command-scheduler health (Icom; send is TX-gated) | +| `civ` | — | civ \|trace [all]\|session\|scheduler\|incident> — CI-V inject, frame trace, lease/scheduler health, or last incident (Icom; send is TX-gated) | | `controls` | — | controls — the CI-V control and meter registry joined against what is actually wired, and a linkage check that drives every settable control without moving any of them (Icom) | | `radiocert` | — | radiocert [freqMhz] — radio bring-up diagnostic, in dependency order (tx/meters key) | | `transmit` | — | transmit <0..100> — transmit drive (TX-gated) | diff --git a/src/core/AutomationServer.cpp b/src/core/AutomationServer.cpp index 054711f4e..60dca2794 100644 --- a/src/core/AutomationServer.cpp +++ b/src/core/AutomationServer.cpp @@ -3187,6 +3187,13 @@ const std::vector& AutomationServer::verbRegistry() return s.doFreqCal(a.action, a.value); }); + add("droopcal", {}, + "droopcal [status|start|stop|apply] — ANAN-G2 DDC0 droop calibration sweep (radios with a measured DDC edge droop)", + parseActionValue, + [](AutomationServer& s, A& a, QLocalSocket*) -> QJsonObject { + return s.doDroopCal(a.action, a.value); + }); + add("targettune", {}, "targettune — absolute tune through band-stack preselection", parseValueOnly, @@ -7657,6 +7664,82 @@ QJsonObject AutomationServer::doFreqCal(const QString& action, const QString& va .arg(action)); } +QJsonObject AutomationServer::doDroopCal(const QString& action, const QString& value) +{ + Q_UNUSED(value); + if (!m_radioModel) + return err(QStringLiteral("no radio model available")); + if (!m_radioModel->backendCapabilities().hostDroopCalibration) { + return err(QStringLiteral("droopcal: this radio has no measured DDC0 droop to correct")); + } + + AnanDroopCalibrator& cal = m_radioModel->droopCalibrator(); + const QString verb = action.isEmpty() ? QStringLiteral("status") : action.toLower(); + + // Direct, synchronous read of the LIVE calibrator -- unlike freqcal's + // state (which only exists inside the backend, reached through + // RadioSettingsScope/loadPpb), this object is directly reachable, so no + // invokeBackendExtension round trip is needed just to report status. + auto report = [&cal] { + return QJsonObject{ + {QStringLiteral("ok"), true}, + {QStringLiteral("running"), cal.isRunning()}, + {QStringLiteral("rateIndex"), cal.rateIndex()}, + {QStringLiteral("totalRates"), cal.totalRates()}, + {QStringLiteral("hasResult"), cal.hasResult()}, + }; + }; + + if (verb == QLatin1String("status")) + return report(); + + if (verb == QLatin1String("start")) { + // Same refusal freqcal's mutating verbs make: a write (here, the + // eventual Apply) with no radio identity would land on the + // family-wide default row and be inherited by every other radio of + // this family. + if (m_radioModel->settingsScope().radioId().isEmpty()) { + return err(QStringLiteral("droopcal: no radio identity yet — connect the radio " + "before calibrating")); + } + cal.start(); + // start() refuses (no active panadapter) by emitting error() and + // returning, leaving the phase Idle. Reporting ok:true with + // running:false there makes the refusal invisible to automation -- + // the #5263 loud-drop shape: a script would sit polling `status` for + // a sweep that was never going to begin. + if (!cal.isRunning()) { + return err(QStringLiteral("droopcal: sweep did not start -- no active " + "panadapter to sweep")); + } + return report(); + } + if (verb == QLatin1String("stop")) { + cal.stop(); + return report(); + } + if (verb == QLatin1String("apply")) { + // applyResult() completes synchronously (AnanBackend's droop.apply + // handler has no device round trip), reporting any refusal -- no + // radio connected, a write the settings store declined, a stored row + // with a newer schema -- through error() just before finished(false). + // Capturing it here is what keeps `droopcal apply` from answering + // ok:true for a correction that was never saved. + QString failure; + const QMetaObject::Connection conn = + QObject::connect(&cal, &AnanDroopCalibrator::error, &cal, + [&failure](const QString& reason) { failure = reason; }); + cal.applyResult(); + QObject::disconnect(conn); + if (!failure.isEmpty()) + return err(QStringLiteral("droopcal: %1").arg(failure)); + return report(); + } + + return err(QStringLiteral("droopcal: unknown action '%1' (status|start|stop|apply)") + .arg(action)); +} + // ── VFO tuning (#3646) ────────────────────────────────────────────────────── // Set a slice's frequency (MHz). The most fundamental control the VfoWidget // couldn't expose (it's custom-painted). Honors the slice lock guard. An diff --git a/src/core/AutomationServer.h b/src/core/AutomationServer.h index b1499d80a..4674336af 100644 --- a/src/core/AutomationServer.h +++ b/src/core/AutomationServer.h @@ -646,6 +646,7 @@ private slots: // RadioCapabilities::hostFrequencyCalibration, so it refuses on a radio that // calibrates itself rather than silently storing a number nothing applies. QJsonObject doFreqCal(const QString& action, const QString& value); + QJsonObject doDroopCal(const QString& action, const QString& value); QJsonObject doTargetTune(const QString& value); QJsonObject doMemory(const QString& action, const QString& arg); // Demo fault injection (RFC #4288 #4): route a fault to backend-> diff --git a/src/core/backends/RadioCapabilities.h b/src/core/backends/RadioCapabilities.h index f2e35f74d..8602bdbdb 100644 --- a/src/core/backends/RadioCapabilities.h +++ b/src/core/backends/RadioCapabilities.h @@ -288,6 +288,17 @@ struct RadioCapabilities { // varies is whether correcting it is the client's job. bool hostFrequencyCalibration = false; + // The client corrects a REAL DDC0 CIC/decimation droop on this radio's + // own panadapter samples (AnanDroopCorrection.h) because nothing in the + // wire protocol characterises or corrects it on-radio. True only for the + // ANAN-G2 today. Gates the Droop Correction settings tab and the + // `droopcal` bridge verb, mirroring hostFrequencyCalibration above. + // + // NOT "does this radio have a droop" — the physics is per-model, not + // per-family-policy the way frequency correction is. What varies is + // whether the client has measured and can correct it. + bool hostDroopCalibration = false; + // Peripherals / features every family may or may not have bool canReboot = false; // supports a client-triggered radio reboot // The radio exposes an authoritative, client-settable dial lock. This is diff --git a/src/core/backends/anan/AnanBackend.cpp b/src/core/backends/anan/AnanBackend.cpp index 5a3f9f646..7759f837d 100644 --- a/src/core/backends/anan/AnanBackend.cpp +++ b/src/core/backends/anan/AnanBackend.cpp @@ -1,8 +1,16 @@ #include "core/backends/anan/AnanBackend.h" +#include "core/backends/anan/AnanDroopCalibrator.h" +#include "core/backends/anan/AnanSettings.h" +#include "core/AppSettings.h" +#include "core/RadioSettingsScope.h" #include +#include +#include #include #include +#include +#include #include #include @@ -164,7 +172,9 @@ AnanBackend::AnanBackend(QObject* parent) : IRadioBackend(parent) // unlike Hl2Backend's own two call sites, there is no second place // this needs to be re-derived from. Bounds match // capabilities().sampleRatesHz's own endpoints (48-1536 ksps). - emit panBandwidthLimitsChanged(kPanId, 48'000.0 / 1.0e6, 1'536'000.0 / 1.0e6); + emit panBandwidthLimitsChanged(kPanId, + kDdc0RatesKsps.front() * 1000.0 / 1.0e6, + kDdc0RatesKsps.back() * 1000.0 / 1.0e6); if (wasRateChange) { // Audio was muted in beginRateChange(), BEFORE this session's // session even started -- see that function's comment for why @@ -276,7 +286,8 @@ RadioCapabilities AnanBackend::capabilities() const c.model = QStringLiteral("ANAN-G2"); c.maxSlices = 1; c.maxPanadapters = 1; - c.sampleRatesHz = {48000, 96000, 192000, 384000, 768000, 1536000}; + for (const int ksps : kDdc0RatesKsps) + c.sampleRatesHz.append(ksps * 1000); // Not reported -- no verified G2 tuning range (RFC: "I have not fetched // the Apache Labs G2 manual"). RadioCapabilities.h's own convention: // both zero means "not reported", not a guess. @@ -293,8 +304,9 @@ RadioCapabilities AnanBackend::capabilities() const c.hasDdcPanEdgeRolloff = true; // see RadioCapabilities.h's own comment c.persistsMemories = false; // default; stated explicitly c.clientSettingsDomains = {}; // no applyRestoredState()/currentOperatingState() yet - c.extensionNamespaces = {}; // no "anan" extension VERBS yet -- see below, - // this is about invokeExtension(), not this map + c.hostDroopCalibration = true; // AnanDroopCorrection.h -- real DDC0 CIC droop, + // corrected client-side via AnanDroopCalibrator + c.extensionNamespaces = {QStringLiteral("anan")}; // "droop.apply" -- see invokeExtension() // Genuinely discovered, not hardcoded (working plan Step 2's "Capabilities // from discovery" item) -- P2Client::discoveryInfoReceived() parses THIS // session's own Discovery reply opportunistically as it arrives (P2Client's @@ -336,6 +348,29 @@ void AnanBackend::connectRadio(const RadioConnectRequest& request) m_discoveredFirmwareVer = 0; m_discoveredNumDdc = 0; + // Per-radio identity for RadioSettingsScope (droop calibration -- see + // invokeExtension()'s "droop.apply" handler). Set BEFORE the seed load + // 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) { + // Forget the PREVIOUS radio's tables before seeding this one's. The + // seed below only inserts, and m_dsp is constructed once for the + // lifetime of this backend -- so without this, connecting a second, + // uncalibrated G2 in the same session renders it through the first + // one's per-bin corrections. See + // AnanRxDsp::clearDroopCorrectionTables(). + QMetaObject::invokeMethod(m_dsp, "clearDroopCorrectionTables", + Qt::QueuedConnection); + const auto tables = AnanDroopCalibrator::loadTables( + RadioSettingsScope(QStringLiteral("anan"), m_radioSerial)); + for (auto it = tables.constBegin(); it != tables.constEnd(); ++it) { + QMetaObject::invokeMethod(m_dsp, "setDroopCorrectionTable", Qt::QueuedConnection, + Q_ARG(int, it.key()), + Q_ARG(std::vector, std::vector(it.value().begin(), it.value().end()))); + } + } + m_pendingParams.host = request.host; m_pendingParams.ddc0RateKsps = request.params.value(QStringLiteral("anan.ddc0RateKsps"), 48).toInt(); @@ -530,6 +565,19 @@ void AnanBackend::disconnectRadio() // ever clear it. if (m_dsp) QMetaObject::invokeMethod(m_dsp, "setAudioMuted", Qt::QueuedConnection, Q_ARG(bool, false)); + // The droop tables are per-RADIO and m_dsp outlives any one connection, + // so they go with the radio they were measured on. Clearing here (as well + // as before connectRadio()'s seed) means a disconnected session cannot + // leave a stale correction armed for whatever connects next, by any path. + // The bypass flag is cleared too: a disconnect mid-sweep stops the + // calibrator (RadioModel's own connectionStateChanged handler) but its + // finishSweep() cannot reach a backend that is already gone. + if (m_dsp) { + QMetaObject::invokeMethod(m_dsp, "clearDroopCorrectionTables", + Qt::QueuedConnection); + QMetaObject::invokeMethod(m_dsp, "setDroopCorrectionBypassed", + Qt::QueuedConnection, Q_ARG(bool, false)); + } // linkDown() (constructor-wired) sets m_connected = false and emits // disconnected() once P2Client::stop() actually runs. } @@ -653,12 +701,11 @@ int AnanBackend::nearestDdc0RateKsps(int requestedKsps) noexcept // under ratio distance -- the equivalent equidistant point is // 96*sqrt(2) =~ 135.76 ksps, not an integer any real zoom request lands // on -- so no tie-break is needed here, matching the HL2 version exactly. - static constexpr std::array kRatesKsps = {48, 96, 192, 384, 768, 1536}; if (requestedKsps <= 0) - return kRatesKsps.front(); - int best = kRatesKsps.front(); + return kDdc0RatesKsps.front(); + int best = kDdc0RatesKsps.front(); double bestDistance = std::numeric_limits::infinity(); - for (const int r : kRatesKsps) { + for (const int r : kDdc0RatesKsps) { const double distance = std::abs(std::log(static_cast(requestedKsps) / r)); if (distance < bestDistance) { bestDistance = distance; @@ -743,6 +790,13 @@ void AnanBackend::beginRateChange(int newRateKsps) // build to another thread alone would do nothing if the old // stop-before-build ordering were kept, since the session would still // sit torn down for however long the build takes either way. + // Remember what is ACTUALLY running before overwriting it. On the + // failure path below, the old channel and old session keep running at + // this rate (finishRateChange()'s own comment) while these two fields + // would otherwise keep describing a rate that never landed -- and + // emitPanState() reports from m_pendingDspConfig, so every consumer of + // pan bandwidth would be told the change succeeded. + m_preRateChangeKsps = m_pendingParams.ddc0RateKsps; m_pendingParams.ddc0RateKsps = newRateKsps; m_pendingDspConfig.inputSampleRateHz = newRateKsps * 1000; // Refresh from CURRENT live operator state, not connectRadio()'s @@ -794,6 +848,17 @@ void AnanBackend::finishRateChange(quint64 generation, bool ok, const QString& e return; // superseded by a newer rate change/connect/disconnect if (!ok) { + // Roll the reported rate back to the one still running BEFORE + // emitting pan state. AnanDroopCalibrator infers "the rate landed" + // from pan bandwidth reaching its target; told the failed rate had + // landed, it would measure the OLD rate's spectrum into the NEW + // rate's correction table and persist it -- one rate's droop curve + // applied to another rate's bins, which is exactly the cross-rate + // corruption anan_rxdsp_handedness_test's Group 6 exists to prevent. + if (m_preRateChangeKsps > 0) { + m_pendingParams.ddc0RateKsps = m_preRateChangeKsps; + m_pendingDspConfig.inputSampleRateHz = m_preRateChangeKsps * 1000; + } emit connectionError(error); // Safe to clear m_rateChanging immediately here, unlike // startP2ClientSession()'s own failure branch below: the OLD @@ -872,18 +937,101 @@ void AnanBackend::setKeying(bool key) "(canTransmit=false) -- the engine TX guard should have refused this"); } +QString AnanBackend::persistDroopTables(const QMap& tables) +{ + if (tables.isEmpty()) + return QStringLiteral("the request carried no valid correction table"); + // Never write an empty radio_id row (AGENTS.md): RadioSettingsScope falls + // back exact-radio -> family-wide on read, so a row written with no + // identity would be silently adopted by every ANAN that has none of its + // own -- the same guard Hl2Backend::applyFreqCalPpb() uses. This is the + // one thing saveTables() cannot judge for itself: a family-wide scope is + // perfectly VALID, just not what a per-radio calibration wants. + if (m_radioSerial.isEmpty()) { + return QStringLiteral("no radio identity yet -- the correction is live " + "for this session only"); + } + return AnanDroopCalibrator::saveTables( + RadioSettingsScope(QStringLiteral("anan"), m_radioSerial), tables); +} + void AnanBackend::invokeExtension(const QString& ns, const QString& verb, quint64 requestId, const QVariant& arg) { - Q_UNUSED(ns); - Q_UNUSED(verb); - Q_UNUSED(arg); - // No extension namespaces advertised (capabilities().extensionNamespaces - // is empty) -- matches FlexBackend/Hl2Backend's own precedent for a - // namespace with no encode path yet: fail the specific request rather - // than hang a caller waiting for a reply that will never come. + if (ns == QLatin1String("anan")) { + // Droop-correction calibration. Completes LOCALLY -- like Hl2Backend's + // freqcal.set, there is no device round trip to await: the tables are + // host-side data pushed straight to AnanRxDsp, and persistence + live + // application happen in exactly this one place, never duplicated + // between AnanDroopCalibrator's own caller (a UI tab or the + // `droopcal` bridge verb) and this handler. + // Suspends/restores droop correction for the duration of a sweep, so + // the calibrator measures the radio rather than its own corrected + // output -- see AnanRxDsp::setDroopCorrectionBypassed(). Routed + // through this seam, not called on m_dsp directly, because m_dsp + // lives on another thread and the calibrator has no handle on it. + if (verb == QLatin1String("droop.bypass")) { + if (m_dsp) { + QMetaObject::invokeMethod(m_dsp, "setDroopCorrectionBypassed", + Qt::QueuedConnection, Q_ARG(bool, arg.toBool())); + } + if (requestId != 0) + emit extensionResult(requestId, true); + return; + } + + if (verb == QLatin1String("droop.apply")) { + const QVariantMap byRate = arg.toMap(); + + // Validate EVERYTHING before touching either the DSP or the + // store, so a half-malformed request cannot leave the live tables + // and the persisted ones disagreeing about which rates are good. + QMap accepted; + for (auto it = byRate.constBegin(); it != byRate.constEnd(); ++it) { + bool okRate = false; + const int rateKsps = it.key().toInt(&okRate); + const QVariantList list = it.value().toList(); + if (!okRate || list.size() != kDroopCorrectionFftSize) + continue; // malformed entry -- skip, do not corrupt the rest + DroopCorrectionTable table{}; + for (int i = 0; i < list.size(); ++i) + table[static_cast(i)] = list[i].toFloat(); + accepted.insert(rateKsps, table); + } + + for (auto it = accepted.constBegin(); it != accepted.constEnd(); ++it) { + if (m_dsp) { + QMetaObject::invokeMethod(m_dsp, "setDroopCorrectionTable", + Qt::QueuedConnection, Q_ARG(int, it.key()), + Q_ARG(std::vector, + std::vector(it.value().begin(), it.value().end()))); + } + } + + const QString failure = persistDroopTables(accepted); + if (!failure.isEmpty()) { + // AGENTS.md / PR #4621: a mutation that silently does not + // persist while the UI repaints from the store is the worst + // failure shape. Loud here, AND reported back -- the dialog + // used to print "live and saved" unconditionally. + qWarning("AnanBackend: droop calibration not saved -- %s", + qUtf8Printable(failure)); + if (requestId != 0) + emit extensionError(requestId, failure); + return; + } + if (requestId != 0) { + emit extensionResult(requestId, QVariantMap{ + {QStringLiteral("applied"), accepted.size()}, + {QStringLiteral("persisted"), true}}); + } + return; + } + } + // No other ANAN extension verbs; honor the async contract without hanging. if (requestId != 0) - emit extensionError(requestId, QStringLiteral("ANAN: no extension namespaces implemented")); + emit extensionError(requestId, QStringLiteral("ANAN: unknown extension verb '%1.%2'") + .arg(ns, verb)); } void AnanBackend::emitSliceState() diff --git a/src/core/backends/anan/AnanBackend.h b/src/core/backends/anan/AnanBackend.h index 5f0ab4bab..3e86d97cb 100644 --- a/src/core/backends/anan/AnanBackend.h +++ b/src/core/backends/anan/AnanBackend.h @@ -4,6 +4,7 @@ #include "core/backends/anan/AnanRxDsp.h" #include "core/backends/anan/P2Client.h" +#include #include #include #include @@ -140,6 +141,15 @@ class AnanBackend : public IRadioBackend { // see its own definition comment for the retry-window fix folded in // here too. void finishRateChange(quint64 generation, bool ok, const QString& error); + + // Identity guard for the droop-calibration write, in front of + // AnanDroopCalibrator::saveTables() (which owns the merge, the schema + // guard and the codec). Returns an empty string on success, or the + // operator-facing reason it did not persist -- never void: the caller + // reports the outcome to the dialog and the bridge, which both used to + // claim success regardless. + [[nodiscard]] QString persistDroopTables( + const QMap& tables); // If a zoom request arrived while a previous one was still in flight // (m_pendingBandwidthKsps != 0), starts it now. Called from every path // that clears m_rateChanging -- linkUp success and both finishDspSetup() @@ -259,6 +269,25 @@ class AnanBackend : public IRadioBackend { // Fixed identifiers -- Phase 1b is exactly one slice, one pan. static constexpr int kSliceId = 0; static const QString kPanId; + + // This radio's identity for per-radio settings (RadioSettingsScope, + // "anan" family) -- the droop-calibration table, currently the only + // per-radio ANAN state. Set from RadioConnectRequest::serial (populated + // by ConnectionPanel from AnanDiscovery::macToSerial()) at the top of + // connectRadio(), matching Hl2Backend's own m_radioSerial precedent. + // Empty before the first connect. RadioSettingsScope::isValid() only + // requires a non-empty FAMILY, not radioId, so a still-empty serial does + // not make reads/writes fail -- it silently targets the family-wide + // default row instead of one specific radio's, which is why droopcal's + // `start` action guards on settingsScope().radioId().isEmpty() + // explicitly, matching freqcal's own guard, rather than trusting isValid(). + QString m_radioSerial; + + // The DDC0 rate actually running, captured at the top of + // beginRateChange() before the pending fields are overwritten, so + // finishRateChange()'s failure path can put them back. 0 until the first + // rate change. See beginRateChange()'s own comment. + int m_preRateChangeKsps = 0; }; } // namespace AetherSDR::anan diff --git a/src/core/backends/anan/AnanDroopCalibrator.cpp b/src/core/backends/anan/AnanDroopCalibrator.cpp new file mode 100644 index 000000000..01acabf11 --- /dev/null +++ b/src/core/backends/anan/AnanDroopCalibrator.cpp @@ -0,0 +1,431 @@ +#include "core/backends/anan/AnanDroopCalibrator.h" + +#include "core/backends/IRadioBackend.h" +#include "models/PanadapterModel.h" +#include "models/RadioModel.h" + +#include "core/AppSettings.h" + +#include +#include +#include +#include +#include + +#include +#include + +Q_LOGGING_CATEGORY(lcAnanDroopCal, "aether.anan.droopcal", QtWarningMsg) + +namespace AetherSDR { + +namespace { + +float medianOf(std::vector values) +{ + if (values.empty()) + return 0.0f; + std::sort(values.begin(), values.end()); + const std::size_t n = values.size(); + const std::size_t mid = n / 2; + if (n % 2 == 1) + return values[mid]; + return 0.5f * (values[mid - 1] + values[mid]); +} + +} // namespace + +AnanDroopCalibrator::AnanDroopCalibrator(RadioModel* radio, QObject* parent) + : QObject(parent), m_radio(radio) +{ + m_pollTimer.setInterval(kPollIntervalMs); + connect(&m_pollTimer, &QTimer::timeout, this, &AnanDroopCalibrator::advance); +} + +// ---- pure math -------------------------------------------------------- + +AnanDroopCalibrator::Curve AnanDroopCalibrator::medianPowerCurve(const QVector& captures) +{ + Curve result{}; + if (captures.isEmpty()) + return result; + // Floor is a log10(0) guard only, not a plausible-signal floor -- real + // edge-droop measurements on this radio run as low as -160 to -180 dBm + // (bench-confirmed), whose linear power (1e-16 to 1e-18) is smaller than + // a naively "tiny" 1e-12 floor. That floor previously clamped every + // deep-droop bin up to exactly -120 dB, destroying the very signal this + // function exists to measure. 1e-30 (-300 dB) is far below anything this + // radio's ADC can produce, so it only ever guards the literal-zero case. + static constexpr float kLog10Floor = 1.0e-30f; + std::vector powers(static_cast(captures.size())); + for (std::size_t k = 0; k < result.size(); ++k) { + for (int c = 0; c < captures.size(); ++c) + powers[static_cast(c)] = std::pow(10.0f, captures[c][k] / 10.0f); + result[k] = 10.0f * std::log10(std::max(medianOf(powers), kLog10Floor)); + } + return result; +} + +float AnanDroopCalibrator::referenceLevel(const Curve& curve, float windowFraction) +{ + const int n = static_cast(curve.size()); + const int halfWidth = std::max(1, static_cast(n * windowFraction / 2.0f)); + const int center = n / 2; + const int lo = std::max(0, center - halfWidth); + const int hi = std::min(n, center + halfWidth); + std::vector window(curve.begin() + lo, curve.begin() + hi); + return medianOf(std::move(window)); +} + +anan::DroopCorrectionTable AnanDroopCalibrator::computeCorrection( + const Curve& curve, float referenceDb, float capDb) +{ + anan::DroopCorrectionTable table{}; + for (std::size_t k = 0; k < curve.size(); ++k) + table[k] = std::clamp(referenceDb - curve[k], 0.0f, capDb); + return table; +} + +// ---- persistence -------------------------------------------------------- + +QMap AnanDroopCalibrator::loadTables( + const RadioSettingsScope& scope) +{ + QMap tables; + if (!scope.isValid()) + return tables; + const QJsonObject doc = scope.feature(QLatin1String(kFeature)); + for (auto it = doc.constBegin(); it != doc.constEnd(); ++it) { + bool okRate = false; + const int rateKsps = it.key().toInt(&okRate); + const QJsonArray arr = it.value().toArray(); + if (!okRate || arr.size() != static_cast(anan::kDroopCorrectionFftSize)) + continue; // malformed entry -- skip, do not corrupt the rest + anan::DroopCorrectionTable table{}; + for (int i = 0; i < arr.size(); ++i) + table[static_cast(i)] = static_cast(arr[i].toDouble()); + tables.insert(rateKsps, table); + } + return tables; +} + +QString AnanDroopCalibrator::saveTables(const RadioSettingsScope& scope, + const QMap& tables) +{ + if (tables.isEmpty()) + return QStringLiteral("no valid correction table to save"); + if (!scope.isValid()) + return QStringLiteral("no settings scope for this radio"); + + int storedSchema = 0; + // featureExact(), not feature(): this is a WRITER judging the row it is + // about to replace, and feature()'s exact-radio -> family-wide fallback + // would silently fold a shared default into this radio's own row + // (PR #4614 review). An absent row reads back as schema 0. + QJsonObject doc = scope.featureExact(QLatin1String(kFeature), &storedSchema); + if (storedSchema > kSchemaVersion) { + return QStringLiteral("stored calibration is schema v%1, newer than this " + "build understands (v%2) -- refusing to overwrite it") + .arg(storedSchema) + .arg(kSchemaVersion); + } + + for (auto it = tables.constBegin(); it != tables.constEnd(); ++it) { + QJsonArray arr; + for (const float v : it.value()) + arr.append(static_cast(v)); + doc.insert(QString::number(it.key()), arr); + } + + // setFeature() refuses while the store is not ReadyToSave. Reporting that + // as success is the #4621 failure shape -- nothing reads a correction + // table back off the radio, so the operator would only discover it on the + // next connect, as a droop that quietly returned. + if (!scope.setFeature(QLatin1String(kFeature), kSchemaVersion, doc)) + return QStringLiteral("the settings store refused the write"); + AppSettings::instance().save(); + return {}; +} + +// ---- sweep control -------------------------------------------------------- + +void AnanDroopCalibrator::start() +{ + if (isRunning()) + return; + if (!m_radio) { + emit error(QStringLiteral("no radio model")); + return; + } + PanadapterModel* pan = m_radio->activePanadapter(); + if (!pan) { + qCWarning(lcAnanDroopCal) << "start() with no active panadapter"; + emit error(QStringLiteral("no active panadapter to sweep")); + return; + } + + m_originalRateKsps = static_cast(std::lround(pan->bandwidthMhz() * 1000.0)); + m_measuredTables.clear(); + m_pendingCaptures.clear(); + m_haveLatestFrame = false; + m_rateIdx = 0; + + // BEFORE the tap is connected and before the first rate is requested: + // every frame this sweep ever sees must be uncorrected. + setDspBypass(true); + + m_clock.start(); + m_spectrumConn = connect(m_radio, &RadioModel::panFeedSpectrumReady, + this, &AnanDroopCalibrator::onSpectrumFrame); + m_pollTimer.start(); + m_phaseStartedAtMs = m_clock.elapsed(); + m_phase = Phase::WaitingForRateLanded; + requestCurrentRate(); + + emit started(); + emit progress(0, totalRates(), 0); +} + +void AnanDroopCalibrator::stop() +{ + if (!isRunning()) + return; + finishSweep(false); +} + +void AnanDroopCalibrator::applyResult() +{ + if (isRunning() || m_measuredTables.isEmpty() || !m_radio) + return; + + IRadioBackend* backend = m_radio->backend(); + if (!backend) { + // invokeBackendExtension() is a documented no-op with nothing + // connected, so this is the exact case that used to print + // "Applied -- the measured correction is now live and saved" over a + // radio that had gone away. The measurements are KEPT: reconnecting + // and pressing Apply again is a real recovery. + emit error(QStringLiteral("no radio connected -- the measured correction " + "was not applied or saved")); + emit finished(false); + return; + } + + QVariantMap byRate; + for (auto it = m_measuredTables.constBegin(); it != m_measuredTables.constEnd(); ++it) { + QVariantList list; + list.reserve(static_cast(it.value().size())); + for (const float v : it.value()) + list.append(static_cast(v)); + byRate.insert(QString::number(it.key()), list); + } + + // Correlated, not fire-and-forget. AnanBackend's droop.apply handler + // completes LOCALLY -- no device round trip, same as Hl2Backend's + // freqcal.set -- so it emits its reply synchronously, inside the invoke + // below, over a same-thread direct connection. Connecting first and + // reading the flags after the call returns is therefore deterministic + // rather than a race, and needs no timer: if nothing answered, the seam + // did not behave as documented and that is itself reportable. The id only + // has to be unique within that one synchronous window. + const quint64 requestId = ++m_applyRequestId; + bool replied = false; + QString failure; + const QMetaObject::Connection okConn = connect( + backend, &IRadioBackend::extensionResult, this, + [&replied, requestId](quint64 id, const QVariant&) { + if (id == requestId) + replied = true; + }); + const QMetaObject::Connection errConn = connect( + backend, &IRadioBackend::extensionError, this, + [&replied, &failure, requestId](quint64 id, const QString& reason) { + if (id == requestId) { + replied = true; + failure = reason; + } + }); + + m_radio->invokeBackendExtension(QStringLiteral("anan"), QStringLiteral("droop.apply"), + requestId, QVariant(byRate)); + + QObject::disconnect(okConn); + QObject::disconnect(errConn); + + if (!replied) + failure = QStringLiteral("the backend did not answer the apply request"); + if (!failure.isEmpty()) { + emit error(failure); + emit finished(false); + return; + } + emit finished(true); +} + +void AnanDroopCalibrator::clear() +{ + if (isRunning()) + return; + m_measuredTables.clear(); + m_pendingCaptures.clear(); +} + +// ---- phase state machine -------------------------------------------------- + +void AnanDroopCalibrator::setDspBypass(bool bypassed) +{ + if (!m_radio) + return; + // Fire-and-forget (requestId 0): the handler completes locally and there + // is nothing to await. AnanBackend also clears the flag on disconnect, so + // a radio that vanishes mid-sweep -- where this call reaches nothing -- + // still comes back with correction live. + m_radio->invokeBackendExtension(QStringLiteral("anan"), + QStringLiteral("droop.bypass"), 0, bypassed); +} + +void AnanDroopCalibrator::requestCurrentRate() +{ + if (!m_radio) + return; + m_radio->setPanBandwidth(currentTargetRateKsps() / 1000.0); +} + +int AnanDroopCalibrator::currentTargetRateKsps() const +{ + return (m_rateIdx >= 0 && m_rateIdx < static_cast(kRatesKsps.size())) + ? kRatesKsps[static_cast(m_rateIdx)] + : 0; +} + +void AnanDroopCalibrator::finishSweep(bool applied) +{ + QObject::disconnect(m_spectrumConn); + m_pollTimer.stop(); + // Every exit from a sweep comes through here -- completion, stop(), and + // abortSweep() alike -- so the correction is restored on all of them. + // Non-destructive by construction: the tables were hidden, never + // overwritten, so there is nothing to restore FROM and no window in which + // a crash could lose them. + setDspBypass(false); + if (m_radio && m_originalRateKsps > 0) + m_radio->setPanBandwidth(m_originalRateKsps / 1000.0); // best-effort, fire-and-forget + m_phase = Phase::Idle; + m_pendingCaptures.clear(); + m_haveLatestFrame = false; + emit finished(applied); +} + +void AnanDroopCalibrator::abortSweep(const QString& reason) +{ + qCWarning(lcAnanDroopCal) << "sweep aborted:" << reason; + emit error(reason); + finishSweep(false); +} + +void AnanDroopCalibrator::advance() +{ + if (m_phase == Phase::Idle) + return; + PanadapterModel* pan = m_radio ? m_radio->activePanadapter() : nullptr; + if (!pan) { + abortSweep(QStringLiteral("panadapter disappeared mid-sweep")); + return; + } + + const qint64 now = m_clock.elapsed(); + switch (m_phase) { + case Phase::Idle: + return; + + case Phase::WaitingForRateLanded: { + const double targetMhz = currentTargetRateKsps() / 1000.0; + if (std::abs(pan->bandwidthMhz() - targetMhz) < 1.0e-6) { + m_phase = Phase::Settling; + m_phaseStartedAtMs = now; + } else if (now - m_phaseStartedAtMs >= kRateWaitTimeoutMs) { + abortSweep(QStringLiteral("rate change to %1 ksps did not land") + .arg(currentTargetRateKsps())); + } + break; + } + + case Phase::Settling: + if (now - m_phaseStartedAtMs >= kPostLandSettleMs) { + m_phase = Phase::Sampling; + m_phaseStartedAtMs = now; // also the stall deadline -- see Sampling + m_lastSampleAtMs = 0; // capture immediately on the first Sampling tick + // Drop whatever frame is in hand. It may predate the settle + // window, or even the rate change -- frames for the PREVIOUS rate + // are still in flight when this phase begins, and one of those + // measured into this rate's table is the cross-rate corruption + // the whole per-rate keying exists to prevent. + m_haveLatestFrame = false; + } + break; + + case Phase::Sampling: { + if (!m_haveLatestFrame) { + // The only unbounded wait in the machine used to be right here. + if (now - m_phaseStartedAtMs >= kSampleStallTimeoutMs) { + abortSweep(QStringLiteral("no spectrum frame at %1 ksps for %2 s " + "-- is the panadapter running?") + .arg(currentTargetRateKsps()) + .arg(kSampleStallTimeoutMs / 1000)); + } + break; + } + if (m_lastSampleAtMs != 0 && now - m_lastSampleAtMs < kSampleSpacingMs) + break; + m_lastSampleAtMs = now; + m_phaseStartedAtMs = now; // progress resets the stall deadline + m_pendingCaptures.push_back(m_latestFrame); + // Require a genuinely NEW frame for the next capture. Without this the + // 8-sample median can be eight copies of one frame whenever the + // spectrum FPS is below 1000/kSampleSpacingMs, which defeats the + // outlier rejection medianPowerCurve() exists to provide. + m_haveLatestFrame = false; + emit rateSampled(currentTargetRateKsps(), m_pendingCaptures.size()); + + const float rateProgress = + static_cast(m_pendingCaptures.size()) / kSamplesPerRate; + const int overallPercent = static_cast( + 100.0f * (static_cast(m_rateIdx) + rateProgress) / totalRates()); + emit progress(m_rateIdx, totalRates(), overallPercent); + + if (m_pendingCaptures.size() >= kSamplesPerRate) { + const Curve curve = medianPowerCurve(m_pendingCaptures); + const float ref = referenceLevel(curve); + const auto table = computeCorrection(curve, ref); + m_measuredTables[currentTargetRateKsps()] = table; + m_pendingCaptures.clear(); + ++m_rateIdx; + if (m_rateIdx < totalRates()) { + m_phase = Phase::WaitingForRateLanded; + m_phaseStartedAtMs = now; + m_haveLatestFrame = false; + requestCurrentRate(); + } else { + finishSweep(false); // staged, not confirmed -- caller decides Apply + } + } + break; + } + } +} + +void AnanDroopCalibrator::onSpectrumFrame(quint32 streamId, const QVector& binsDbm, + qint64 emittedNs) +{ + Q_UNUSED(streamId); + Q_UNUSED(emittedNs); + if (m_phase == Phase::Idle) + return; + if (binsDbm.size() != static_cast(anan::kDroopCorrectionFftSize)) + return; + for (int i = 0; i < binsDbm.size(); ++i) + m_latestFrame[static_cast(i)] = binsDbm[i]; + m_haveLatestFrame = true; +} + +} // namespace AetherSDR diff --git a/src/core/backends/anan/AnanDroopCalibrator.h b/src/core/backends/anan/AnanDroopCalibrator.h new file mode 100644 index 000000000..4f093daf1 --- /dev/null +++ b/src/core/backends/anan/AnanDroopCalibrator.h @@ -0,0 +1,227 @@ +#pragma once + +#include "core/backends/anan/AnanDroopCorrection.h" +#include "core/backends/anan/P2Protocol.h" // kDdc0RatesKsps +#include "core/RadioSettingsScope.h" + +#include +#include +#include +#include +#include +#include + +#include + +namespace AetherSDR { + +class RadioModel; + +// Live, in-app calibration for the ANAN-G2's real DDC0 CIC/decimation droop +// (see AnanDroopCorrection.h for what the droop is and why it exists). +// +// Modeled on this codebase's own established conventions rather than +// invented fresh: the sweep-engine shape (headless QObject; steps a +// control, samples a live response, builds a curve, computes and applies a +// result; started/progress/finished signal vocabulary) mirrors +// AgcTCalibrator. The per-radio persistence shape (RadioSettingsScope +// feature document, kFeature/kSchemaVersion, a pure static load function) +// mirrors Hl2FreqCal. Family-agnostic mechanically -- it drives +// RadioModel::setPanBandwidth() and taps RadioModel::panFeedSpectrumReady +// only, both generic RadioModel surfaces -- and is exposed only for ANAN +// via RadioCapabilities::hostDroopCalibration. +// +// Owned by RadioModel (RadioModel::droopCalibrator()), not dialog- +// constructed like AgcTCalibrator: invokeBackendExtension() is a +// synchronous, fire-and-forget call correlated only by requestId (confirmed +// against Hl2Backend::invokeExtension()), which cannot deliver a +// multi-minute sweep's live progress to a UI. A persistent, directly +// reachable instance lets a UI tab and the `droopcal` bridge verb observe +// and drive the SAME sweep without fighting over or duplicating it. +class AnanDroopCalibrator : public QObject { + Q_OBJECT + +public: + explicit AnanDroopCalibrator(RadioModel* radio, QObject* parent = nullptr); + + enum class Phase { Idle, WaitingForRateLanded, Settling, Sampling }; + + [[nodiscard]] bool isRunning() const noexcept { return m_phase != Phase::Idle; } + [[nodiscard]] int rateIndex() const noexcept { return m_rateIdx; } + [[nodiscard]] int totalRates() const noexcept { return static_cast(kRatesKsps.size()); } + [[nodiscard]] bool hasResult() const { return !m_measuredTables.isEmpty(); } + [[nodiscard]] const QMap& measuredTables() const + { + return m_measuredTables; + } + + // ---- pure math (static, unit-testable without a live radio) ---- + // Ported directly from this feature's original offline prototype + // (a throwaway offline script, never in this tree, since superseded by + // this in-app engine) -- same algorithm, same reasoning, now C++. + using Curve = std::array; + + // Combines N per-capture dB curves into one, per bin, via the MEDIAN + // across captures -- robust against a stray in-band signal landing in + // one capture during a live-antenna sweep, unlike a mean. Converts to + // linear power before taking the median and back to dB after: for a + // median specifically this round trip is a no-op in exact arithmetic + // (dB is a strictly increasing function of power, and order statistics + // are invariant under any strictly monotonic transform), but it keeps + // this function's contract "average in the physically meaningful + // domain" even if a future caller swaps the reducer for a mean, where + // the log-domain-bias problem (Jensen's inequality) is real. + [[nodiscard]] static Curve medianPowerCurve(const QVector& captures); + + // Median of the curve over a central window (default: center +/- 15% of + // the bins), not the exact center bin -- avoids any residual DC-region + // artifact even though AnanSpectrum already DC-removes before windowing. + [[nodiscard]] static float referenceLevel(const Curve& curve, + float windowFraction = 0.15f); + + // correction[k] = clamp(referenceDb - curve[k], 0, capDb). The sweep + // measures with a dummy load, so `curve` IS the noise floor -- droop + // here is a real, deterministic attenuation the CIC/decimation chain + // applies equally to noise and any in-band signal, not a floor the + // signal disappears beneath. Correcting it, even by many tens of dB, is + // restoring the true level of whatever is actually in that bin, not + // amplifying noise past a recoverable signal. 70 dB (the first value + // tried, from an estimate off one bench capture) still left the worst + // bins visibly low: with that cap applied and persisted, a live + // panadapter capture at 1536 ksps still read edges at -15 to -20 dB + // relative to mid-band (mid-band ~-108..-115 dBm, edges down to + // -133..-139 dBm) -- the cap itself was the limiting factor, not the + // correction math. 90 dB clears that with margin. capDb exists as a + // genuine safety bound against a corrupted/garbage measurement, not as + // a "past this point it's unrecoverable" line. + [[nodiscard]] static anan::DroopCorrectionTable computeCorrection( + const Curve& curve, float referenceDb, float capDb = 90.0f); + + // ---- persistence (static, no live radio needed) ---- + static constexpr const char* kFeature = "DroopCalibration"; + static constexpr int kSchemaVersion = 1; + + // Reads whatever was last persisted for this radio (possibly a partial + // set of rates, or none). Missing/unparseable entries are simply + // absent from the result -- AnanRxDsp's own per-rate fallback + // (kDroopCorrectionZero) covers a rate this never measured. + [[nodiscard]] static QMap loadTables( + const RadioSettingsScope& scope); + + // The write half, symmetric with loadTables() so the float<->JSON codec + // and its validity rules live in ONE place rather than being hand-copied + // between the reader and the apply handler. + // + // MERGES into whatever this radio already has: a partial sweep is blessed + // as "a safe, real partial improvement" (see stop()), so an Apply after + // one carries only the rates it measured. Writing the document from those + // alone would drop every previously calibrated rate from disk while the + // live DSP kept them -- the radio correct until the next connect and + // silently wrong after it. Principle XIV: persisted as a unit. + // + // Refuses a row whose stored schema is NEWER than this build understands, + // rather than merging into a shape it cannot know. Returns an empty + // string on success, otherwise the operator-facing reason it did not + // persist -- never void, because the caller reports that outcome to the + // dialog and the bridge. + // + // Still exactly one CALLER: AnanBackend::invokeExtension()'s + // "anan"/"droop.apply" handler, never duplicated between the UI tab and + // the bridge verb. + [[nodiscard]] static QString saveTables( + const RadioSettingsScope& scope, + const QMap& tables); + +public slots: + // Begins a 6-rate sweep from Idle. No-op if already running or the + // radio has no active panadapter yet. + void start(); + + // Aborts a running sweep: disconnects the spectrum tap, stops the poll + // timer (the entire cancellation -- nothing else is awaited, since a + // rate-change confirmation is only ever polled, never blocked on), + // lifts the DSP correction bypass, best-effort restores the pre-sweep + // rate, and returns to Idle. + // Whatever rates were already measured before stopping are KEPT (not + // cleared) -- a partial table set is a safe, real partial improvement, + // not corrupt data; hasResult()/applyResult() work with it as-is. + void stop(); + + // Pushes every measured table live to AnanRxDsp and persists them, via + // exactly one call into AnanBackend::invokeExtension()'s "droop.apply" + // handler -- mirrors Hl2Backend's applyFreqCalPpb() discipline of one + // apply path, never duplicated. No-op if hasResult() is false. + // + // Emits finished(true) ONLY when the backend confirms both the live push + // and the write; otherwise error(reason) then finished(false). This used + // to report success unconditionally, so a radio dropped between the sweep + // and Apply -- which makes invokeBackendExtension() a documented no-op -- + // still printed "live and saved" in the dialog and returned ok:true to + // the bridge. + void applyResult(); + + // Drops any measured (but not yet applied) tables. No-op while running. + void clear(); + +signals: + void started(); + void progress(int rateIndex, int totalRates, int percent); + void rateSampled(int rateKsps, int sampleCount); + void finished(bool applied); + void error(const QString& reason); + +private slots: + void advance(); // one poll tick of the phase state machine + void onSpectrumFrame(quint32 streamId, const QVector& binsDbm, qint64 emittedNs); + +private: + void requestCurrentRate(); + void finishSweep(bool applied); + void abortSweep(const QString& reason); + [[nodiscard]] int currentTargetRateKsps() const; + + // Suspends (true) / restores (false) AnanRxDsp's droop correction for the + // duration of the sweep, through the same backend seam applyResult() + // uses. WITHOUT this the sweep taps panFeedSpectrumReady downstream of + // the correction it is trying to measure: the second sweep an operator + // runs sees an already-flat curve, computes a near-zero table, and Apply + // persists that over the good one -- with the synthetic edge fade baked + // in as if it were hardware. See + // AnanRxDsp::setDroopCorrectionBypassed(). + void setDspBypass(bool bypassed); + + // The sweep covers every rate the radio has, in the order it has them -- + // see anan::kDdc0RatesKsps for why the set is spelled in exactly one place. + static constexpr auto& kRatesKsps = anan::kDdc0RatesKsps; + static constexpr int kSamplesPerRate = 8; + static constexpr int kSampleSpacingMs = 300; + static constexpr int kRateWaitTimeoutMs = 90'000; // "~a minute cold" + margin + static constexpr int kPostLandSettleMs = 500; // EMA (kSpectrumSmoothAlpha) convergence + static constexpr int kPollIntervalMs = 200; + // Sampling needs its own bound. Only the rate wait used to have one, so a + // feed that simply stopped -- a hidden or paused panadapter, a frame size + // that is not kDroopCorrectionFftSize, a quiet network drop -- left this + // phase spinning forever: isRunning() stayed latched (making start() and + // applyResult() permanent no-ops), the correction stayed bypassed, and + // the radio sat parked at the sweep's rate. Generous against a low + // spectrum FPS while still failing in seconds rather than never. + static constexpr int kSampleStallTimeoutMs = 15'000; + + RadioModel* m_radio = nullptr; // not QPointer: RadioModel outlives this (owned member) + QTimer m_pollTimer; + Phase m_phase = Phase::Idle; + int m_rateIdx = -1; + qint64 m_phaseStartedAtMs = 0; + qint64 m_lastSampleAtMs = 0; + QElapsedTimer m_clock; + int m_originalRateKsps = 0; + + quint64 m_applyRequestId = 0; // correlates applyResult()'s one reply + bool m_haveLatestFrame = false; + Curve m_latestFrame{}; + QVector m_pendingCaptures; + QMap m_measuredTables; + QMetaObject::Connection m_spectrumConn; +}; + +} // namespace AetherSDR diff --git a/src/core/backends/anan/AnanDroopCorrection.cpp b/src/core/backends/anan/AnanDroopCorrection.cpp new file mode 100644 index 000000000..232fdfb3a --- /dev/null +++ b/src/core/backends/anan/AnanDroopCorrection.cpp @@ -0,0 +1,11 @@ +#include "core/backends/anan/AnanDroopCorrection.h" + +namespace AetherSDR::anan { + +namespace { +constexpr DroopCorrectionTable kZeroTable{}; +} // namespace + +const DroopCorrectionTable& kDroopCorrectionZero = kZeroTable; + +} // namespace AetherSDR::anan diff --git a/src/core/backends/anan/AnanDroopCorrection.h b/src/core/backends/anan/AnanDroopCorrection.h new file mode 100644 index 000000000..c132fae69 --- /dev/null +++ b/src/core/backends/anan/AnanDroopCorrection.h @@ -0,0 +1,109 @@ +#pragma once + +#include +#include +#include +#include + +namespace AetherSDR::anan { + +// The Saturn FPGA's DDC0 decimation chain (two cascaded CIC decimators plus +// a halfband FIR -- see reference/saturn/New_protocol_FPGA_Block_diagrams.pdf, +// "Receiver(3)") imposes a REAL sin(x)/x amplitude droop near the edges of +// the displayed span, baked into the raw IQ samples themselves. This is not +// a rendering artifact and not fixable by touching bin count or reported +// bandwidth -- an earlier attempt at exactly that broke zoom-out (see +// AnanBackend::emitPanState()'s own comment). This header applies a per-bin +// dB correction, measured empirically per DDC0 rate by the in-app +// AnanDroopCalibrator sweep (src/core/backends/anan/AnanDroopCalibrator.h), +// to the actual FFT magnitude before display. AnanRxDsp holds the live +// table set (loaded from per-radio settings at connect, or produced by a +// fresh sweep); this header only owns the data shape and the pure apply +// math, not table selection or storage. + +inline constexpr int kDroopCorrectionFftSize = 1024; + +using DroopCorrectionTable = std::array; + +// Safe no-op fallback for an unrecognized/uncalibrated rate -- additive +// zero, not a guess. +extern const DroopCorrectionTable& kDroopCorrectionZero; + +// Adds the per-bin dB correction into binsDbfs in place. Pure, no I/O, no +// AnanRxDsp state -- unit-testable standalone. A size mismatch leaves +// binsDbfs byte-for-byte unchanged rather than truncating or asserting: a +// table generated for a different fftSize must never silently misalign bin +// k against the wrong correction. +inline void applyDroopCorrectionDb(std::vector& binsDbfs, + const DroopCorrectionTable& table) noexcept +{ + if (binsDbfs.size() != table.size()) + return; + for (std::size_t i = 0; i < binsDbfs.size(); ++i) + binsDbfs[i] += table[i]; +} + +// Cosmetic fade for the outermost `tailFraction` of bins on each side, +// applied AFTER applyDroopCorrectionDb() -- for the true edge of the span, +// not for the recoverable bulk of it. +// +// The measured droop at the true edge is deep enough, and noisy enough bin +// to bin, that no per-bin dB correction produces a clean result: raising +// AnanDroopCalibrator's capDb from 70 to 90 dB left those bins unchanged or +// worse from one calibration sweep to the next, 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. +// +// This function does not try. It overwrites the tail zone with a +// deterministic raised-cosine fade from the corrected value at the tail +// boundary down to (boundary - fadeDb), replacing whatever noisy value the +// real droop + correction produced there -- so the display always shows a +// smooth, repeatable roll-off at the true edge instead of an unpredictable +// one that sometimes drops below the panadapter's black level and reads as +// a broken/glitchy dark band. This is the same judgment call WDSP's own +// Display/Analyzer API makes: SetAnalyzer's `clp` parameter exists 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). +// We fade instead of literally clipping bins because changing bin +// count/reported bandwidth already broke zoom-out once -- see +// AnanBackend::emitPanState()'s own comment. +inline void applyEdgeFade(std::vector& binsDbfs, + float tailFraction = 0.03f, + float fadeDb = 12.0f) noexcept +{ + const auto n = binsDbfs.size(); + const auto tailBins = static_cast(static_cast(n) * tailFraction); + // Require at least one untouched bin strictly between the two tail + // zones -- otherwise they'd overlap (or abut with no gap), and + // rightBoundary below could read a bin the left loop already + // overwrote. + if (tailBins < 2 || n <= tailBins * 2) + return; + + constexpr float kPi = 3.14159265358979323846f; + + // Left edge: bin 0 is the true edge, bin tailBins is the boundary this + // fade blends FROM (left untouched). u runs 1 (true edge) -> 0 + // (boundary), so the raised-cosine window is 0 right at the boundary + // (perfect continuity with the untouched region) and 1 at the true + // edge (full fadeDb applied). + const float leftBoundary = binsDbfs[tailBins]; + for (std::size_t k = 0; k < tailBins; ++k) { + const float u = static_cast(tailBins - k) / static_cast(tailBins); + const float window = 0.5f * (1.0f - std::cos(u * kPi)); + binsDbfs[k] = leftBoundary - fadeDb * window; + } + + // Right edge: mirror image, boundary at n-1-tailBins. + const float rightBoundary = binsDbfs[n - 1 - tailBins]; + for (std::size_t k = 0; k < tailBins; ++k) { + const std::size_t idx = n - 1 - k; + const float u = static_cast(tailBins - k) / static_cast(tailBins); + const float window = 0.5f * (1.0f - std::cos(u * kPi)); + binsDbfs[idx] = rightBoundary - fadeDb * window; + } +} + +} // namespace AetherSDR::anan diff --git a/src/core/backends/anan/AnanRxDsp.cpp b/src/core/backends/anan/AnanRxDsp.cpp index 14e231912..de31d69d2 100644 --- a/src/core/backends/anan/AnanRxDsp.cpp +++ b/src/core/backends/anan/AnanRxDsp.cpp @@ -93,6 +93,7 @@ AnanRxDsp::RebuildResult AnanRxDsp::buildChannel(const Config& config) if (!channel) return result; result.outputBlockSize = channel->outputBlockSize(); + result.inputSampleRateHz = config.inputSampleRateHz; result.spectrum = std::make_unique(config.fftSize); result.channel = std::move(channel); return result; @@ -120,6 +121,13 @@ bool AnanRxDsp::installRebuiltChannel(RebuildResult result) void AnanRxDsp::installChannel(RebuildResult result) { + // The only update site for this field outside configure()'s own + // synchronous m_config = config -- see RebuildResult::inputSampleRateHz's + // comment. Must land before droopTableForRate() is ever consulted again, + // which processIqBlock() does on every block once m_channel is swapped + // below. + m_config.inputSampleRateHz = result.inputSampleRateHz; + m_iqBuffer.clear(); m_i.assign(static_cast(m_config.dspBlockSize), 0.0f); m_q.assign(static_cast(m_config.dspBlockSize), 0.0f); @@ -199,6 +207,42 @@ void AnanRxDsp::setSpectrumRateFps(int fps) // grant an immediate extra one. } +void AnanRxDsp::setDroopCorrectionTable(int rateKsps, const std::vector& table) +{ + if (table.size() != kDroopCorrectionFftSize) + return; + bool valid = false; + for (const int r : kDdc0RatesKsps) + valid |= (r == rateKsps); + if (!valid) + return; + DroopCorrectionTable t; + std::copy(table.begin(), table.end(), t.begin()); + m_droopTables[rateKsps] = t; +} + +void AnanRxDsp::setDroopCorrectionBypassed(bool bypassed) +{ + m_droopBypassed = bypassed; +} + +void AnanRxDsp::clearDroopCorrectionTables() +{ + m_droopTables.clear(); +} + +const DroopCorrectionTable& AnanRxDsp::droopTableForRate(int rateKsps) const noexcept +{ + // Returning the kDroopCorrectionZero OBJECT (not a zero-valued copy) is + // what also suppresses the edge fade in processIqBlock(), which tests + // identity against exactly this address -- see + // setDroopCorrectionBypassed()'s comment. + if (m_droopBypassed) + return kDroopCorrectionZero; + const auto it = m_droopTables.constFind(rateKsps); + return it != m_droopTables.constEnd() ? it.value() : kDroopCorrectionZero; +} + void AnanRxDsp::setShift(double shiftHz) { m_shiftHz = shiftHz; @@ -277,6 +321,20 @@ void AnanRxDsp::processIqBlock(const std::vector>& iq) // displayed frame. if (spectrumFrameDue()) { if (m_spectrum->process(m_conjugated, m_bins) > 0) { + // Real DDC0 CIC/decimation droop, corrected on the actual FFT + // magnitude BEFORE the EMA below so the smoothed/emitted trace + // reflects the corrected value at every step -- see + // AnanDroopCorrection.h. inputSampleRateHz is always an exact + // multiple of 1000 for the six valid DDC0 rates. + const DroopCorrectionTable& droopTable = + droopTableForRate(m_config.inputSampleRateHz / 1000); + applyDroopCorrectionDb(m_bins, droopTable); + // Cosmetic fade for the true edge -- only once a real + // calibration exists for this rate (the zero fallback has + // nothing meaningful to fade FROM). See applyEdgeFade()'s own + // comment for why this exists instead of a larger capDb. + if (&droopTable != &kDroopCorrectionZero) + applyEdgeFade(m_bins); smoothSpectrumBins(m_bins); emit spectrumReady(m_bins); m_lastSpectrumMs = m_spectrumClock.elapsed(); diff --git a/src/core/backends/anan/AnanRxDsp.h b/src/core/backends/anan/AnanRxDsp.h index 2531137a5..0cb23adaf 100644 --- a/src/core/backends/anan/AnanRxDsp.h +++ b/src/core/backends/anan/AnanRxDsp.h @@ -9,9 +9,13 @@ #include #include +#include "core/backends/anan/AnanDroopCorrection.h" +#include "core/backends/anan/P2Protocol.h" // kDdc0RatesKsps #include "core/backends/anan/AnanSpectrum.h" #include "core/dsp/WdspChannel.h" +#include + namespace AetherSDR::anan { // The ANAN-G2 receive DSP stage: turns raw DDC0 IQ blocks (from @@ -89,6 +93,14 @@ class AnanRxDsp : public QObject { std::unique_ptr channel; std::unique_ptr spectrum; std::size_t outputBlockSize = 0; + // The DDC0 rate buildChannel() actually built this channel for. + // installChannel() copies it into m_config.inputSampleRateHz on + // swap -- the only place that field is updated for a live rate + // change, since buildChannel() runs off this object's own thread and + // cannot touch m_config directly. Without this, droopTableForRate() + // keeps reading the connect-time rate forever after the first zoom, + // applying one rate's correction curve to a different rate's data. + int inputSampleRateHz = 0; std::string error; // set iff channel == nullptr }; @@ -152,6 +164,49 @@ class AnanRxDsp : public QObject { // not anything ANAN-specific, and transfers unchanged. Q_INVOKABLE void setSpectrumRateFps(int fps); + // Installs the measured per-bin dB correction for ONE DDC0 rate (see + // AnanDroopCorrection.h). Ignored -- no change, no crash -- if `table` + // is not exactly kDroopCorrectionFftSize long or rateKsps is not one of + // the six valid ANAN-G2 DDC0 rates: a caller passing a stale or + // malformed table must never silently misalign bin k against the wrong + // correction. Callers: AnanBackend seeds this from persisted per-radio + // settings at connect, and AnanDroopCalibrator pushes freshly measured + // tables live once a sweep completes. std::vector, not + // DroopCorrectionTable, because qRegisterMetaType> + // is already registered (constructor, for spectrumReady/audioReady) -- + // reusing it avoids adding a second metatype for the same threading + // need. + Q_INVOKABLE void setDroopCorrectionTable(int rateKsps, const std::vector& table); + + // Suspends droop correction WITHOUT discarding the measured tables, so + // AnanDroopCalibrator can measure the radio instead of measuring its own + // output. The sweep taps the same spectrumReady bins the panadapter + // paints; with correction live, the second sweep an operator runs sees an + // already-flattened curve, computes a near-zero table from it, and Apply + // persists that over the good one. + // + // A bypass FLAG rather than "push kDroopCorrectionZero for each rate": + // pushing a zero-valued table through setDroopCorrectionTable() stores a + // COPY, so droopTableForRate() no longer returns the kDroopCorrectionZero + // object itself and processIqBlock()'s `&droopTable != &kDroopCorrectionZero` + // identity test still reads true -- the synthetic 12 dB edge fade would + // stay on and be measured as if it were hardware droop. Routing the + // bypass through droopTableForRate() keeps both suppressions on the one + // switch they were always meant to share. It is also non-destructive: an + // abort, a disconnect, or a crash mid-sweep cannot lose a calibration + // that was only ever hidden, never overwritten. + Q_INVOKABLE void setDroopCorrectionBypassed(bool bypassed); + [[nodiscard]] bool droopCorrectionBypassed() const noexcept { return m_droopBypassed; } + + // Forgets every measured table. This object is constructed ONCE and + // survives disconnect/reconnect, while the tables are per-RADIO -- so + // without this, calibrated G2 #1 -> disconnect -> G2 #2 renders #2's + // spectrum through #1's per-bin corrections (plus the edge fade on top), + // with the Droop tab showing nothing, since it reads the calibrator's + // measuredTables() and those are empty. AnanBackend calls this on + // disconnect and again before seeding a fresh connect's tables. + Q_INVOKABLE void clearDroopCorrectionTables(); + // Exposes the active channel for testing installChannel()'s reapply // behaviour (mode/filter/AGC/shift surviving a rebuild swap) without a // live radio -- matches WdspChannel's own *ForTest accessor convention. @@ -275,6 +330,18 @@ public slots: DcBlocker m_dcBlockL, m_dcBlockR; std::vector m_stereo; // interleaved audio out std::vector m_bins; // spectrum scratch + + // Live droop-correction tables, keyed by DDC0 rate in ksps -- see + // setDroopCorrectionTable(). Survives a rate-change rebuild untouched: + // installRebuiltChannel() swaps m_channel/m_spectrum, not this object, + // and the correction is applied to m_bins after the FFT, independent of + // which WdspChannel produced the IQ that fed it. + QMap m_droopTables; + // See setDroopCorrectionBypassed(). Deliberately NOT cleared by + // clearDroopCorrectionTables(): "am I mid-sweep" is a property of the + // sweep, not of which tables happen to be loaded. + bool m_droopBypassed = false; + [[nodiscard]] const DroopCorrectionTable& droopTableForRate(int rateKsps) const noexcept; }; } // namespace AetherSDR::anan diff --git a/src/core/backends/anan/P2Protocol.h b/src/core/backends/anan/P2Protocol.h index 228eb93c3..e0d8b56cd 100644 --- a/src/core/backends/anan/P2Protocol.h +++ b/src/core/backends/anan/P2Protocol.h @@ -62,6 +62,20 @@ inline constexpr std::uint16_t kHighPriorityPort = 1027; inline constexpr std::uint16_t kDdc0DefaultPort = 1035; // Saturn boards' DSP clock. RFC §2.4; saturnregisters.c VSAMPLERATE. +// The six DDC0 sample rates this radio can run, in ksps, ascending. DDC0 is +// STEPPED, not continuously tunable -- see AnanBackend::nearestDdc0RateKsps(), +// which snaps a zoom request to one of these by ratio distance. +// +// Spelled ONCE, on purpose. Five places consume this set -- the backend's +// capabilities().sampleRatesHz and its pan-bandwidth limits, +// nearestDdc0RateKsps(), AnanRxDsp::setDroopCorrectionTable()'s validity +// check, AnanDroopCalibrator's sweep, and ConnectionPanel's rate picker -- +// and they used to spell it out independently. Adding a seventh rate to some +// of them but not to AnanRxDsp's check would silently reject every +// correction table for the new rate, which presents to the operator as "the +// calibration didn't take" with nothing logged anywhere. +inline constexpr std::array kDdc0RatesKsps{48, 96, 192, 384, 768, 1536}; + inline constexpr std::uint32_t kDspClockHz = 122'880'000; // 24-bit signed full scale. (1<<23)-1, not 1<<23 -- see MetisProtocol.h's diff --git a/src/core/backends/flex/FlexBackend.cpp b/src/core/backends/flex/FlexBackend.cpp index 0bbd7a659..f70e02e44 100644 --- a/src/core/backends/flex/FlexBackend.cpp +++ b/src/core/backends/flex/FlexBackend.cpp @@ -190,6 +190,7 @@ RadioCapabilities FlexBackend::capabilities() const // group on the Receive page, and it is NOT this flag. False here means "the // client does not apply a frequency scalar", which is correct for a Flex. caps.hostFrequencyCalibration = false; + caps.hostDroopCalibration = false; // no known DDC edge droop on this radio // Global / TX / mic profiles are a SmartSDR feature on every current model. caps.hasProfiles = true; caps.hasSelectableMicInputs = true; diff --git a/src/core/backends/hl2/Hl2Backend.cpp b/src/core/backends/hl2/Hl2Backend.cpp index cdda9284c..f77b658d9 100644 --- a/src/core/backends/hl2/Hl2Backend.cpp +++ b/src/core/backends/hl2/Hl2Backend.cpp @@ -1453,6 +1453,11 @@ RadioCapabilities Hl2Backend::capabilities() const // HPSDR map can be told the crystal's real error — so the correction is ours // or it does not happen. See Hl2FreqCal for the derivation. c.hostFrequencyCalibration = true; + // Not yet measured/calibrated for this radio -- see + // RadioCapabilities::hostDroopCalibration's own comment on why "false" + // here is not a claim the HL2's DDC has no droop, only that nothing has + // characterised or corrected one. + c.hostDroopCalibration = false; // Declared because invokeExtension() now implements it (freqcal.get / .set / // .set_live). This field is the handshake a client pre-checks before issuing // an extension call, so leaving it empty while the verbs work would report diff --git a/src/core/backends/sim/SimBackend.cpp b/src/core/backends/sim/SimBackend.cpp index 3f38aa64f..5bbf5d9f6 100644 --- a/src/core/backends/sim/SimBackend.cpp +++ b/src/core/backends/sim/SimBackend.cpp @@ -304,6 +304,7 @@ RadioCapabilities SimBackend::capabilities() const // Synthesised signals come out exactly where the demo says they are; there // is no oscillator to be wrong about. caps.hostFrequencyCalibration = false; + caps.hostDroopCalibration = false; // synthesised bins have no DDC to droop // The simulator has no profile store to list, load or save into. caps.hasProfiles = false; caps.hasSelectableMicInputs = false; diff --git a/src/gui/ConnectionPanel.cpp b/src/gui/ConnectionPanel.cpp index 9cd4b560b..92dd00fbc 100644 --- a/src/gui/ConnectionPanel.cpp +++ b/src/gui/ConnectionPanel.cpp @@ -903,7 +903,7 @@ ConnectionPanel::ConnectionPanel(QWidget* parent) tr("DDC0 sample rate (ksps) -- also the starting width of the panadapter span.\n" "Higher rates use more of the radio's Ethernet link.")); AetherSDR::applyComboStyle(m_manualAnanRateCombo, comboExtraRules); - for (const int ksps : {48, 96, 192, 384, 768, 1536}) + for (const int ksps : anan::kDdc0RatesKsps) m_manualAnanRateCombo->addItem(tr("%1 ksps").arg(ksps), ksps); { const int idx = m_manualAnanRateCombo->findData(anan::AnanSettings::ddc0RateKsps()); diff --git a/src/gui/RadioSetupDialog.cpp b/src/gui/RadioSetupDialog.cpp index 327ca909a..eac6a7f06 100644 --- a/src/gui/RadioSetupDialog.cpp +++ b/src/gui/RadioSetupDialog.cpp @@ -792,6 +792,19 @@ RadioSetupDialog::RadioSetupDialog(RadioModel* model, AudioEngine* audio, if (m_calibrationReseed) m_calibrationReseed(); }); + // Droop Correction page — mirrors the Calibration page immediately above: + // gated on the CAPABILITY (RadioCapabilities::hostDroopCalibration, the + // ANAN-G2 today), not the family name. + QTreeWidgetItem* droopItem = addPage(radioCategory, QStringLiteral("Droop Correction"), + QStringLiteral("droop calibration ddc0 panadapter spectrum sweep decimation edge cic"), + [this] { return buildDroopCalibrationTab(); }); + m_droopCalibrationPageIndex = m_pageIndexes.value(QStringLiteral("Droop Correction")); + droopItem->setHidden(!m_model->backendCapabilities().hostDroopCalibration); + connect(m_model, &RadioModel::connectionStateChanged, this, [this, droopItem] { + droopItem->setHidden(!m_model->backendCapabilities().hostDroopCalibration); + if (m_droopReseed) + m_droopReseed(); + }); addPage(hardwareCategory, QStringLiteral("Antennas"), QStringLiteral("antenna names ant1 ant2 rx in transverter"), [this] { return buildAntennaNamesTab(); }); addPage(hardwareCategory, QStringLiteral("Transverters"), @@ -867,12 +880,14 @@ RadioSetupDialog::RadioSetupDialog(RadioModel* model, AudioEngine* audio, // reaches the radio). const bool apdRow = item == m_pageItems.value(m_apdPageIndex); const bool calRow = item == m_pageItems.value(m_calibrationPageIndex); + const bool droopRow = item == m_pageItems.value(m_droopCalibrationPageIndex); const bool gated = (isFlexOnlyPage(item) && !isCapabilityPageAvailable(item)) || (isGpsPage(item) && !isGpsSetupAvailable()) || (apdRow && !m_model->transmitModel().apdConfigurable()) - || (calRow && !m_model->backendCapabilities().hostFrequencyCalibration); + || (calRow && !m_model->backendCapabilities().hostFrequencyCalibration) + || (droopRow && !m_model->backendCapabilities().hostDroopCalibration); if (!gated) { item->setHidden(!matches); } @@ -947,6 +962,8 @@ void RadioSetupDialog::showEvent(QShowEvent* event) // `freqcal` bridge call, a different radio) has to be re-read here. if (m_calibrationReseed) m_calibrationReseed(); + if (m_droopReseed) + m_droopReseed(); } bool RadioSetupDialog::isFlexOnlyPage(const QTreeWidgetItem* item) const @@ -3418,6 +3435,261 @@ QWidget* RadioSetupDialog::buildCalibrationTab() return page; } +// ── Droop Correction tab ──────────────────────────────────────────────────── + +QWidget* RadioSetupDialog::buildDroopCalibrationTab() +{ + auto* page = new QWidget; + auto* vbox = new QVBoxLayout(page); + vbox->setSpacing(8); + + auto& theme = AetherSDR::ThemeManager::instance(); + auto themed = [&theme](QWidget* w, const QString& tpl) { theme.applyStyleSheet(w, tpl); }; + + static const QString kLabel = + QStringLiteral("QLabel { color: {{color.text.primary}}; font-size: 12px; }"); + static const QString kButton = + QStringLiteral("QPushButton { background: {{color.background.1}}; " + "border: 1px solid {{color.background.2}}; border-radius: 4px; " + "color: {{color.text.primary}}; font-size: 12px; font-weight: bold; " + "padding: 4px 10px; }" + "QPushButton:hover { background: {{color.background.2}}; }" + "QPushButton:disabled { color: {{color.text.secondary}}; }"); + + auto* group = new QGroupBox("DDC0 Droop Correction"); + themed(group, QStringLiteral( + "QGroupBox { border: 1px solid {{color.background.2}}; border-radius: 4px; " + "margin-top: 8px; padding-top: 12px; font-weight: bold; " + "color: {{color.text.secondary}}; }" + "QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; }")); + auto* gvb = new QVBoxLayout(group); + gvb->setSpacing(8); + + { + auto* intro = new QLabel( + "This radio's DDC has a real amplitude droop near the edges of the " + "displayed span, measured here rather than guessed. For the most " + "accurate correction, disconnect the antenna or terminate it in a " + "dummy load before starting — the sweep measures the receiver's own " + "noise floor as a flat reference, and a live signal during the " + "sweep will bias the correction for whichever rate it lands in."); + themed(intro, kLabel); + intro->setWordWrap(true); + gvb->addWidget(intro); + } + + auto* noRadioLbl = new QLabel( + "Connect the radio first. The calibration belongs to one physical " + "radio, and there is no radio identity to store it against yet."); + themed(noRadioLbl, QStringLiteral( + "QLabel { color: {{color.accent.danger}}; font-size: 12px; font-weight: bold; }")); + noRadioLbl->setWordWrap(true); + noRadioLbl->setVisible(false); + gvb->addWidget(noRadioLbl); + + auto* startStopBtn = new QPushButton("Start Sweep"); + themed(startStopBtn, kButton); + startStopBtn->setFixedWidth(120); + // Description, NOT accessibleName: this button's text toggles between + // "Start Sweep" and "Stop", and a screen reader takes a button's name + // from its text unless one is set explicitly. A fixed name here would + // freeze the announcement at "Start sweep" while the button actually + // reads "Stop" -- worse than saying nothing. The description supplements + // the live text instead of replacing it. + startStopBtn->setAccessibleDescription(QStringLiteral( + "Steps the radio through every DDC0 sample rate and measures the " + "panadapter's edge droop at each one. Takes several minutes.")); + + auto* progressBar = new QProgressBar; + progressBar->setRange(0, 100); + progressBar->setValue(0); + progressBar->setTextVisible(false); + progressBar->setFixedHeight(startStopBtn->sizeHint().height()); + // setTextVisible(false) leaves this with no text at all, so without a + // name it is announced as an unlabelled progress bar. + progressBar->setAccessibleName(QStringLiteral("Droop sweep progress")); + + auto* rowLayout = new QHBoxLayout; + rowLayout->addWidget(startStopBtn); + rowLayout->addWidget(progressBar, 1); + gvb->addLayout(rowLayout); + + auto* statusLbl = new QLabel("Idle — no sweep has been run this session."); + themed(statusLbl, kLabel); + statusLbl->setWordWrap(true); + // Same reasoning as the Start button, for the same reason in reverse: a + // QLabel's accessible name IS its text, and this label's text is the + // live sweep status (including the failure reasons the calibrator + // reports). Naming it would hide exactly the content worth hearing. + statusLbl->setAccessibleDescription(QStringLiteral("Droop sweep status")); + gvb->addWidget(statusLbl); + + auto* summaryLbl = new QLabel; + themed(summaryLbl, QStringLiteral( + "QLabel { color: {{color.text.secondary}}; font-size: 11px; " + "font-family: monospace; }")); + summaryLbl->setWordWrap(true); + summaryLbl->setAccessibleDescription(QStringLiteral( + "Measured correction per DDC0 rate")); + gvb->addWidget(summaryLbl); + + auto* applyBtn = new QPushButton("Apply"); + themed(applyBtn, kButton); + applyBtn->setFixedWidth(90); + applyBtn->setEnabled(false); + // Static text, so a name is safe here -- and needed: "Apply" and + // "Discard" alone say nothing about what is being applied or discarded. + applyBtn->setAccessibleName(QStringLiteral("Apply the measured droop correction")); + applyBtn->setToolTip(QStringLiteral( + "Push the measured tables live and save them for this radio")); + + auto* cancelBtn = new QPushButton("Discard"); + themed(cancelBtn, kButton); + cancelBtn->setFixedWidth(90); + cancelBtn->setEnabled(false); + cancelBtn->setAccessibleName(QStringLiteral("Discard the measured droop correction")); + cancelBtn->setToolTip(QStringLiteral("Drop the measured (not yet applied) result")); + + auto* applyRow = new QHBoxLayout; + applyRow->addWidget(applyBtn); + applyRow->addWidget(cancelBtn); + applyRow->addStretch(1); + gvb->addLayout(applyRow); + + vbox->addWidget(group); + + AnanDroopCalibrator& cal = m_model->droopCalibrator(); + + auto refreshSummary = [summaryLbl, &cal] { + const auto& tables = cal.measuredTables(); + if (tables.isEmpty()) { + summaryLbl->clear(); + return; + } + QStringList lines; + for (auto it = tables.constBegin(); it != tables.constEnd(); ++it) { + const auto [minIt, maxIt] = std::minmax_element(it.value().begin(), it.value().end()); + lines << QStringLiteral("%1 ksps: %2–%3 dB correction") + .arg(it.key()) + .arg(*minIt, 0, 'f', 1) + .arg(*maxIt, 0, 'f', 1); + } + summaryLbl->setText(lines.join(QStringLiteral("\n"))); + }; + + QPointer startStopGuard(startStopBtn); + QPointer progressGuard(progressBar); + QPointer statusGuard(statusLbl); + QPointer applyGuard(applyBtn); + QPointer cancelGuard(cancelBtn); + + // error() is always followed by finished(false) — an aborted sweep and a + // refused Apply both report the reason and then wind down. Without this + // latch the finished handler's generic text overwrites the specific + // reason the operator actually needs, so "Error: no spectrum frame at 768 + // ksps…" would flash and be replaced by "Sweep complete — review the + // result below". Cleared wherever a new operation starts. + auto errorLatch = std::make_shared(false); + + connect(&cal, &AnanDroopCalibrator::started, this, + [startStopGuard, statusGuard, errorLatch, &cal] { + *errorLatch = false; + if (startStopGuard) + startStopGuard->setText(QStringLiteral("Stop")); + if (statusGuard) + statusGuard->setText(QStringLiteral("Sweeping — rate 1 of %1…") + .arg(cal.totalRates())); + }); + connect(&cal, &AnanDroopCalibrator::progress, this, + [progressGuard, statusGuard](int rateIndex, int totalRates, int percent) { + if (progressGuard) + progressGuard->setValue(std::clamp(percent, 0, 100)); + if (statusGuard) { + statusGuard->setText(QStringLiteral("Sweeping — rate %1 of %2…") + .arg(rateIndex + 1).arg(totalRates)); + } + }); + connect(&cal, &AnanDroopCalibrator::finished, this, + [startStopGuard, statusGuard, applyGuard, cancelGuard, refreshSummary, errorLatch, + &cal](bool applied) { + if (startStopGuard) + startStopGuard->setText(QStringLiteral("Start Sweep")); + // "live and saved" only when the backend confirmed BOTH — see + // AnanDroopCalibrator::applyResult(), which no longer reports + // success for a radio that went away or a write the store refused. + if (statusGuard && !*errorLatch) { + statusGuard->setText(applied + ? QStringLiteral("Applied — the measured correction is now live and saved.") + : (cal.hasResult() + ? QStringLiteral("Sweep complete — review the result below, then Apply or Discard.") + : QStringLiteral("Sweep stopped — no result to apply."))); + } + if (applyGuard) + applyGuard->setEnabled(cal.hasResult()); + if (cancelGuard) + cancelGuard->setEnabled(cal.hasResult()); + refreshSummary(); + }); + connect(&cal, &AnanDroopCalibrator::error, this, + [statusGuard, errorLatch](const QString& reason) { + *errorLatch = true; + if (statusGuard) + statusGuard->setText(QStringLiteral("Error: %1").arg(reason)); + }); + + connect(startStopBtn, &QPushButton::clicked, this, [&cal, errorLatch] { + *errorLatch = false; + if (cal.isRunning()) + cal.stop(); + else + cal.start(); + }); + connect(applyBtn, &QPushButton::clicked, this, [&cal, errorLatch] { + *errorLatch = false; + cal.applyResult(); + }); + connect(cancelBtn, &QPushButton::clicked, this, + [&cal, applyGuard, cancelGuard, refreshSummary, statusGuard] { + cal.clear(); + if (applyGuard) + applyGuard->setEnabled(false); + if (cancelGuard) + cancelGuard->setEnabled(false); + if (statusGuard) + statusGuard->setText(QStringLiteral("Discarded — no sweep result staged.")); + refreshSummary(); + }); + + // Same reason as m_calibrationReseed: the page is built once per process, + // so anything that changed while it was closed (a different radio, a + // `droopcal` bridge call) has to be re-read here. + QList> droopControls{startStopBtn}; + m_droopReseed = [this, noRadioGuard = QPointer(noRadioLbl), + droopControls, statusGuard, applyGuard, cancelGuard, refreshSummary, &cal] { + const bool haveRadio = !m_model->settingsScope().radioId().isEmpty(); + for (const QPointer& w : droopControls) { + if (w) + w->setEnabled(haveRadio); + } + if (noRadioGuard) + noRadioGuard->setVisible(!haveRadio); + if (applyGuard) + applyGuard->setEnabled(haveRadio && cal.hasResult()); + if (cancelGuard) + cancelGuard->setEnabled(haveRadio && cal.hasResult()); + if (!cal.isRunning() && statusGuard) { + statusGuard->setText(cal.hasResult() + ? QStringLiteral("A measured result is staged — Apply or Discard.") + : QStringLiteral("Idle — no sweep has been run this session.")); + } + refreshSummary(); + }; + m_droopReseed(); + + vbox->addStretch(1); + return page; +} + // ── Audio tab ──────────────────────────────────────────────────────────────── QWidget* RadioSetupDialog::buildAudioTab() diff --git a/src/gui/RadioSetupDialog.h b/src/gui/RadioSetupDialog.h index 4dfb31961..84199c797 100644 --- a/src/gui/RadioSetupDialog.h +++ b/src/gui/RadioSetupDialog.h @@ -113,6 +113,11 @@ class RadioSetupDialog : public PersistentDialog { // hostFrequencyCalibration — the HL2 today). A Flex calibrates itself and // keeps its own Frequency Offset group on the Receive page. QWidget* buildCalibrationTab(); + // Live DDC0 droop-correction sweep, for families with a measured DDC edge + // droop (RadioCapabilities::hostDroopCalibration -- the ANAN-G2 today). + // Mirrors buildCalibrationTab()'s own shape (gated on the capability, not + // the family; a m_droopReseed lambda re-synced the same two ways). + QWidget* buildDroopCalibrationTab(); QWidget* buildAudioTab(); QWidget* buildFiltersTab(); QWidget* buildXvtrTab(); @@ -249,6 +254,9 @@ class RadioSetupDialog : public PersistentDialog { // keeps whatever it read at first build — and the next Trim press would // commit that stale number to whichever radio is connected now. std::function m_calibrationReseed; + int m_droopCalibrationPageIndex{-1}; + // Same reason as m_calibrationReseed above, for the Droop Correction page. + std::function m_droopReseed; QHash m_apdSamplerCombos; // Peripherals tab — savers run on dialog close to persist field edits diff --git a/src/gui/SpectrumWidget.cpp b/src/gui/SpectrumWidget.cpp index 3fe18c52b..11ebd735b 100644 --- a/src/gui/SpectrumWidget.cpp +++ b/src/gui/SpectrumWidget.cpp @@ -13940,14 +13940,25 @@ void SpectrumWidget::renderGpuFrame(QRhiCommandBuffer* cb, // top of it unaffected. Only the CONTENT width (excluding the // dBm strip, which isn't spectrum data) is faded. if (m_edgeTaperEnabled) { - // 0.05 (5% margin per side) -- the same proportion the - // now-reverted bin-crop attempt used, which the operator - // already confirmed looked good on the bench. A later, - // untested guess that HALVING it (2.5%) would look gentler - // was wrong -- the same opacity swing over half the pixel - // distance is a STEEPER ramp, which read as a harder visible - // edge, not a softer one. Back to the confirmed value. - static constexpr double kEdgeTaperFraction = 0.05; + // 0.09 (9% margin per side) -- derived, not guessed: once + // AnanDroopCorrection applies a real per-bin dB correction + // (measured by AnanDroopCalibrator's sweep) to most of + // the span, this fade only needs to cover the residual + // sliver where that correction was CLAMPED -- i.e. the bins + // close enough to the CIC null that boosting them further + // would amplify noise, not recover signal, so they stay + // genuinely uncorrected. The sweep reported a + // clamped fraction of ~0.079-0.082 across all 6 DDC0 rates + // (consistent, since it's the same relative filter shape at + // every rate) -- 0.09 is that worst case plus a small + // margin. fftSize is fixed at 1024 for every rate, and this + // fraction applies uniformly to pixel width, so a bin-count + // fraction and a pixel-width fraction are the same number + // with no unit conversion needed. Superseded value: 0.05, + // from the pre-AnanDroopCorrection era when this fade was + // the ONLY mitigation and had to cover the whole droop + // region, not just its unrecoverable edge. + static constexpr double kEdgeTaperFraction = 0.09; const QColor bg = AetherSDR::ThemeManager::instance().color("color.background.0"); QColor bgOpaque = bg; bgOpaque.setAlpha(255); QColor bgClear = bg; bgClear.setAlpha(0); diff --git a/src/models/RadioModel.cpp b/src/models/RadioModel.cpp index 6356d172e..b7d5a242f 100644 --- a/src/models/RadioModel.cpp +++ b/src/models/RadioModel.cpp @@ -1805,7 +1805,14 @@ void RadioModel::evaluateTxFilterAudioLoss(float scFilt1, float scFilt2) RadioModel::RadioModel(QObject* parent) : QObject(parent) + , m_droopCalibrator(this) { + // A radio disconnecting mid-sweep must not leave the calibrator polling + // a panadapter/rate that no longer exists -- nothing else guards this. + connect(this, &RadioModel::connectionStateChanged, this, [this](bool connected) { + if (!connected) + m_droopCalibrator.stop(); + }); // Register the typed seam-delta payloads so IRadioBackend's normalized // signals survive a queued connection. Today decode*Status runs synchronously // on this thread (AutoConnection → DirectConnection, no metatype needed), but diff --git a/src/models/RadioModel.h b/src/models/RadioModel.h index 1bf2cef74..6da2028ab 100644 --- a/src/models/RadioModel.h +++ b/src/models/RadioModel.h @@ -9,6 +9,11 @@ #include "core/backends/RadioDelta.h" // applyRadioChanges payload (aetherd 2.3) #include "core/backends/RadioCapabilities.h" // backendCapabilities() return type #include "core/backends/IRadioBackend.h" // backendHealthSnapshot() return type +#include "core/backends/anan/AnanDroopCalibrator.h" // droopCalibrator() -- family-agnostic + // mechanically (panFeedSpectrumReady + + // setPanBandwidth only), gated by + // hostDroopCalibration; owned here for + // the same reason TransmitModel is #include "core/RadioConnection.h" #include "core/WanConnection.h" #include "core/PanadapterStream.h" @@ -138,6 +143,7 @@ class RadioModel : public QObject { [[nodiscard]] DataLiveness dataLiveness() const; TunerModel& tunerModel() { return m_tunerModel; } TransmitModel& transmitModel() { return m_transmitModel; } + AnanDroopCalibrator& droopCalibrator() { return m_droopCalibrator; } EqualizerModel& equalizerModel() { return m_equalizerModel; } TnfModel& tnfModel() { return m_tnfModel; } SpotModel& spotModel() { return m_spotModel; } @@ -1637,6 +1643,7 @@ private slots: qint64 m_lastAudioMs{0}; TunerModel m_tunerModel; TransmitModel m_transmitModel; + AnanDroopCalibrator m_droopCalibrator; // see droopCalibrator()'s own comment above EqualizerModel m_equalizerModel; TnfModel m_tnfModel; SpotModel m_spotModel; diff --git a/tests/anan_droop_calibrator_test.cpp b/tests/anan_droop_calibrator_test.cpp new file mode 100644 index 000000000..545933c88 --- /dev/null +++ b/tests/anan_droop_calibrator_test.cpp @@ -0,0 +1,286 @@ +// Pure-math unit tests for AnanDroopCalibrator -- no live radio needed. +// Ports the test cases from this feature's original offline prototype +// (a throwaway offline script, never in this tree, since superseded by this +// in-app engine): outlier rejection via median, central-window reference, +// and clamp behavior at the cap. + +#include "core/backends/anan/AnanDroopCalibrator.h" + +#include "TestSettingsProfile.h" +#include "core/AppSettings.h" +#include "core/RadioSettingsScope.h" + +#include +#include +#include + +#include +#include + +using namespace AetherSDR; + +namespace { + +int fail(const char* message) +{ + std::fprintf(stderr, "anan_droop_calibrator_test: %s\n", message); + return 1; +} + +bool nearlyEqual(float a, float b, float epsilon = 1.0e-4f) +{ + return std::fabs(a - b) <= epsilon; +} + +int testMedianPowerCurveRejectsAStrayOutlierCapture() +{ + // Bin 0: all five captures agree (-10 dB). Bin 1: four quiet captures + // (-10 dB) and one huge outlier (+40 dB, e.g. a stray signal during a + // live-antenna sweep) -- the median must ignore the outlier entirely, + // which a mean could not. + AnanDroopCalibrator::Curve quiet{}; + quiet.fill(-10.0f); + AnanDroopCalibrator::Curve outlier = quiet; + outlier[1] = 40.0f; + + QVector captures{quiet, quiet, quiet, quiet, outlier}; + const AnanDroopCalibrator::Curve curve = AnanDroopCalibrator::medianPowerCurve(captures); + + if (!nearlyEqual(curve[0], -10.0f)) + return fail("bin 0 should stay -10 dB"); + if (!nearlyEqual(curve[1], -10.0f)) + return fail("bin 1 median should reject the outlier"); + return 0; +} + +int testMedianPowerCurvePreservesDeepDroopValues() +{ + // Regression for a real bug: the log10(0) safety floor was originally + // 1e-12 (-120 dB), chosen as "an obviously tiny epsilon" without + // accounting for this radio's actual dynamic range -- bench-measured + // edge droop legitimately reads -160 to -180 dBm, whose linear power is + // SMALLER than that floor, so every deep-droop bin was silently clamped + // UP to exactly -120 dB, destroying the very signal being measured (the + // droop tables came out all-zero at the narrower rates as a result). + // A bin whose captures agree on a much deeper value than -120 dB must + // come back near that deep value, not floored. + AnanDroopCalibrator::Curve deep{}; + deep.fill(-165.0f); + QVector captures{deep, deep, deep, deep, deep}; + const AnanDroopCalibrator::Curve curve = AnanDroopCalibrator::medianPowerCurve(captures); + if (!nearlyEqual(curve[0], -165.0f, 0.5f)) + return fail("a consistent -165 dB bin must not be floored to -120 dB"); + return 0; +} + +int testMedianPowerCurveEmptyIsAllZero() +{ + const AnanDroopCalibrator::Curve curve = + AnanDroopCalibrator::medianPowerCurve(QVector{}); + for (const float v : curve) { + if (v != 0.0f) + return fail("an empty capture set must produce an all-zero curve"); + } + return 0; +} + +int testReferenceLevelUsesCentralWindow() +{ + // Flat at 0 dB in the center, drooping hard at both edges -- the + // reference must come from the flat center, not be dragged down by the + // edges. + AnanDroopCalibrator::Curve curve{}; + curve.fill(0.0f); + for (int k = 0; k < 60; ++k) { + curve[static_cast(k)] = -50.0f; + curve[curve.size() - 1 - static_cast(k)] = -50.0f; + } + const float ref = AnanDroopCalibrator::referenceLevel(curve, 0.15f); + if (!nearlyEqual(ref, 0.0f)) + return fail("reference should read the flat center, not the drooping edges"); + return 0; +} + +int testComputeCorrectionClamps() +{ + AnanDroopCalibrator::Curve curve{}; + curve[0] = 0.0f; + curve[1] = -5.0f; + curve[2] = -20.0f; + curve[3] = -50.0f; + const anan::DroopCorrectionTable table = + AnanDroopCalibrator::computeCorrection(curve, /*referenceDb=*/0.0f, /*capDb=*/15.0f); + if (!nearlyEqual(table[0], 0.0f) || !nearlyEqual(table[1], 5.0f) + || !nearlyEqual(table[2], 15.0f) || !nearlyEqual(table[3], 15.0f)) { + return fail("unexpected correction values"); + } + return 0; +} + +int testComputeCorrectionNeverNegative() +{ + // A bin ABOVE the reference (e.g. a stray signal the median did not + // fully reject) must not produce a negative "correction" that would + // attenuate a healthy bin. + AnanDroopCalibrator::Curve curve{}; + curve[0] = 5.0f; + const anan::DroopCorrectionTable table = + AnanDroopCalibrator::computeCorrection(curve, /*referenceDb=*/0.0f, /*capDb=*/15.0f); + if (!nearlyEqual(table[0], 0.0f)) + return fail("correction must clamp to >= 0"); + return 0; +} + +int testLoadTablesOnInvalidScopeIsEmpty() +{ + const RadioSettingsScope invalidScope; // default-constructed: empty family + const auto tables = AnanDroopCalibrator::loadTables(invalidScope); + if (!tables.isEmpty()) + return fail("an invalid scope must load nothing"); + return 0; +} + +// ---- saveTables(): the write half of the codec ------------------------- +// +// These drive a REAL settings store (TestSettingsProfile redirects it into a +// temporary home), because every one of the rules below is about what ends up +// on disk, and a hand-rolled fake would just re-implement the thing under +// test. + +anan::DroopCorrectionTable tableFilledWith(float base) +{ + anan::DroopCorrectionTable t{}; + for (std::size_t k = 0; k < t.size(); ++k) { + // Exactly representable in both float and double, so a JSON round + // trip is bit-exact and the comparisons below can demand equality. + t[k] = base + 0.25f * static_cast(k % 8); + } + return t; +} + +int testSaveTablesMergesRatherThanReplacing() +{ + const RadioSettingsScope scope(QStringLiteral("anan"), QStringLiteral("AA:BB:CC:00:00:01")); + + const auto t48 = tableFilledWith(1.0f); + const auto t96 = tableFilledWith(2.0f); + if (!AnanDroopCalibrator::saveTables(scope, {{48, t48}, {96, t96}}).isEmpty()) + return fail("a first two-rate save should succeed"); + + // A partial sweep -- blessed by stop() as a real partial improvement -- + // Applies only the rate it measured. That must not erase the others. + const auto t192 = tableFilledWith(3.0f); + if (!AnanDroopCalibrator::saveTables(scope, {{192, t192}}).isEmpty()) + return fail("a later single-rate save should succeed"); + + const auto loaded = AnanDroopCalibrator::loadTables(scope); + if (loaded.size() != 3) + return fail("a partial save must MERGE: all three calibrated rates survive"); + if (!loaded.contains(48) || !loaded.contains(96) || !loaded.contains(192)) + return fail("the merged document keeps 48, 96 and 192 ksps"); + if (loaded.value(48) != t48 || loaded.value(96) != t96) + return fail("the previously calibrated rates keep their measured values"); + if (loaded.value(192) != t192) + return fail("the newly saved rate round-trips exactly"); + return 0; +} + +int testSaveTablesOverwritesOnlyTheRatesItCarries() +{ + const RadioSettingsScope scope(QStringLiteral("anan"), QStringLiteral("AA:BB:CC:00:00:02")); + const auto original = tableFilledWith(4.0f); + const auto replacement = tableFilledWith(9.0f); + if (!AnanDroopCalibrator::saveTables(scope, {{48, original}, {96, original}}).isEmpty()) + return fail("seed save should succeed"); + if (!AnanDroopCalibrator::saveTables(scope, {{48, replacement}}).isEmpty()) + return fail("re-measuring one rate should succeed"); + + const auto loaded = AnanDroopCalibrator::loadTables(scope); + if (loaded.value(48) != replacement) + return fail("the re-measured rate is updated"); + if (loaded.value(96) != original) + return fail("a rate absent from the save is left exactly as it was"); + return 0; +} + +int testSaveTablesRefusesANewerSchema() +{ + const QString radioId = QStringLiteral("AA:BB:CC:00:00:03"); + const RadioSettingsScope scope(QStringLiteral("anan"), radioId); + + // Plant a row written by a hypothetical newer build. Its shape is unknown + // to this one, so merging into it would produce a document that is + // neither version. + QJsonArray future; + for (std::size_t k = 0; k < anan::kDroopCorrectionFftSize; ++k) + future.append(7.5); + const QJsonObject planted{{QStringLiteral("48"), future}}; + if (!AppSettings::instance().setRadioFeature( + QStringLiteral("anan"), radioId, + QLatin1String(AnanDroopCalibrator::kFeature), + AnanDroopCalibrator::kSchemaVersion + 1, planted)) { + return fail("planting a newer-schema row should succeed"); + } + + const QString failure = + AnanDroopCalibrator::saveTables(scope, {{96, tableFilledWith(1.0f)}}); + if (failure.isEmpty()) + return fail("saving over a NEWER schema version must be refused, not merged"); + if (!failure.contains(QStringLiteral("schema"))) + return fail("the refusal names the schema mismatch, so the operator can act on it"); + + // And it must be a refusal, not a partial write. + const auto loaded = AnanDroopCalibrator::loadTables(scope); + if (loaded.contains(96)) + return fail("a refused save writes nothing at all"); + return 0; +} + +int testSaveTablesReportsWhyItDidNotPersist() +{ + const RadioSettingsScope scope(QStringLiteral("anan"), QStringLiteral("AA:BB:CC:00:00:04")); + if (AnanDroopCalibrator::saveTables(scope, {}).isEmpty()) + return fail("saving nothing is reported as a failure, not silent success"); + if (AnanDroopCalibrator::saveTables(RadioSettingsScope(QString(), QString()), + {{48, tableFilledWith(1.0f)}}).isEmpty()) { + return fail("an invalid scope is reported as a failure"); + } + return 0; +} + +} // namespace + +int main(int argc, char** argv) +{ + // Before QCoreApplication and before the first AppSettings touch. + TestSettingsProfile profile(QStringLiteral("aether-anan-droop-calibrator-test")); + if (!profile.isValid()) + return fail("could not create a temporary settings home"); + QCoreApplication app(argc, argv); + AppSettings::instance().load(); + + if (const int result = testMedianPowerCurveRejectsAStrayOutlierCapture(); result != 0) + return result; + if (const int result = testMedianPowerCurveEmptyIsAllZero(); result != 0) + return result; + if (const int result = testMedianPowerCurvePreservesDeepDroopValues(); result != 0) + return result; + if (const int result = testReferenceLevelUsesCentralWindow(); result != 0) + return result; + if (const int result = testComputeCorrectionClamps(); result != 0) + return result; + if (const int result = testComputeCorrectionNeverNegative(); result != 0) + return result; + if (const int result = testLoadTablesOnInvalidScopeIsEmpty(); result != 0) + return result; + if (const int result = testSaveTablesMergesRatherThanReplacing(); result != 0) + return result; + if (const int result = testSaveTablesOverwritesOnlyTheRatesItCarries(); result != 0) + return result; + if (const int result = testSaveTablesRefusesANewerSchema(); result != 0) + return result; + if (const int result = testSaveTablesReportsWhyItDidNotPersist(); result != 0) + return result; + std::printf("anan_droop_calibrator_test: all checks passed\n"); + return 0; +} diff --git a/tests/anan_droop_correction_test.cpp b/tests/anan_droop_correction_test.cpp new file mode 100644 index 000000000..d5ba87e07 --- /dev/null +++ b/tests/anan_droop_correction_test.cpp @@ -0,0 +1,146 @@ +#include "core/backends/anan/AnanDroopCorrection.h" + +#include + +using namespace AetherSDR::anan; + +namespace { + +int fail(const char* message) +{ + std::fprintf(stderr, "anan_droop_correction_test: %s\n", message); + return 1; +} + +int testApplyDroopCorrectionDbAddsElementwise() +{ + DroopCorrectionTable table{}; + table.fill(2.5f); + std::vector bins(kDroopCorrectionFftSize, -10.0f); + applyDroopCorrectionDb(bins, table); + for (const float v : bins) { + if (v != -7.5f) + return fail("expected -10.0f + 2.5f == -7.5f elementwise"); + } + return 0; +} + +int testApplyDroopCorrectionDbNoOpOnSizeMismatch() +{ + DroopCorrectionTable table{}; + table.fill(99.0f); + std::vector bins(kDroopCorrectionFftSize - 1, -10.0f); // wrong size + const std::vector before = bins; + applyDroopCorrectionDb(bins, table); + if (bins != before) + return fail("a size mismatch must leave binsDbfs byte-for-byte unchanged"); + return 0; +} + +int testApplyDroopCorrectionDbZeroFallbackIsNumericallyInert() +{ + std::vector bins(kDroopCorrectionFftSize); + for (std::size_t i = 0; i < bins.size(); ++i) + bins[i] = static_cast(i) - 500.0f; + const std::vector before = bins; + applyDroopCorrectionDb(bins, kDroopCorrectionZero); + if (bins != before) + return fail("the all-zero fallback table must not change any bin"); + return 0; +} + +int testDroopCorrectionZeroIsAllZero() +{ + for (const float v : kDroopCorrectionZero) { + if (v != 0.0f) + return fail("kDroopCorrectionZero must be all-zero"); + } + return 0; +} + +int testApplyEdgeFadeLeavesTheMiddleUntouched() +{ + std::vector bins(kDroopCorrectionFftSize, -110.0f); + const std::vector before = bins; + applyEdgeFade(bins); // default tailFraction=0.03f -> 30 bins/side at 1024 + for (std::size_t i = 30; i < bins.size() - 30; ++i) { + if (bins[i] != before[i]) + return fail("applyEdgeFade must not touch bins outside the tail zone"); + } + return 0; +} + +int testApplyEdgeFadeIsContinuousAtTheBoundary() +{ + std::vector bins(kDroopCorrectionFftSize); + for (std::size_t i = 0; i < bins.size(); ++i) + bins[i] = -110.0f - 0.01f * static_cast(i % 7); // mild per-bin texture + applyEdgeFade(bins); + // Bin 29 (just inside the tail zone, adjacent to the untouched boundary + // at bin 30) must land close to the boundary value -- the whole point + // of the raised-cosine window is zero slope at that seam. + if (std::fabs(bins[29] - bins[30]) > 0.5f) + return fail("left tail must blend smoothly into the untouched boundary bin"); + if (std::fabs(bins[bins.size() - 30] - bins[bins.size() - 31]) > 0.5f) + return fail("right tail must blend smoothly into the untouched boundary bin"); + return 0; +} + +int testApplyEdgeFadeReachesFullFadeAtTheTrueEdge() +{ + std::vector bins(kDroopCorrectionFftSize, -100.0f); + applyEdgeFade(bins, 0.03f, 12.0f); + const float boundary = -100.0f; // untouched, so still the input value + if (std::fabs(bins[0] - (boundary - 12.0f)) > 1.0e-3f) + return fail("bin 0 must land exactly boundary - fadeDb at the true edge"); + if (std::fabs(bins[bins.size() - 1] - (boundary - 12.0f)) > 1.0e-3f) + return fail("the last bin must land exactly boundary - fadeDb at the true edge"); + return 0; +} + +int testApplyEdgeFadeIsMonotonicTowardTheEdge() +{ + std::vector bins(kDroopCorrectionFftSize, -100.0f); + applyEdgeFade(bins); + for (std::size_t i = 1; i < 30; ++i) { + if (bins[i] < bins[i - 1]) + return fail("the left fade must not dip below a bin closer to the true edge"); + } + return 0; +} + +int testApplyEdgeFadeNoOpOnTooSmallAnArray() +{ + std::vector bins(4, -100.0f); + const std::vector before = bins; + applyEdgeFade(bins, 0.5f, 12.0f); // tailFraction*2 would exceed the array + if (bins != before) + return fail("applyEdgeFade must be a no-op when the array is too small for the tail width"); + return 0; +} + +} // namespace + +int main() +{ + if (const int result = testApplyDroopCorrectionDbAddsElementwise(); result != 0) + return result; + if (const int result = testApplyDroopCorrectionDbNoOpOnSizeMismatch(); result != 0) + return result; + if (const int result = testApplyDroopCorrectionDbZeroFallbackIsNumericallyInert(); result != 0) + return result; + if (const int result = testDroopCorrectionZeroIsAllZero(); result != 0) + return result; + if (const int result = testApplyEdgeFadeLeavesTheMiddleUntouched(); result != 0) + return result; + if (const int result = testApplyEdgeFadeIsContinuousAtTheBoundary(); result != 0) + return result; + if (const int result = testApplyEdgeFadeReachesFullFadeAtTheTrueEdge(); result != 0) + return result; + if (const int result = testApplyEdgeFadeIsMonotonicTowardTheEdge(); result != 0) + return result; + if (const int result = testApplyEdgeFadeNoOpOnTooSmallAnArray(); result != 0) + return result; + std::printf("anan_droop_correction_test: all checks passed\n"); + return 0; +} diff --git a/tests/anan_rxdsp_handedness_test.cpp b/tests/anan_rxdsp_handedness_test.cpp index 83f114f4b..588004ef5 100644 --- a/tests/anan_rxdsp_handedness_test.cpp +++ b/tests/anan_rxdsp_handedness_test.cpp @@ -34,12 +34,14 @@ // a WDSP fact, not a Hermes-Lite fact, and this class reuses the identical // DcBlocker code, so it is tested with full confidence, no hedging. +#include "core/backends/anan/AnanDroopCorrection.h" #include "core/backends/anan/AnanRxDsp.h" #include "core/backends/anan/AnanSpectrum.h" #include #include +#include #include #include #include @@ -397,6 +399,358 @@ int main(int argc, char** argv) "installRebuiltChannel() re-applies the operator's CURRENT AGC setting"); } + // ---- Group 5: droop-correction insertion point ---- + // Pins that AnanDroopCorrection's per-bin dB correction lands exactly + // between AnanSpectrum::process() and smoothSpectrumBins()'s EMA -- not + // applied twice, not applied after smoothing. Uses a SYNTHETIC table + // pushed via setDroopCorrectionTable() rather than any bench-measured + // one, so this test is independent of whatever a real calibration sweep + // (AnanDroopCalibrator) or a per-radio settings load happened to + // produce. On a freshly configured AnanRxDsp, smoothSpectrumBins() + // takes its "nothing to blend against yet" branch on exactly the FIRST + // emitted frame (m_smoothedBins starts empty), which passes m_bins + // through untouched -- so the first frame's emitted bins must equal an + // independently computed raw AnanSpectrum frame plus the synthetic + // table, bin for bin. + { + constexpr int kFft = 1024; // matches production (AnanBackend.cpp + // always configures fftSize=1024) and + // the fixed size of every droop table. + + AnanRxDsp::Config cfg; + cfg.inputSampleRateHz = 48000; // -> 48 ksps, a recognized droop rate + cfg.audioSampleRateHz = kAudioRate; + cfg.dspBlockSize = kBlock; + cfg.fftSize = kFft; + cfg.mode = WdspChannel::Mode::Usb; + cfg.filterLowHz = 100.0; + cfg.filterHighHz = 2900.0; + cfg.agcMode = 0; // linear: nothing else rescales the bins + cfg.maximumAgcGainDb = 40.0; + cfg.blockForOutput = true; + + AnanRxDsp dsp; + std::string err; + check(dsp.configure(cfg, &err), + err.empty() ? "droop-correction test: configure() succeeds" : err.c_str()); + + // Synthetic, deliberately non-zero and non-uniform so the test can't + // pass by accident on an all-zero table. + DroopCorrectionTable syntheticTable{}; + for (std::size_t k = 0; k < syntheticTable.size(); ++k) + syntheticTable[k] = 3.25f + 0.01f * static_cast(k % 50); + dsp.setDroopCorrectionTable(cfg.inputSampleRateHz / 1000, + std::vector(syntheticTable.begin(), syntheticTable.end())); + + const std::vector> iq = makeTone(kFft, 37.0, /*conjugate=*/false); + + std::vector emitted; + bool gotFrame = false; + const auto conn = QObject::connect(&dsp, &AnanRxDsp::spectrumReady, + [&](const std::vector& bins) { + if (!gotFrame) { // keep the FIRST frame only + emitted = bins; + gotFrame = true; + } + }); + dsp.processIqBlock(iq); + QObject::disconnect(conn); + check(gotFrame, "the first processIqBlock() call emits one spectrum frame"); + + // Independently computed reference: the SAME conjugation convention + // processIqBlock() applies (raw IQ conjugated once, before the FFT -- + // see processIqBlock()'s own comment on why the spectrum path + // conjugates), fed to a standalone AnanSpectrum with no AnanRxDsp + // involved at all. + std::vector> conjugated(iq.size()); + for (std::size_t k = 0; k < iq.size(); ++k) + conjugated[k] = std::conj(iq[k]); + AnanSpectrum refSpectrum(kFft); + std::vector rawBins; + check(refSpectrum.process(conjugated, rawBins) == 1, + "reference AnanSpectrum produces exactly one frame from the same IQ"); + + const DroopCorrectionTable& table = syntheticTable; + check(emitted.size() == rawBins.size() && emitted.size() == table.size(), + "emitted/reference/table sizes all agree"); + + // applyEdgeFade() now runs right after applyDroopCorrectionDb() for + // any rate with a real (non-zero) table -- which this synthetic one + // is -- so the outermost kTailBins on each side no longer equal + // raw+table exactly; they're the fade's own deterministic curve. + // kTailBins mirrors applyEdgeFade()'s own default tailFraction + // (0.03) at this test's fftSize (1024): static_cast(1024 * + // 0.03f) == 30. + constexpr std::size_t kTailBins = 30; + + std::vector rawPlusTable(rawBins.size()); + for (std::size_t k = 0; k < rawPlusTable.size(); ++k) + rawPlusTable[k] = rawBins[k] + table[k]; + + bool matched = emitted.size() == rawBins.size() && emitted.size() == table.size(); + for (std::size_t k = kTailBins; matched && k < emitted.size() - kTailBins; ++k) { + if (std::fabs(emitted[k] - rawPlusTable[k]) > 1.0e-4f) { + matched = false; + std::fprintf(stderr, + " bin %zu: emitted=%.6f raw+correction=%.6f (raw=%.6f correction=%.6f)\n", + k, emitted[k], rawPlusTable[k], rawBins[k], table[k]); + } + } + check(matched, + "the first emitted frame's non-tail bins equal the raw FFT bins plus the " + "rate's droop correction table, bin for bin -- proving the correction " + "lands between process() and the EMA, not before the FFT and not after " + "smoothing"); + + // Tail bins: applyEdgeFade() run on a copy of raw+table, at the + // same defaults processIqBlock() uses, must equal what was emitted + // -- proving the fade is the SECOND step in the pipeline (after + // the per-bin correction, still before the EMA), not skipped and + // not applied to the raw bins directly. + std::vector expectedTail = rawPlusTable; + applyEdgeFade(expectedTail); + bool tailMatched = expectedTail.size() == emitted.size(); + for (std::size_t k = 0; tailMatched && k < kTailBins; ++k) { + if (std::fabs(emitted[k] - expectedTail[k]) > 1.0e-4f) + tailMatched = false; + const std::size_t ridx = emitted.size() - 1 - k; + if (std::fabs(emitted[ridx] - expectedTail[ridx]) > 1.0e-4f) + tailMatched = false; + } + check(tailMatched, + "the tail bins match applyEdgeFade() run on raw+table, bin for bin -- " + "the cosmetic fade is the second step, not a replacement for the real " + "correction and not skipped"); + } + + // ---- Group 6: rate change picks up the NEW rate's droop table ---- + // Regression for a real bug: installRebuiltChannel() swapped in the new + // WdspChannel/AnanSpectrum but never updated m_config.inputSampleRateHz, + // so droopTableForRate() (which reads m_config.inputSampleRateHz, not + // the rate the new channel was actually built for) kept consulting + // whatever rate was live at the last configure() -- forever, for every + // live rate change after the first. On real hardware this meant a live + // panadapter zoom (which snaps to a new DDC0 rate and rebuilds through + // exactly this path) kept applying the CONNECT-TIME rate's correction + // curve to a different rate's spectrum: visible as a mismatched, lumpy + // rolloff rather than a flat corrected trace. Configure at 48 ksps, + // rebuild to 96 ksps like a live rate change does, and confirm the + // emitted spectrum reflects the 96 ksps table -- not the 48 ksps one and + // not the zero fallback. + { + constexpr int kFft = 1024; + + AnanRxDsp::Config cfg48; + cfg48.inputSampleRateHz = 48000; // -> 48 ksps + cfg48.audioSampleRateHz = kAudioRate; + cfg48.dspBlockSize = kBlock; + cfg48.fftSize = kFft; + cfg48.mode = WdspChannel::Mode::Usb; + cfg48.filterLowHz = 100.0; + cfg48.filterHighHz = 2900.0; + cfg48.agcMode = 0; // linear: nothing else rescales the bins + cfg48.maximumAgcGainDb = 40.0; + cfg48.blockForOutput = true; + + AnanRxDsp dsp; + std::string err; + check(dsp.configure(cfg48, &err), + err.empty() ? "rate-change test: initial 48 ksps configure() succeeds" + : err.c_str()); + + DroopCorrectionTable table48{}; + DroopCorrectionTable table96{}; + for (std::size_t k = 0; k < table48.size(); ++k) { + table48[k] = 3.25f + 0.01f * static_cast(k % 50); + table96[k] = 9.0f + 0.02f * static_cast(k % 37); // distinct shape + } + dsp.setDroopCorrectionTable(48, std::vector(table48.begin(), table48.end())); + dsp.setDroopCorrectionTable(96, std::vector(table96.begin(), table96.end())); + + // Rebuild to 96 ksps exactly as AnanBackend::beginRateChange() does: + // buildChannel() off this object's own thread, then + // installRebuiltChannel() to swap it in. + AnanRxDsp::Config cfg96 = cfg48; + cfg96.inputSampleRateHz = 96000; // -> 96 ksps + AnanRxDsp::RebuildResult result = AnanRxDsp::buildChannel(cfg96); + check(result.channel != nullptr, + result.error.empty() ? "rate-change test: 96 ksps buildChannel() succeeds" + : result.error.c_str()); + check(dsp.installRebuiltChannel(std::move(result)), + "rate-change test: installRebuiltChannel() to 96 ksps succeeds"); + + const std::vector> iq = makeTone(kFft, 37.0, /*conjugate=*/false); + std::vector emitted; + bool gotFrame = false; + const auto conn = QObject::connect(&dsp, &AnanRxDsp::spectrumReady, + [&](const std::vector& bins) { + if (!gotFrame) { + emitted = bins; + gotFrame = true; + } + }); + dsp.processIqBlock(iq); + QObject::disconnect(conn); + check(gotFrame, "rate-change test: processIqBlock() emits a frame after the rebuild"); + + std::vector> conjugated(iq.size()); + for (std::size_t k = 0; k < iq.size(); ++k) + conjugated[k] = std::conj(iq[k]); + AnanSpectrum refSpectrum(kFft); + std::vector rawBins; + check(refSpectrum.process(conjugated, rawBins) == 1, + "rate-change test: reference AnanSpectrum produces exactly one frame"); + + // Skip the tail bins here -- applyEdgeFade() replaces them with its + // own deterministic curve (see Group 5, which already covers that + // math in detail); this group's job is only to confirm the RIGHT + // rate's table drives the non-tail bins after a live rate change. + constexpr std::size_t kTailBins = 30; + bool matched = emitted.size() == rawBins.size() && emitted.size() == table96.size(); + for (std::size_t k = kTailBins; matched && k < emitted.size() - kTailBins; ++k) { + if (std::fabs(emitted[k] - (rawBins[k] + table96[k])) > 1.0e-4f) + matched = false; + } + check(matched, + "after a live rate change, the emitted frame's non-tail bins use the NEW " + "rate's (96 ksps) droop table -- proving m_config.inputSampleRateHz was " + "updated by the rebuild, not left stale at the connect-time 48 ksps"); + } + + // ---- Group 7: the sweep bypass, and forgetting a radio's tables ---- + // Two lifecycle rules the droop feature depends on, both invisible to + // Groups 5 and 6 because both of those only ever measure the armed path. + // + // (a) BYPASS. AnanDroopCalibrator taps the same spectrumReady bins the + // panadapter paints, so it measures its own corrected output unless + // the correction is suspended for the sweep. The subtle half is the + // EDGE FADE: processIqBlock() decides whether to fade by testing + // `&droopTable != &kDroopCorrectionZero`, an IDENTITY test -- so + // "bypass" implemented as "push an all-zero table" would store a + // copy, keep failing that test, and leave the synthetic fade running + // to be measured as if it were hardware droop. The pin below is + // therefore equality with the RAW reference across the WHOLE frame, + // tails included, not just the non-tail bins the other groups check. + // + // (b) CLEAR. m_droopTables outlives any one connection (this object is + // constructed once per backend), so a second radio in the same + // session would render through the first one's corrections. Clearing + // must return the same rate to the true zero path -- again including + // the fade, for the same identity reason. + // + // Each case needs its OWN AnanRxDsp: smoothSpectrumBins() passes only the + // FIRST emitted frame through untouched (m_smoothedBins starts empty), + // and a comparison against a raw reference is only exact on that frame. + { + constexpr int kFft = 1024; + constexpr int kRateKsps = 48; + + AnanRxDsp::Config cfg; + cfg.inputSampleRateHz = kRateKsps * 1000; + cfg.audioSampleRateHz = kAudioRate; + cfg.dspBlockSize = kBlock; + cfg.fftSize = kFft; + cfg.mode = WdspChannel::Mode::Usb; + cfg.filterLowHz = 100.0; + cfg.filterHighHz = 2900.0; + cfg.agcMode = 0; + cfg.maximumAgcGainDb = 40.0; + cfg.blockForOutput = true; + + // Non-uniform and non-zero, so "suppressed" cannot pass by accident. + DroopCorrectionTable syntheticTable{}; + for (std::size_t k = 0; k < syntheticTable.size(); ++k) + syntheticTable[k] = 5.5f + 0.02f * static_cast(k % 40); + const std::vector tableVec(syntheticTable.begin(), syntheticTable.end()); + + const std::vector> iq = makeTone(kFft, 41.0, /*conjugate=*/false); + + // The raw reference: the same conjugation convention processIqBlock() + // uses, through a standalone AnanSpectrum with no AnanRxDsp involved. + std::vector> conjugated(iq.size()); + for (std::size_t k = 0; k < iq.size(); ++k) + conjugated[k] = std::conj(iq[k]); + AnanSpectrum refSpectrum(kFft); + std::vector rawBins; + check(refSpectrum.process(conjugated, rawBins) == 1, + "bypass test: reference AnanSpectrum produces exactly one frame"); + + // Captures the first emitted frame from a freshly configured AnanRxDsp + // after `arrange` has had its way with the droop tables. + const auto firstFrameWith = [&](const std::function& arrange) { + AnanRxDsp dsp; + std::string err; + check(dsp.configure(cfg, &err), + err.empty() ? "bypass test: configure() succeeds" : err.c_str()); + dsp.setDroopCorrectionTable(kRateKsps, tableVec); + arrange(dsp); + std::vector emitted; + bool got = false; + const auto conn = QObject::connect(&dsp, &AnanRxDsp::spectrumReady, + [&](const std::vector& bins) { + if (!got) { emitted = bins; got = true; } + }); + dsp.processIqBlock(iq); + QObject::disconnect(conn); + check(got, "bypass test: processIqBlock() emits one spectrum frame"); + return emitted; + }; + + // `report` only for the checks that EXPECT equality -- the negative + // controls below call this too, and printing their first differing + // bin would decorate a fully passing run with what looks like failure + // output. + const auto equalsRawEverywhere = [&](const std::vector& emitted, + bool report) { + if (emitted.size() != rawBins.size()) + return false; + bool equal = true; + for (std::size_t k = 0; k < emitted.size(); ++k) { + if (std::fabs(emitted[k] - rawBins[k]) > 1.0e-4f) { + if (!report) + return false; + equal = false; + std::fprintf(stderr, " bin %zu: emitted=%.6f raw=%.6f\n", + k, emitted[k], rawBins[k]); + break; + } + } + return equal; + }; + + // Control: armed, the table really does change the bins. Without this + // the two suppression checks below would pass on a broken table push. + const std::vector armed = firstFrameWith([](AnanRxDsp&) {}); + check(!equalsRawEverywhere(armed, /*report=*/false), + "control: with the table armed, the emitted frame differs from raw"); + + const std::vector bypassed = + firstFrameWith([](AnanRxDsp& d) { d.setDroopCorrectionBypassed(true); }); + check(equalsRawEverywhere(bypassed, /*report=*/true), + "with the correction bypassed, the emitted frame equals the raw FFT bins " + "across the WHOLE frame -- neither the per-bin correction nor the edge " + "fade survives, so a calibration sweep measures the radio and not its " + "own corrected output"); + + const std::vector cleared = + firstFrameWith([](AnanRxDsp& d) { d.clearDroopCorrectionTables(); }); + check(equalsRawEverywhere(cleared, /*report=*/true), + "after clearDroopCorrectionTables(), the same rate falls back to the true " + "zero path (fade included) -- a second radio in one session cannot inherit " + "the first one's corrections"); + + // The bypass is a sweep property, not a table property: lifting it + // must bring the SAME tables back without re-pushing them. + const std::vector restored = firstFrameWith([](AnanRxDsp& d) { + d.setDroopCorrectionBypassed(true); + d.setDroopCorrectionBypassed(false); + }); + check(!equalsRawEverywhere(restored, /*report=*/false) && restored == armed, + "lifting the bypass restores the identical corrected frame -- the tables " + "were hidden, never discarded, so an aborted sweep cannot lose a " + "calibration"); + } + if (g_failures == 0) std::fprintf(stderr, "anan_rxdsp_handedness_test: all checks passed\n"); return g_failures == 0 ? 0 : 1; diff --git a/tests/tests.cmake b/tests/tests.cmake index 4f743661f..1f6615292 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -1758,6 +1758,33 @@ target_compile_definitions(rf_gain_presentation_test PRIVATE AETHER_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") add_test(NAME rf_gain_presentation_test COMMAND rf_gain_presentation_test) +# ANAN droop-correction apply math -- pure C++, no Qt dependency at all. +# Table SELECTION/storage now lives in AnanRxDsp (a runtime map, populated +# live by AnanDroopCalibrator or a per-radio settings load), not a compiled +# lookup, so this only covers applyDroopCorrectionDb()/kDroopCorrectionZero. +add_executable(anan_droop_correction_test + tests/anan_droop_correction_test.cpp + src/core/backends/anan/AnanDroopCorrection.cpp +) +target_include_directories(anan_droop_correction_test PRIVATE src) +add_test(NAME anan_droop_correction_test COMMAND anan_droop_correction_test) + +# AnanDroopCalibrator's pure math (median-in-power averaging, central-window +# reference, clamp) -- no live radio needed. Ported from this feature's +# original offline prototype (formerly tools/test_anan_droop_calibration.py, +# since superseded by this in-app engine). Links aethercore (matches +# anan_rxdsp_handedness_test's own pattern) rather than compiling +# AnanDroopCalibrator.cpp/AnanDroopCorrection.cpp a second time -- both +# already live in libaethercore.a, and re-compiling AnanDroopCalibrator.cpp +# here too duplicates its moc-generated QObject symbols (multiple +# definition at link time). +add_executable(anan_droop_calibrator_test + tests/anan_droop_calibrator_test.cpp +) +target_include_directories(anan_droop_calibrator_test PRIVATE src) +target_link_libraries(anan_droop_calibrator_test PRIVATE aethercore Qt6::Core) +add_test(NAME anan_droop_calibrator_test COMMAND anan_droop_calibrator_test) + # Floating-panadapter crash-loop guard (#4617) — pins that a session which died # inside floatPanadapter() comes up docked instead of replaying the crash. add_executable(floating_restore_policy_test