From e342351bd3cbb3d01fcf2675d9999655c87b2f77 Mon Sep 17 00:00:00 2001 From: Sotiris Date: Thu, 20 Aug 2026 01:29:49 -0500 Subject: [PATCH 01/14] fix(anan): compensate the real ANAN-G2 DDC0 edge droop, not just hide it The panadapter trace rolled off near the edges of the displayed span -- real signal/noise attenuation baked into the raw IQ samples by the Saturn FPGA's DDC0 decimation chain (two cascaded CIC decimators plus a halfband FIR, per reference/saturn/New_protocol_FPGA_Block_diagrams.pdf), not a rendering artifact. An earlier session's fix only faded the edge pixels to the background color; the operator explicitly wanted the underlying magnitude corrected. Add a "radiocert spectrum" bring-up phase and a "pan bandwidth " bridge verb to capture live FFT frames at each of the 6 DDC0 rates, and tools/anan_droop_calibration.py to turn a bench sweep (antenna disconnected, dummy load, flat noise-floor reference) into a per-bin dB correction table per rate -- median-in-linear-power across captures, clamped at 15dB so bins near the true CIC null don't get an amplified-noise "correction" applied past the point of being physically recoverable. AnanDroopCorrection applies that table as a straight dB addition in AnanRxDsp::processIqBlock(), right after the FFT and before the existing EMA smoothing. SpectrumWidget's cosmetic edge taper shrinks from a flat 5% guess to 9%, derived from the sweep's own clamped-bin fraction (~8% at every rate) -- now covering only the genuinely unrecoverable sliver instead of the whole droop region. Bench-verified on the real ANAN-G2 with a dummy load: the trace went from a clear rolloff at 1.536 MHz span to essentially flat across the full width, with no zoom regression afterward. Co-Authored-By: Claude Sonnet 5 --- CMakeLists.txt | 1 + docs/automation-bridge.md | 4 +- src/core/AutomationServer.cpp | 35 ++- src/core/RadioCertification.cpp | 99 +++++- src/core/RadioCertification.h | 12 +- .../backends/anan/AnanDroopCorrection.cpp | 32 ++ src/core/backends/anan/AnanDroopCorrection.h | 45 +++ .../anan/AnanDroopCorrectionTables.inc | 37 +++ src/core/backends/anan/AnanRxDsp.cpp | 9 + src/gui/SpectrumWidget.cpp | 27 +- tests/anan_droop_correction_test.cpp | 93 ++++++ tests/anan_rxdsp_handedness_test.cpp | 82 +++++ tests/tests.cmake | 16 + tools/anan_droop_calibration.py | 286 ++++++++++++++++++ tools/test_anan_droop_calibration.py | 182 +++++++++++ 15 files changed, 940 insertions(+), 20 deletions(-) create mode 100644 src/core/backends/anan/AnanDroopCorrection.cpp create mode 100644 src/core/backends/anan/AnanDroopCorrection.h create mode 100644 src/core/backends/anan/AnanDroopCorrectionTables.inc create mode 100644 tests/anan_droop_correction_test.cpp create mode 100644 tools/anan_droop_calibration.py create mode 100644 tools/test_anan_droop_calibration.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 24e2ce3b8..15d5e2dbe 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -713,6 +713,7 @@ 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 (empirical, generated tables) 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/automation-bridge.md b/docs/automation-bridge.md index 83633c95f..d30d7ee82 100644 --- a/docs/automation-bridge.md +++ b/docs/automation-bridge.md @@ -3850,7 +3850,7 @@ The complete registry, generated from the `add(...)` table in `AutomationServer. | `sim` | — | sim [arg] — demo fault injection (RFC #4288; only valid when the demo is connected) | | `record` | — | record [args] | | `testtone` | — | testtone [freqHz levelDb] | -| `pan` | — | pan [value] — float/dock drive PanadapterStack's real reparent path (#4864) | +| `pan` | — | pan [value] — float/dock drive PanadapterStack's real reparent path (#4864); bandwidth requests a DDC0 rate change in ksps and returns once ISSUED, not once it lands | | `workspace` | — | workspace — the canvas, its workspaces and its extra windows as data; arg shapes in docs/automation-bridge.md (#4887 ph4/ph6/ph7) | | `layout` | — | layout \|get> — splitter layout exerciser | | `scale` | — | scale [pct] — report/persist the UI scale factor | @@ -3867,7 +3867,7 @@ The complete registry, generated from the `add(...)` table in `AutomationServer. | `liveness` | — | liveness — per-class data ages and the producer->consumer meter join | | `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) | +| `radiocert` | — | radiocert [freqMhz] — radio bring-up diagnostic, in dependency order (tx/meters key); spectrum captures one FFT frame for calibration tooling and does not key | | `transmit` | — | transmit <0..100> — transmit drive (TX-gated) | | `key` | — | key — semantic keying (TX-gated) | | `station` | — | station — set the GUI-client station name | diff --git a/src/core/AutomationServer.cpp b/src/core/AutomationServer.cpp index 49fd4d1ef..57df283d5 100644 --- a/src/core/AutomationServer.cpp +++ b/src/core/AutomationServer.cpp @@ -3280,8 +3280,9 @@ const std::vector& AutomationServer::verbRegistry() // rfgain branch already splits the joined value and handles both shapes, // so the handler was right and only the parser choice was wrong. add("pan", {}, - "pan [value] — " - "float/dock drive PanadapterStack's real reparent path (#4864)", + "pan [value] — " + "float/dock drive PanadapterStack's real reparent path (#4864); " + "bandwidth requests a DDC0 rate change in ksps and returns once ISSUED, not once it lands", parseActionRest, [](AutomationServer& s, A& a, QLocalSocket*) -> QJsonObject { if (a.action.isEmpty()) @@ -3505,7 +3506,8 @@ const std::vector& AutomationServer::verbRegistry() }); add("radiocert", {}, - "radiocert [freqMhz] — radio bring-up diagnostic, in dependency order (tx/meters key)", + "radiocert [freqMhz] — radio bring-up diagnostic, in dependency order (tx/meters key); " + "spectrum captures one FFT frame for calibration tooling and does not key", parseActionValue, [](AutomationServer& s, A& a, QLocalSocket*) -> QJsonObject { return s.doRadioCert(a.action, a.value); @@ -8344,6 +8346,7 @@ QJsonObject AutomationServer::doRadioCert(const QString& phaseArg, const QString else if (phase == QLatin1String("rx")) opts.phase = RadioCertification::Phase::Rx; else if (phase == QLatin1String("tx")) opts.phase = RadioCertification::Phase::Tx; else if (phase == QLatin1String("meters")) opts.phase = RadioCertification::Phase::Meters; + else if (phase == QLatin1String("spectrum")) opts.phase = RadioCertification::Phase::Spectrum; else if (phase == QLatin1String("all")) opts.phase = RadioCertification::Phase::All; else // FAIL CLOSED. An unrecognised phase used to leave opts.phase at its @@ -8351,7 +8354,7 @@ QJsonObject AutomationServer::doRadioCert(const QString& phaseArg, const QString // `radiocert`, or a typo like `radiocert reciever`, silently started a // multi-minute transmit sequence nobody asked for. return err(QStringLiteral( - "radiocert: unknown phase '%1' — expected tune|rx|tx|meters|all. " + "radiocert: unknown phase '%1' — expected tune|rx|tx|meters|spectrum|all. " "Refusing to default to 'all', which keys the transmitter") .arg(phaseArg.trimmed())); @@ -10359,6 +10362,30 @@ QJsonObject AutomationServer::doPan(const QString& action, const QString& arg) {QStringLiteral("gain"), gain}, {QStringLiteral("requested"), true}}; } + if (action == QLatin1String("bandwidth") || action == QLatin1String("bw")) { + // `pan bandwidth `. Added for the same "hidden control needs a + // direct verb" reason as rfgain above, plus a bring-up-tooling need: + // tools/anan_droop_calibration.py drives a full sweep across a + // DDC-stepped radio's fixed rate set (ANAN-G2: 48/96/192/384/768/ + // 1536 ksps) and there was no bridge verb to select a rate directly + // — only the zoom buttons, which snap to the nearest of a few fixed + // steps and give no way to target a SPECIFIC rate by value. + // + // FIRE-AND-FORGET: on a DDC-stepped radio a rate change can take up + // to ~a minute cold (AnanRxDsp::buildChannel(), a never-before-used + // rate/block-size combination in this process), so this returns once + // the request is ISSUED, not once it lands. Poll + // `get_state model=pan property=bandwidthMhz` or use `wait_for` to + // know when it actually took. + bool okKsps = false; + const double ksps = arg.trimmed().toDouble(&okKsps); + if (!okKsps || ksps <= 0.0) + return err(QStringLiteral("pan bandwidth requires a positive rate in ksps")); + radio->setPanBandwidth(ksps / 1000.0); + return QJsonObject{{QStringLiteral("ok"), true}, {QStringLiteral("pan"), QStringLiteral("bandwidth")}, + {QStringLiteral("requestedKsps"), ksps}, {QStringLiteral("requested"), true}}; + } + if (action == QLatin1String("float") || action == QLatin1String("dock")) { // `pan float ` / `pan dock …` — the reparent + // GPU re-initialize path (#2495/#4319/#4617) made drivable headlessly diff --git a/src/core/RadioCertification.cpp b/src/core/RadioCertification.cpp index 44724ca3b..b0a16ddde 100644 --- a/src/core/RadioCertification.cpp +++ b/src/core/RadioCertification.cpp @@ -956,6 +956,88 @@ void RadioCertification::stageTuning(const Options& o) QStringLiteral("docs/HERMES.md 14.1 — connect-time state")); } +void RadioCertification::stageSpectrum(const Options& o) +{ + // Data capture, not a verdict -- see Phase::Spectrum's own comment. Tunes + // to o.frequencyMhz and grabs exactly one live spectrum frame at + // whatever DDC0 rate/bandwidth the pan is CURRENTLY at. It does NOT + // change rate itself -- a rate change on a DDC-stepped radio can take up + // to ~a minute cold (AnanRxDsp::buildChannel()), and folding a wait for + // that into this stage would make it an outlier against every other + // stage's few-second budget. Drive the rate with the `pan bandwidth + // ` bridge verb and confirm it landed (get_state / wait_for) + // BEFORE calling `radiocert spectrum`. + // + // EVERY CALL MUST PASS AN EXPLICIT freqMhz. Options::frequencyMhz + // defaults to 14.200 and doRadioCert() only overrides it when a value is + // supplied -- an omitted argument silently re-tunes to the default on + // every call, which would corrupt a same-frequency averaging sweep. + if (!m_radio) + return; + auto* slice = m_radio->slice(0); + if (!slice) + return; + + if (o.frequencyMhz > 0.0) { + slice->setFrequency(o.frequencyMhz); + spin(600); + } + + const QString panId = m_radio->panId(); + PanadapterModel* pan = panId.isEmpty() ? nullptr : m_radio->panadapter(panId); + + // Captured at the panFeedSpectrumReady boundary -- the exact signal the + // panadapter paints from -- rather than reaching into AnanRxDsp directly, + // so a calibration capture and what the operator sees agree by + // construction (see tools/anan_droop_calibration.py for how these + // captures get turned into a correction table). + QVector bins; + quint32 streamId = 0; + qint64 tsNs = 0; + bool captured = false; + QMetaObject::Connection conn = QObject::connect( + m_radio, &RadioModel::panFeedSpectrumReady, m_radio, + [&](quint32 id, const QVector& b, qint64 ts) { + if (captured) + return; // keep the FIRST frame after arming, not the latest + streamId = id; + bins = b; + tsNs = ts; + captured = true; + }); + for (int waited = 0; waited < 3000 && !captured; waited += 100) + spin(100); + QObject::disconnect(conn); + + QJsonObject m; + m[QStringLiteral("captured")] = captured; + m[QStringLiteral("streamId")] = static_cast(streamId); + m[QStringLiteral("tsNs")] = static_cast(tsNs); + m[QStringLiteral("centerMhz")] = pan ? pan->centerMhz() : slice->frequency(); + m[QStringLiteral("ddc0RateKsps")] = + pan ? static_cast(pan->bandwidthMhz() * 1000.0 + 0.5) : 0; + m[QStringLiteral("fftSize")] = static_cast(bins.size()); + QJsonArray arr; + for (const float v : bins) + arr.append(static_cast(v)); + m[QStringLiteral("binsDbm")] = arr; + + record(QStringLiteral("spectrum"), + QStringLiteral("One FFT frame at the current DDC0 rate, for offline calibration capture"), + m, + QStringLiteral( + "Captures RadioModel::panFeedSpectrumReady verbatim -- the same " + "bins the panadapter paints. tools/anan_droop_calibration.py " + "issues this repeatedly across frequency and rate and averages " + "offline; this stage takes no average of its own. Change the " + "DDC0 rate with the `pan bandwidth ` verb and wait for it " + "to land BEFORE calling this -- a rate change can take up to " + "~a minute cold, and this stage stays a few-second capture."), + captured ? QString() : QStringLiteral("no spectrum frame arrived within 3 s"), + QStringLiteral("reference/saturn/New_protocol_FPGA_Block_diagrams.pdf " + "'Receiver(3)'; AnanSpectrum::computeFrame()")); +} + void RadioCertification::stageModeMap() { // A mode name the backend does not recognise falls through to a default — @@ -1904,6 +1986,8 @@ QJsonObject RadioCertification::run(const Options& o) const bool doRx = all || o.phase == Phase::Rx; const bool doTx = all || o.phase == Phase::Tx; const bool doMet = all || o.phase == Phase::Meters; + // NOT folded into `all` -- see Phase::Spectrum's own comment. + const bool doSpectrum = o.phase == Phase::Spectrum; // RECEIVE FIRST WHEN BOTH ARE SELECTED, and not for tidiness. The wire's // handedness is ONE fact that both directions consume, and transmit cannot @@ -1973,6 +2057,10 @@ QJsonObject RadioCertification::run(const Options& o) stageMeterInventory(); } + if (doSpectrum) { + stageSpectrum(o); + } + // Unkey, restore and re-tune all happen in `epilogue` above. // What this instrument CANNOT determine, stated as work for a human rather @@ -2041,11 +2129,12 @@ QJsonObject RadioCertification::run(const Options& o) {QStringLiteral("ok"), true}, {QStringLiteral("kind"), QStringLiteral("radio-bringup-diagnostic")}, {QStringLiteral("phase"), - o.phase == Phase::Tune ? QStringLiteral("tune") - : o.phase == Phase::Rx ? QStringLiteral("rx") - : o.phase == Phase::Tx ? QStringLiteral("tx") - : o.phase == Phase::Meters ? QStringLiteral("meters") - : QStringLiteral("all")}, + o.phase == Phase::Tune ? QStringLiteral("tune") + : o.phase == Phase::Rx ? QStringLiteral("rx") + : o.phase == Phase::Tx ? QStringLiteral("tx") + : o.phase == Phase::Meters ? QStringLiteral("meters") + : o.phase == Phase::Spectrum ? QStringLiteral("spectrum") + : QStringLiteral("all")}, {QStringLiteral("note"), QStringLiteral( "Diagnostic only — this deliberately does not pass or fail. Read the " "concerns, then the measurements.")}, diff --git a/src/core/RadioCertification.h b/src/core/RadioCertification.h index fd35e7e78..14235f082 100644 --- a/src/core/RadioCertification.h +++ b/src/core/RadioCertification.h @@ -66,7 +66,11 @@ class RadioCertification { // meters have been validated. Where a stage does use a meter, it labels the // conclusion meterDependent so a failure can be attributed to the right // subsystem instead of the nearest one. - enum class Phase { Tune, Rx, Tx, Meters, All }; + // Spectrum is deliberately NOT folded into All: it is a data-capture tool + // for offline calibration work (e.g. tools/anan_droop_calibration.py), + // not a bring-up verdict stage, and dumping a multi-KB bin array into + // every routine `radiocert all` report would serve nobody. + enum class Phase { Tune, Rx, Tx, Meters, Spectrum, All }; struct Options { Phase phase = Phase::All; @@ -141,6 +145,12 @@ class RadioCertification { void stageModeMap(); void stageTuning(const Options& o); + // ---- calibration-capture stage (no verdict, no key) ---- + // + // Captures one live spectrum frame for offline tooling. NOT a receive + // handedness/bring-up stage -- see the Phase::Spectrum comment above. + void stageSpectrum(const Options& o); + // ---- meter stages, LAST ---- void stageMeterInventory(); void stageMeterScale(const Options& o); diff --git a/src/core/backends/anan/AnanDroopCorrection.cpp b/src/core/backends/anan/AnanDroopCorrection.cpp new file mode 100644 index 000000000..22cf5ab5d --- /dev/null +++ b/src/core/backends/anan/AnanDroopCorrection.cpp @@ -0,0 +1,32 @@ +#include "core/backends/anan/AnanDroopCorrection.h" + +namespace AetherSDR::anan { + +namespace { + +// Generated bulk data, included from this one .cpp only -- mirrors +// SpectralNR.cpp's own #include "Nr2GammaTables.inc" precedent. Regenerating +// the measured tables (tools/anan_droop_calibration.py) is then a drop-in +// file replace with zero CMakeLists.txt changes. +#include "AnanDroopCorrectionTables.inc" + +constexpr DroopCorrectionTable kZeroTable{}; + +} // namespace + +const DroopCorrectionTable& kDroopCorrectionZero = kZeroTable; + +const DroopCorrectionTable& droopCorrectionTableForRateKsps(int ddc0RateKsps) noexcept +{ + switch (ddc0RateKsps) { + case 48: return kDroopTable48Ksps; + case 96: return kDroopTable96Ksps; + case 192: return kDroopTable192Ksps; + case 384: return kDroopTable384Ksps; + case 768: return kDroopTable768Ksps; + case 1536: return kDroopTable1536Ksps; + default: return 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..a47ed3544 --- /dev/null +++ b/src/core/backends/anan/AnanDroopCorrection.h @@ -0,0 +1,45 @@ +#pragma once + +#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 (tools/ +// anan_droop_calibration.py), to the actual FFT magnitude before display. + +inline constexpr int kDroopCorrectionFftSize = 1024; + +using DroopCorrectionTable = std::array; + +// Safe no-op fallback for an unrecognized rate -- additive zero, not a guess. +extern const DroopCorrectionTable& kDroopCorrectionZero; + +// Selects the measured table for a DDC0 rate in ksps (one of the six ANAN-G2 +// values: 48/96/192/384/768/1536). Returns kDroopCorrectionZero for any +// other rate. +[[nodiscard]] const DroopCorrectionTable& droopCorrectionTableForRateKsps(int ddc0RateKsps) noexcept; + +// 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]; +} + +} // namespace AetherSDR::anan diff --git a/src/core/backends/anan/AnanDroopCorrectionTables.inc b/src/core/backends/anan/AnanDroopCorrectionTables.inc new file mode 100644 index 000000000..d11eab58b --- /dev/null +++ b/src/core/backends/anan/AnanDroopCorrectionTables.inc @@ -0,0 +1,37 @@ +// AnanDroopCorrectionTables.inc +// +// DO NOT HAND-EDIT. Generated by tools/anan_droop_calibration.py from a +// bench calibration sweep of the ANAN-G2's real DDC0 CIC/decimation droop +// -- see AnanDroopCorrection.h for what this data is and why it exists. +// +// Generated: 2026-08-20 06:19 UTC +// Capture counts per rate: 48ksps=10, 96ksps=10, 192ksps=10, 384ksps=10, 768ksps=10, 1536ksps=10 +// cap_db=15.0 reference_window_fraction=0.15 +// +// Regenerate by running the calibration sweep against a real ANAN-G2 and +// re-running this script; it overwrites this file. + +constexpr DroopCorrectionTable kDroopTable48Ksps{{ + 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 13.4814f, 13.7061f, 9.9442f, 8.8462f, 10.1477f, 9.6325f, 8.9634f, 10.4437f, 8.6654f, 6.9795f, 5.2079f, 4.9907f, 5.9567f, 2.9512f, 3.5809f, 2.4377f, 1.5084f, 3.2782f, 2.3018f, 2.5213f, 2.1375f, 1.7515f, 1.8822f, 1.7986f, 0.0000f, 0.8545f, 1.8514f, 0.0000f, 0.3275f, 2.1183f, 0.0000f, 2.0391f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.0286f, 1.2709f, 1.1452f, 0.0000f, 0.3953f, 0.0000f, 0.0000f, 0.2547f, 0.1119f, 0.0000f, 0.0000f, 0.3901f, 0.4051f, 0.4374f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5365f, 1.2339f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2854f, 1.5790f, 1.3311f, 0.1192f, 0.5914f, 0.0000f, 0.0000f, 1.4402f, 0.5714f, 1.3873f, 0.0000f, 0.8119f, 0.0000f, 0.0000f, 1.5709f, 0.9763f, 0.0000f, 0.4146f, 0.7661f, 0.8035f, 0.8981f, 0.0000f, 0.0000f, 1.6894f, 0.4603f, 0.0000f, 0.0000f, 0.0000f, 0.5799f, 0.0000f, 0.0000f, 0.9087f, 0.0000f, 0.0000f, 0.6232f, 0.4111f, 0.0000f, 0.5181f, 0.9105f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.2561f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.7764f, 0.0000f, 0.2241f, 1.7655f, 0.0000f, 0.0000f, 0.0000f, 0.9813f, 0.0000f, 0.0000f, 0.0000f, 0.2852f, 0.1343f, 0.0000f, 0.1802f, 1.5928f, 0.6054f, 0.0000f, 0.0000f, 1.0568f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.9272f, 2.3826f, 1.0206f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.0696f, 0.0000f, 0.4335f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.8489f, 0.4151f, 0.0000f, 0.7568f, 1.0306f, 0.8681f, 0.2274f, 0.0000f, 0.0000f, 0.0000f, 1.3235f, 0.3192f, 0.0000f, 0.6371f, 0.1679f, 0.0000f, 0.0000f, 1.7034f, 1.3502f, 0.0000f, 0.0867f, 0.2967f, 1.0657f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.5081f, 1.1954f, 0.9002f, 0.0000f, 0.0000f, 0.0000f, 0.1020f, 1.4047f, 1.6331f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.4093f, 1.2333f, 0.0000f, 0.8796f, 0.3887f, 0.1427f, 0.0000f, 0.0000f, 1.7004f, 0.0000f, 0.0000f, 0.8309f, 1.2677f, 0.3596f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.8850f, 0.0000f, 0.3694f, 0.2233f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1934f, 0.0001f, 0.0000f, 0.0000f, 1.7725f, 2.8815f, 0.4200f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1537f, 0.0000f, 0.1343f, 0.0000f, 0.0000f, 0.2928f, 0.2901f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6328f, 0.9544f, 0.1129f, 0.0000f, 0.0000f, 1.1325f, 0.1224f, 0.0000f, 0.0000f, 0.3487f, 0.1116f, 0.1537f, 0.3738f, 0.9141f, 0.9248f, 0.0000f, 0.0000f, 0.0000f, 0.3904f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.8637f, 0.5274f, 0.0000f, 0.0000f, 0.4233f, 0.0000f, 0.0000f, 2.7165f, 2.2522f, 2.1447f, 0.3597f, 0.4506f, 1.5723f, 0.1221f, 0.0000f, 0.3946f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.3290f, 0.1790f, 0.0000f, 0.0000f, 0.0000f, 0.3195f, 0.4845f, 0.0000f, 0.0000f, 0.0000f, 0.7296f, 1.6897f, 0.4574f, 0.0000f, 0.0587f, 0.3909f, 1.3722f, 0.0000f, 0.9816f, 0.0000f, 0.0000f, 0.6697f, 2.4767f, 0.0000f, 0.6269f, 0.1433f, 0.0242f, 0.7282f, 0.4257f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.0858f, 2.0484f, 0.6292f, 0.0000f, 1.3205f, 0.5922f, 0.2413f, 0.4691f, 1.4521f, 2.7527f, 2.3297f, 1.0402f, 0.0000f, 0.5316f, 0.0000f, 0.7214f, 0.0000f, 2.7410f, 2.0378f, 0.0000f, 1.6422f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3191f, 0.0000f, 0.0000f, 0.3207f, 0.7114f, 0.0000f, 0.0000f, 0.8182f, 0.0000f, 0.6693f, 0.0000f, 3.1545f, 0.0000f, 1.1582f, 0.2741f, 0.0000f, 1.5401f, 2.0635f, 0.4576f, 0.0000f, 0.0000f, 0.4693f, 0.0476f, 0.7485f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1995f, 0.2798f, 0.0000f, 0.0000f, 0.8097f, 0.7858f, 0.9744f, 1.6739f, 1.0348f, 0.0000f, 0.4450f, 0.2571f, 0.0000f, 0.0000f, 0.0000f, 2.2526f, 0.4502f, 0.6580f, 1.8387f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.9736f, 2.2905f, 0.5476f, 0.9130f, 1.4286f, 0.9042f, 1.5106f, 0.0000f, 0.0000f, 0.0000f, 0.0015f, 0.0000f, 1.0526f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3507f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.1788f, 0.0000f, 0.0000f, 2.1553f, 0.1996f, 1.1890f, 0.0000f, 0.3733f, 3.0834f, 1.8103f, 1.3624f, 0.1799f, 3.6393f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0246f, 0.0000f, 0.0000f, 0.0000f, 1.2456f, 5.5362f, 0.6880f, 0.8220f, 0.0000f, 0.0000f, 1.8915f, 0.9694f, 0.0000f, 0.0000f, 0.0000f, 1.0021f, 0.0000f, 0.0000f, 0.1945f, 0.0802f, 0.1615f, 2.5779f, 0.7944f, 0.0000f, 0.0719f, 1.1499f, 0.0000f, 0.0000f, 0.3275f, 0.0000f, 0.0000f, 0.0000f, 0.4266f, 0.6712f, 1.0116f, 0.9160f, 2.5307f, 1.8061f, 2.8731f, 5.6078f, 0.6126f, 0.0000f, 0.1913f, 0.0000f, 0.4990f, 0.9671f, 0.0000f, 0.0000f, 0.2450f, 0.7997f, 3.4113f, 2.5360f, 0.5994f, 0.0000f, 0.0000f, 0.0000f, 0.9872f, 0.0000f, 0.0000f, 0.0000f, 1.1847f, 0.0000f, 0.0000f, 1.3730f, 1.3042f, 1.7753f, 2.3934f, 0.0000f, 0.0000f, 2.1056f, 1.0122f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5558f, 0.0000f, 1.6542f, 0.0000f, 0.0000f, 0.5723f, 1.6999f, 0.0000f, 1.9240f, 0.8187f, 0.0000f, 0.0000f, 0.3299f, 0.0000f, 0.0000f, 0.0000f, 0.1858f, 0.0000f, 0.3209f, 0.3333f, 0.8088f, 0.0000f, 0.2022f, 0.0000f, 0.0000f, 0.0000f, 0.1536f, 0.5918f, 0.7788f, 0.8055f, 0.0000f, 0.0000f, 0.0000f, 1.7658f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0686f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3143f, 0.0000f, 0.7248f, 0.2883f, 1.1078f, 0.5205f, 0.0000f, 1.5274f, 0.3312f, 1.2843f, 1.3244f, 0.0000f, 0.4652f, 2.8653f, 0.0089f, 2.2951f, 1.3296f, 1.7245f, 0.0000f, 2.0020f, 0.1062f, 0.0000f, 0.0000f, 0.6951f, 1.4572f, 0.0000f, 0.0000f, 0.9975f, 0.5313f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.2058f, 1.0465f, 0.0000f, 0.0000f, 0.0000f, 1.1333f, 0.0000f, 0.8242f, 2.3090f, 1.3197f, 0.0000f, 0.1382f, 0.0000f, 0.0000f, 1.0872f, 1.0831f, 0.9807f, 0.7196f, 0.3918f, 0.0000f, 0.0000f, 0.0000f, 0.6927f, 1.2556f, 0.3501f, 0.0000f, 0.0000f, 0.0000f, 0.7120f, 0.0000f, 0.6855f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.7770f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.5974f, 0.7119f, 0.0000f, 2.0012f, 1.9754f, 0.3622f, 0.0000f, 0.2088f, 1.1927f, 0.0000f, 0.0000f, 0.0000f, 0.9614f, 0.5172f, 2.0933f, 1.7097f, 0.0000f, 0.3827f, 0.3978f, 0.0000f, 0.5422f, 1.3462f, 1.3123f, 4.1652f, 0.8818f, 0.3090f, 0.2929f, 0.0971f, 0.0000f, 0.9314f, 0.2774f, 0.0000f, 0.4154f, 0.4188f, 0.7504f, 0.0000f, 0.1407f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4447f, 0.2667f, 0.0000f, 0.0000f, 0.0000f, 0.5908f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.0705f, 0.0000f, 0.0000f, 0.0000f, 0.6932f, 1.1117f, 0.9196f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3626f, 0.0000f, 0.6230f, 0.4436f, 1.1895f, 0.0000f, 0.0000f, 0.6145f, 0.1877f, 1.0683f, 0.8847f, 0.3241f, 1.7458f, 1.6929f, 0.0000f, 0.0000f, 1.7189f, 1.2531f, 0.7474f, 0.0000f, 0.6987f, 0.5521f, 0.2865f, 0.6794f, 0.0000f, 0.0000f, 0.3724f, 0.0000f, 0.0000f, 0.0000f, 0.9045f, 0.5412f, 0.1327f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0116f, 2.5582f, 0.8648f, 0.1433f, 1.2479f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6262f, 0.9060f, 0.4541f, 0.3386f, 0.0572f, 0.0000f, 1.2386f, 0.2120f, 0.0000f, 0.0588f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6671f, 0.0000f, 0.0000f, 1.3253f, 0.0000f, 0.0000f, 0.0000f, 1.2294f, 0.0000f, 1.6020f, 0.0000f, 0.0000f, 0.0000f, 0.0871f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1655f, 1.0817f, 0.7509f, 1.2724f, 0.0000f, 0.0000f, 0.0000f, 0.1177f, 2.3467f, 0.0000f, 0.0000f, 0.4853f, 0.0000f, 0.0000f, 0.0000f, 1.0855f, 0.6105f, 0.0000f, 1.1497f, 1.0484f, 0.8052f, 0.0000f, 0.0000f, 0.0000f, 1.1316f, 0.5436f, 0.4397f, 1.6287f, 0.0000f, 1.0278f, 0.8590f, 0.0000f, 0.0000f, 0.5955f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3844f, 0.0000f, 0.7676f, 1.3622f, 0.6613f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.3372f, 0.4562f, 1.6358f, 0.0000f, 0.0000f, 1.3428f, 1.2318f, 0.0000f, 0.0000f, 0.7421f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0483f, 1.7780f, 2.0880f, 0.0000f, 0.0000f, 2.4526f, 0.5787f, 2.2037f, 0.4281f, 1.7961f, 0.0000f, 0.0000f, 0.0000f, 2.9694f, 0.0000f, 0.7154f, 2.1476f, 0.0000f, 0.0000f, 0.2173f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.7788f, 0.9950f, 0.3746f, 1.3007f, 0.0000f, 2.7281f, 1.5978f, 1.5807f, 0.3432f, 0.2003f, 0.7247f, 2.6125f, 0.5104f, 0.4995f, 1.6437f, 2.0746f, 1.5794f, 2.4803f, 2.4410f, 4.8194f, 6.0348f, 8.2478f, 8.7138f, 5.9977f, 7.4042f, 8.5741f, 10.1969f, 8.5313f, 9.4121f, 11.9739f, 11.0141f, 13.6485f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f +}}; + +constexpr DroopCorrectionTable kDroopTable96Ksps{{ + 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 13.8509f, 14.6278f, 14.8237f, 12.5809f, 10.6154f, 11.0926f, 8.9885f, 9.3248f, 7.4641f, 5.3688f, 5.6834f, 8.6119f, 6.5192f, 6.8565f, 5.8092f, 5.9600f, 2.5771f, 1.2058f, 1.0725f, 0.0000f, 0.0074f, 1.4565f, 2.1469f, 1.2180f, 0.4066f, 0.0000f, 2.0119f, 2.4430f, 1.9182f, 2.9558f, 1.4879f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.2622f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6708f, 0.9661f, 0.0000f, 0.0000f, 0.1659f, 0.5814f, 0.5701f, 1.0692f, 0.0000f, 2.4405f, 0.0000f, 0.4421f, 0.9760f, 0.8149f, 1.7199f, 1.4307f, 0.5546f, 0.3416f, 0.0000f, 0.0000f, 1.0294f, 0.6944f, 0.3819f, 0.0000f, 2.0894f, 1.0175f, 0.8724f, 1.2810f, 0.5486f, 0.8674f, 0.0000f, 0.0000f, 0.0314f, 0.0000f, 0.0000f, 0.0000f, 0.4554f, 0.2317f, 0.0000f, 0.0000f, 1.0062f, 0.0000f, 1.1544f, 1.5590f, 1.2441f, 1.5918f, 0.0000f, 0.0000f, 0.9351f, 0.0000f, 1.9371f, 1.1230f, 1.5963f, 1.3313f, 0.0012f, 0.0000f, 0.0000f, 0.3632f, 0.0000f, 0.0000f, 0.3225f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6058f, 0.8780f, 1.3622f, 0.5257f, 1.0857f, 0.1691f, 0.0000f, 0.8112f, 3.6796f, 1.6966f, 0.0000f, 1.4552f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.9986f, 0.0000f, 1.7950f, 0.0000f, 0.5283f, 0.0000f, 1.3634f, 0.0000f, 0.1286f, 1.0782f, 0.0000f, 0.0000f, 0.6963f, 0.4254f, 0.8947f, 3.1297f, 2.6512f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5393f, 0.0000f, 1.2314f, 1.1096f, 2.0714f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.9442f, 0.3638f, 0.1317f, 0.0000f, 0.4003f, 1.7233f, 1.3430f, 0.1499f, 0.0000f, 0.0000f, 0.9980f, 0.2585f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 3.9180f, 1.1048f, 0.0000f, 1.1503f, 0.2645f, 0.8511f, 2.4109f, 1.6939f, 0.4093f, 0.5477f, 1.6287f, 0.8864f, 0.5798f, 1.4552f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1293f, 1.8168f, 0.5185f, 0.5203f, 1.2780f, 0.4313f, 0.3799f, 0.5982f, 0.0000f, 0.3679f, 0.2263f, 0.8686f, 0.0830f, 0.0000f, 0.2115f, 1.0077f, 0.0000f, 1.1098f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.1944f, 0.0000f, 0.0000f, 0.0000f, 0.5038f, 0.0000f, 0.6506f, 0.9839f, 0.5664f, 0.0000f, 0.2359f, 0.2973f, 0.9596f, 0.0000f, 0.0000f, 1.2047f, 0.7754f, 0.0000f, 0.0000f, 1.7881f, 0.6642f, 0.0000f, 0.0000f, 1.9323f, 0.7069f, 1.0721f, 0.0000f, 0.2176f, 1.3587f, 1.3091f, 0.2498f, 0.0156f, 0.0000f, 0.0000f, 0.3034f, 5.0173f, 2.1688f, 0.0000f, 0.0000f, 0.0959f, 1.1386f, 3.6346f, 0.0000f, 0.0000f, 1.4887f, 1.6210f, 0.7108f, 0.0000f, 0.0000f, 0.0000f, 0.2334f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.2099f, 0.5563f, 0.0000f, 0.0000f, 1.8906f, 2.0153f, 0.0000f, 0.0000f, 0.8582f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.9391f, 0.2009f, 0.0000f, 1.1321f, 0.8699f, 0.0152f, 0.0000f, 1.2603f, 0.1703f, 0.0000f, 1.9084f, 0.7080f, 0.0000f, 1.4190f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4246f, 0.0549f, 0.5181f, 0.0000f, 0.6451f, 1.3411f, 1.4378f, 0.0000f, 0.6831f, 2.0379f, 0.0000f, 0.0000f, 0.0000f, 0.9897f, 0.0000f, 0.0000f, 0.0000f, 1.2609f, 0.0000f, 0.0000f, 1.4463f, 0.0000f, 0.0000f, 0.8256f, 0.7723f, 0.9601f, 0.5640f, 1.0583f, 0.5111f, 0.0000f, 2.2350f, 0.0000f, 0.0000f, 0.6372f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0608f, 0.4967f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.5277f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.2455f, 0.0000f, 0.0000f, 0.7854f, 0.0000f, 1.2673f, 0.3101f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2403f, 0.0000f, 0.0000f, 0.0000f, 0.3125f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5654f, 0.0000f, 0.0000f, 0.0000f, 0.3312f, 0.6139f, 2.0393f, 0.5218f, 0.0000f, 0.0000f, 0.0000f, 0.5905f, 0.0413f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5704f, 0.5495f, 0.0000f, 0.3541f, 1.1437f, 0.0010f, 0.0000f, 0.0114f, 0.3191f, 1.0672f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.9351f, 0.1028f, 0.9468f, 0.0000f, 0.3641f, 0.0000f, 0.0000f, 1.3653f, 0.5575f, 0.0000f, 0.0000f, 0.0000f, 0.8254f, 1.1679f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.4740f, 0.4007f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4954f, 0.5403f, 1.0605f, 0.8943f, 1.4871f, 0.4772f, 0.0000f, 0.7902f, 0.4451f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 3.0876f, 3.3603f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0303f, 1.1580f, 0.1590f, 1.5131f, 0.0574f, 1.1878f, 1.4561f, 0.0000f, 0.0000f, 0.9184f, 0.0000f, 0.0000f, 0.0000f, 0.2225f, 1.0730f, 2.4113f, 3.0249f, 0.8241f, 0.0000f, 0.8256f, 0.0000f, 0.0000f, 0.1376f, 0.4588f, 0.9627f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.5432f, 1.2148f, 0.5174f, 0.0000f, 0.6064f, 0.0000f, 0.8151f, 1.3650f, 0.4419f, 1.0691f, 0.8943f, 0.0000f, 1.2092f, 0.0800f, 1.0962f, 0.0000f, 0.0000f, 1.1994f, 0.0000f, 0.5969f, 0.0000f, 0.3026f, 0.0241f, 0.0000f, 0.2684f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5802f, 2.9080f, 0.8643f, 0.0000f, 0.4315f, 1.7041f, 1.7051f, 1.0172f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1824f, 1.2964f, 0.0000f, 0.0000f, 2.1853f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.0540f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3438f, 1.0668f, 0.2473f, 0.0000f, 0.5134f, 0.0000f, 0.0000f, 0.3456f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2500f, 2.6484f, 1.9879f, 0.1683f, 0.4365f, 0.0000f, 0.7828f, 1.3157f, 1.0659f, 0.9702f, 0.7339f, 0.0000f, 0.0000f, 0.2550f, 0.5240f, 0.8350f, 0.4808f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0165f, 0.0000f, 0.0000f, 0.8068f, 0.2649f, 0.0000f, 0.1021f, 0.0000f, 0.5570f, 0.0000f, 0.0989f, 0.6911f, 0.0000f, 0.0000f, 0.1701f, 1.2804f, 0.2742f, 0.0000f, 0.0000f, 0.0000f, 0.3486f, 0.2130f, 0.1628f, 0.0000f, 0.0000f, 0.0000f, 0.2511f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.8291f, 0.0000f, 0.0000f, 0.0000f, 0.7668f, 0.0000f, 0.0000f, 0.4771f, 0.0000f, 2.0412f, 0.1635f, 1.2004f, 0.0000f, 0.0000f, 1.3358f, 1.7317f, 1.5047f, 0.0000f, 0.0000f, 0.7383f, 0.0000f, 0.0000f, 3.5022f, 1.9164f, 0.0000f, 0.0000f, 0.1298f, 0.1008f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.5788f, 0.0000f, 1.6440f, 0.9735f, 0.0000f, 0.4575f, 0.0000f, 0.0000f, 0.2326f, 0.2777f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.5037f, 1.6166f, 1.2540f, 2.4782f, 0.4844f, 0.0000f, 0.0000f, 1.0020f, 0.0000f, 1.4583f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0956f, 1.0968f, 0.0000f, 1.0347f, 0.4692f, 0.2421f, 0.0000f, 0.7294f, 0.0000f, 1.1397f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4682f, 0.1701f, 0.0000f, 0.2869f, 0.0000f, 0.0000f, 0.5569f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3161f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6422f, 0.0000f, 0.5287f, 0.0000f, 0.4361f, 0.0000f, 0.0000f, 1.3262f, 0.0000f, 1.9334f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4973f, 0.0000f, 0.1625f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0832f, 1.3147f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1169f, 0.2089f, 0.3363f, 0.0000f, 0.0000f, 0.0000f, 1.4614f, 0.3944f, 1.2477f, 0.0000f, 2.0480f, 0.0000f, 0.6817f, 0.6032f, 0.0000f, 1.9554f, 0.6195f, 0.0000f, 0.4273f, 0.3104f, 1.8175f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2606f, 1.0317f, 0.0000f, 0.0000f, 1.7507f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4393f, 0.0000f, 0.7331f, 0.0000f, 0.0000f, 0.0000f, 0.7378f, 0.0000f, 0.0000f, 0.0000f, 0.4918f, 2.4553f, 3.8872f, 0.0000f, 0.9093f, 0.0000f, 0.0000f, 0.0000f, 1.5643f, 0.7658f, 0.0000f, 0.0000f, 1.3147f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0301f, 0.5495f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4158f, 0.4675f, 1.9949f, 2.1069f, 1.0213f, 0.8469f, 0.1672f, 1.5419f, 0.0000f, 2.3526f, 0.0000f, 0.6400f, 0.0000f, 0.0000f, 0.0000f, 1.0002f, 0.0718f, 1.1654f, 0.0806f, 0.0000f, 0.0000f, 0.0000f, 0.8440f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3810f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.9120f, 0.4244f, 0.0000f, 0.0000f, 0.0000f, 0.6703f, 0.0933f, 1.7438f, 0.0000f, 0.0000f, 1.1497f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1602f, 0.7672f, 0.0000f, 0.0000f, 0.0000f, 0.7893f, 0.7738f, 0.3950f, 0.0000f, 0.0000f, 0.0000f, 1.5843f, 2.6319f, 0.4726f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.3123f, 1.4627f, 0.5512f, 0.5202f, 1.1253f, 2.9569f, 2.8600f, 3.1080f, 3.3344f, 3.3178f, 1.9787f, 2.9589f, 5.9053f, 6.9687f, 6.8184f, 7.1891f, 8.3430f, 8.6203f, 7.9073f, 11.6717f, 10.6669f, 10.5488f, 10.9154f, 12.7241f, 14.3078f, 14.6541f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f +}}; + +constexpr DroopCorrectionTable kDroopTable192Ksps{{ + 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 13.6207f, 14.1448f, 12.3424f, 10.7272f, 10.3810f, 9.3742f, 8.7653f, 7.8668f, 7.3557f, 6.9759f, 6.2347f, 5.4071f, 4.4551f, 4.9006f, 4.3405f, 5.8281f, 3.4548f, 3.8142f, 1.9483f, 2.2179f, 1.0758f, 1.8385f, 2.2758f, 2.0111f, 1.5552f, 0.0211f, 0.0647f, 1.5927f, 1.1501f, 0.0000f, 0.0753f, 0.0000f, 0.0000f, 0.0000f, 0.8247f, 0.8178f, 0.1070f, 0.4797f, 1.8207f, 0.1578f, 0.9324f, 0.0000f, 0.0000f, 2.4778f, 0.0000f, 0.0000f, 0.0000f, 1.9030f, 0.0000f, 0.0000f, 0.5852f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.7432f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.2332f, 0.2851f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.8388f, 0.7314f, 0.0000f, 0.0000f, 0.4790f, 0.2835f, 0.0000f, 0.0668f, 0.0000f, 0.0000f, 0.0000f, 0.9198f, 0.1646f, 0.0000f, 0.6380f, 2.1151f, 1.2369f, 1.6079f, 1.2332f, 0.0873f, 0.0000f, 0.4813f, 1.9377f, 0.5694f, 0.0000f, 2.1981f, 0.0000f, 1.3807f, 0.0000f, 0.0000f, 0.3830f, 0.0000f, 0.0541f, 0.9246f, 0.9066f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2279f, 0.0000f, 0.5042f, 0.1591f, 0.0000f, 0.1005f, 2.9607f, 0.5275f, 0.0000f, 0.0000f, 2.0738f, 1.2474f, 0.0000f, 0.5528f, 0.0000f, 0.3265f, 1.3301f, 0.7342f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4219f, 2.5789f, 1.8661f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.0288f, 0.0000f, 1.0106f, 0.3502f, 1.1205f, 0.7924f, 1.5582f, 0.0143f, 0.8433f, 0.0717f, 1.4608f, 0.6603f, 1.1092f, 0.0000f, 1.2516f, 0.0000f, 0.0000f, 0.0000f, 0.5860f, 0.0000f, 0.2532f, 0.0000f, 1.6306f, 0.0000f, 0.1748f, 2.4043f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2694f, 0.2335f, 0.0000f, 0.0000f, 1.3838f, 0.2805f, 1.3463f, 0.0090f, 0.0000f, 2.6160f, 0.3683f, 0.0000f, 0.0000f, 0.0000f, 0.4145f, 0.5570f, 1.7539f, 0.7134f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.4750f, 0.0652f, 1.0957f, 0.0544f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.7548f, 0.0000f, 0.8685f, 0.0000f, 0.0000f, 1.1817f, 1.2302f, 0.0000f, 0.5969f, 0.0000f, 0.5719f, 1.9289f, 1.3419f, 1.1701f, 0.5990f, 0.7639f, 0.1162f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.0828f, 0.0000f, 0.4361f, 1.1102f, 0.1780f, 0.2734f, 0.4447f, 2.1252f, 0.3566f, 0.8746f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.0176f, 1.7349f, 0.3321f, 0.1678f, 1.7679f, 1.1803f, 3.1730f, 0.0000f, 0.0000f, 2.8390f, 1.1457f, 0.1635f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.7560f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6867f, 0.6884f, 1.3344f, 0.6792f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.1017f, 2.2320f, 0.0000f, 0.0169f, 0.0000f, 0.0000f, 0.0940f, 2.9506f, 0.5271f, 0.7201f, 0.0000f, 0.6975f, 0.4853f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4733f, 0.0000f, 0.2344f, 0.0000f, 0.0000f, 0.0000f, 1.0361f, 1.9852f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.0596f, 0.1390f, 0.2718f, 0.4929f, 0.5141f, 1.5737f, 0.0000f, 0.0000f, 0.0000f, 1.7800f, 0.0000f, 0.1000f, 0.0000f, 0.0000f, 1.0403f, 0.1831f, 1.9964f, 1.7641f, 2.8640f, 1.1193f, 2.0952f, 0.0581f, 0.0000f, 0.0000f, 2.6574f, 0.0000f, 0.0000f, 1.3939f, 0.9218f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0003f, 0.0000f, 1.3367f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5637f, 2.0165f, 0.0000f, 0.0000f, 0.0000f, 0.8469f, 1.4226f, 1.1526f, 1.2911f, 0.0276f, 0.0000f, 1.9379f, 0.0606f, 0.0000f, 0.0000f, 1.0231f, 0.0000f, 0.7176f, 1.7508f, 0.4385f, 1.8774f, 0.0000f, 1.4353f, 0.0000f, 1.9887f, 0.6914f, 0.0000f, 0.0000f, 0.7999f, 1.7408f, 0.0000f, 0.0000f, 0.0000f, 1.0063f, 0.0000f, 1.0704f, 0.4491f, 0.1319f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4513f, 0.6252f, 0.3997f, 0.0000f, 0.0000f, 0.0000f, 0.3291f, 0.3652f, 0.7696f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2371f, 1.4720f, 0.3703f, 0.0000f, 0.0000f, 0.0000f, 2.1427f, 1.5538f, 0.0000f, 0.0000f, 0.0000f, 0.6342f, 0.0000f, 0.4226f, 0.0000f, 0.8209f, 0.3598f, 0.9413f, 0.7286f, 0.8189f, 0.0000f, 0.0000f, 0.6079f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3212f, 0.0000f, 0.1119f, 0.0000f, 0.3478f, 0.7983f, 0.1368f, 0.0000f, 0.0000f, 0.0000f, 0.2376f, 1.4456f, 1.2865f, 0.0000f, 0.0000f, 0.0100f, 0.2453f, 0.2529f, 0.0000f, 0.5463f, 0.3652f, 0.0000f, 2.0853f, 0.0000f, 0.0000f, 0.0000f, 1.0207f, 1.9745f, 5.2984f, 3.3544f, 0.0000f, 0.0000f, 0.2092f, 1.6258f, 0.4643f, 1.2647f, 0.0000f, 3.2759f, 1.8847f, 1.2488f, 0.9878f, 0.0000f, 0.8835f, 0.1729f, 0.0000f, 0.6878f, 0.2924f, 0.0000f, 2.1776f, 2.0143f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.7925f, 1.3123f, 0.5893f, 0.6951f, 1.0861f, 0.0000f, 0.2151f, 0.0000f, 0.0000f, 0.4928f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.2026f, 0.3834f, 1.2583f, 1.2827f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5837f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.1948f, 1.1607f, 0.6703f, 1.1988f, 0.9356f, 0.6100f, 2.7993f, 0.9102f, 0.0000f, 0.0000f, 0.0000f, 0.8922f, 0.3200f, 0.0000f, 0.0000f, 0.0000f, 0.0274f, 0.0508f, 1.0589f, 0.8522f, 0.0000f, 1.4311f, 0.3242f, 0.5747f, 0.3643f, 0.0000f, 0.0000f, 0.0000f, 0.1023f, 0.3575f, 0.0000f, 1.8916f, 0.0000f, 0.3100f, 0.4591f, 1.1732f, 0.2562f, 0.0000f, 0.6025f, 0.9677f, 0.0300f, 0.0000f, 0.1037f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.9880f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3272f, 0.0000f, 0.7065f, 0.0000f, 0.0000f, 0.0000f, 0.6709f, 0.2147f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.7268f, 0.2723f, 0.2686f, 3.6536f, 0.7623f, 0.0000f, 0.8168f, 1.9961f, 0.0000f, 0.0000f, 0.4301f, 0.0000f, 0.0679f, 1.3082f, 0.5894f, 0.0000f, 0.0000f, 0.0000f, 0.2404f, 0.0000f, 0.8783f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.6669f, 0.6676f, 0.0000f, 0.0000f, 0.0000f, 0.4505f, 0.2317f, 0.0000f, 0.5735f, 0.0000f, 0.0000f, 1.8045f, 0.0000f, 0.0000f, 0.8905f, 0.2496f, 0.0000f, 0.0000f, 0.0210f, 0.8840f, 0.3182f, 0.0000f, 0.0000f, 0.4518f, 2.0963f, 0.0000f, 1.9294f, 0.0000f, 0.2320f, 1.0945f, 0.0000f, 0.0997f, 0.2711f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4715f, 0.0000f, 1.7974f, 0.6601f, 0.1798f, 0.0000f, 0.5395f, 0.0000f, 0.0000f, 1.5494f, 0.0000f, 0.6658f, 0.0000f, 1.0416f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2511f, 0.3974f, 1.5720f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5022f, 1.6102f, 0.6656f, 0.0000f, 1.5518f, 0.3789f, 0.0000f, 2.4646f, 0.4117f, 1.1314f, 0.0000f, 2.2533f, 0.0000f, 0.8809f, 0.0000f, 0.4724f, 0.0000f, 0.5745f, 0.2528f, 0.2414f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2593f, 0.0000f, 0.0000f, 0.0000f, 0.2939f, 0.0000f, 0.3825f, 1.4359f, 0.0473f, 0.0262f, 1.0999f, 0.0000f, 0.4263f, 0.0518f, 1.1855f, 0.1615f, 0.0000f, 0.0000f, 0.0000f, 2.1261f, 0.0000f, 0.0000f, 0.0000f, 1.6171f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2023f, 1.7694f, 1.5025f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.7487f, 0.3676f, 0.0000f, 0.0276f, 0.0000f, 0.0000f, 0.0000f, 0.3040f, 0.4028f, 0.8727f, 0.0000f, 0.0000f, 1.0491f, 0.0000f, 0.0000f, 0.0000f, 1.0127f, 0.1536f, 0.0000f, 0.1919f, 0.6662f, 0.0656f, 2.1428f, 0.0000f, 3.4138f, 2.0196f, 0.1173f, 0.0000f, 0.0000f, 0.0000f, 0.4064f, 0.0000f, 0.3162f, 1.5687f, 1.2770f, 0.0000f, 0.0000f, 0.9062f, 1.1399f, 1.0026f, 0.0000f, 1.0010f, 1.8478f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0280f, 0.4899f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.7397f, 0.8152f, 0.1755f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.7716f, 0.3577f, 0.0785f, 0.0000f, 0.5533f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.3312f, 0.0000f, 0.0717f, 0.3558f, 2.2713f, 3.5197f, 0.2315f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0113f, 0.1770f, 1.5452f, 0.8795f, 0.0000f, 0.0000f, 1.0073f, 0.9004f, 1.7056f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.7579f, 2.6532f, 0.9141f, 0.0000f, 0.7376f, 0.0000f, 0.1483f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.8873f, 0.0000f, 1.2889f, 0.0000f, 0.2711f, 0.9446f, 1.2136f, 1.4821f, 0.3810f, 0.0941f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1719f, 0.0379f, 1.5140f, 0.7694f, 0.8859f, 0.2108f, 0.3443f, 0.0000f, 0.0684f, 1.9621f, 1.0580f, 0.0000f, 1.3883f, 0.8511f, 0.0000f, 0.0000f, 0.7644f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3795f, 1.5631f, 0.0000f, 0.0000f, 0.8696f, 0.9457f, 1.1767f, 0.8485f, 1.5642f, 0.1108f, 1.8149f, 2.4111f, 2.9222f, 3.9341f, 3.2645f, 3.9720f, 4.3526f, 5.8608f, 5.2886f, 5.8211f, 8.8346f, 6.9358f, 6.5874f, 9.5157f, 9.3837f, 9.8434f, 10.9590f, 11.0693f, 13.5683f, 13.5300f, 14.0887f, 14.4369f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f +}}; + +constexpr DroopCorrectionTable kDroopTable384Ksps{{ + 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 13.4635f, 12.5058f, 11.9880f, 10.6686f, 9.5310f, 9.7662f, 9.5666f, 10.0386f, 9.0205f, 7.5873f, 7.6019f, 6.9870f, 7.1159f, 5.9678f, 4.6689f, 3.6661f, 3.7944f, 3.2215f, 5.3016f, 3.4910f, 1.9753f, 2.2990f, 1.4351f, 0.5857f, 2.4500f, 0.9881f, 0.0000f, 0.9037f, 1.2105f, 0.0000f, 0.8231f, 0.0000f, 2.0671f, 0.4348f, 1.8088f, 0.1565f, 0.4818f, 1.0210f, 1.0155f, 1.4261f, 0.4976f, 1.4822f, 0.0000f, 0.0000f, 0.6635f, 0.0000f, 0.6501f, 0.0000f, 2.0670f, 0.0000f, 0.1829f, 0.0000f, 0.0000f, 0.0003f, 0.4836f, 0.8217f, 0.0000f, 3.0912f, 0.3549f, 0.0000f, 0.0000f, 0.5837f, 2.3157f, 0.0000f, 0.0000f, 1.8315f, 0.8455f, 0.0000f, 0.0000f, 0.7196f, 0.0000f, 0.3021f, 0.1343f, 1.1060f, 0.0000f, 0.5988f, 0.0000f, 1.5520f, 0.4723f, 0.4160f, 0.2795f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3516f, 0.0000f, 0.0000f, 1.3277f, 0.4805f, 0.8792f, 0.0000f, 0.6142f, 0.5317f, 0.7247f, 0.0000f, 0.8651f, 0.0000f, 1.3643f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6222f, 0.2590f, 0.0000f, 0.0000f, 0.0000f, 2.1008f, 0.0000f, 0.0000f, 0.6334f, 0.0000f, 1.1525f, 0.0000f, 1.1350f, 0.3035f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.1448f, 1.7326f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.6439f, 0.5870f, 0.2294f, 1.3260f, 1.5031f, 0.5538f, 0.0384f, 0.1481f, 0.4662f, 0.9672f, 1.3469f, 0.0000f, 0.4503f, 1.3546f, 0.0000f, 0.0000f, 1.2043f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4256f, 0.0512f, 0.7112f, 1.3782f, 0.0000f, 0.0468f, 0.5491f, 0.1265f, 0.0000f, 0.8048f, 0.0000f, 0.9366f, 1.7536f, 0.0000f, 0.6568f, 1.9011f, 0.1709f, 1.4159f, 0.0000f, 0.0000f, 0.7871f, 0.0000f, 0.0000f, 0.0000f, 0.8685f, 0.0000f, 1.1675f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1867f, 0.4805f, 1.6009f, 0.0731f, 0.0000f, 0.0000f, 0.3293f, 1.2269f, 0.0000f, 0.0712f, 0.0000f, 0.9722f, 0.0000f, 0.7610f, 0.0318f, 0.0000f, 0.6953f, 1.1950f, 0.0831f, 1.3416f, 0.7298f, 0.5992f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.1486f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6703f, 0.0000f, 0.0000f, 0.0000f, 0.7937f, 1.8581f, 1.4335f, 0.0000f, 0.0000f, 0.6772f, 0.9687f, 0.0000f, 0.8885f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3600f, 0.0000f, 0.0000f, 0.4297f, 0.3968f, 0.0000f, 0.7992f, 1.1338f, 1.3845f, 1.1167f, 0.1656f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.8545f, 1.4045f, 1.1217f, 0.0000f, 0.7387f, 1.0324f, 0.0000f, 1.2607f, 2.5404f, 0.4334f, 0.0000f, 1.1235f, 0.0000f, 0.0000f, 1.0727f, 0.0000f, 0.0220f, 0.0000f, 0.0000f, 0.5134f, 0.0000f, 2.1127f, 0.6089f, 0.0000f, 0.9004f, 0.0000f, 0.0000f, 0.0000f, 0.8815f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1786f, 0.1288f, 0.3074f, 1.3870f, 0.0000f, 0.0000f, 0.4376f, 0.2430f, 0.0000f, 0.0000f, 0.0000f, 0.4601f, 0.9945f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.8354f, 1.5910f, 0.2107f, 0.7486f, 0.0000f, 0.0000f, 0.1998f, 0.0000f, 0.0000f, 1.4013f, 1.8425f, 0.7241f, 0.2329f, 0.0000f, 0.5686f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2106f, 0.0000f, 0.0842f, 0.0000f, 0.4040f, 0.3277f, 0.0000f, 0.2565f, 1.0654f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3023f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6056f, 0.3778f, 0.0000f, 0.3564f, 0.0000f, 0.0000f, 0.7691f, 0.7645f, 0.7074f, 1.3329f, 0.5421f, 1.3079f, 0.0000f, 0.0000f, 0.0000f, 1.4767f, 0.0000f, 1.3246f, 0.9519f, 0.0000f, 0.6511f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2103f, 2.3671f, 0.0000f, 2.6074f, 0.7049f, 0.0000f, 1.1406f, 0.0000f, 0.8049f, 0.2650f, 0.0000f, 1.8585f, 0.2326f, 0.0000f, 2.0243f, 1.4831f, 0.0000f, 0.0000f, 0.4871f, 0.0000f, 0.0000f, 0.9715f, 0.0000f, 0.0000f, 1.2605f, 0.1466f, 0.0101f, 0.0000f, 0.0344f, 0.7328f, 2.2687f, 1.7489f, 0.5113f, 2.2390f, 1.1618f, 1.1205f, 1.3570f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.1142f, 0.0886f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6155f, 0.7946f, 0.9698f, 0.0000f, 0.2504f, 1.1206f, 0.2359f, 0.3918f, 0.0000f, 0.0510f, 0.0000f, 0.0000f, 0.7299f, 0.0000f, 0.0000f, 1.6021f, 0.1638f, 0.0000f, 0.4808f, 0.0000f, 0.0459f, 0.5274f, 0.0000f, 0.0000f, 0.0000f, 0.0465f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.0953f, 3.4191f, 1.7060f, 0.7915f, 1.3640f, 2.0848f, 0.4540f, 1.1554f, 0.0000f, 2.2736f, 0.7144f, 1.3352f, 4.5830f, 1.7642f, 0.2400f, 0.0000f, 0.2503f, 1.6223f, 0.0000f, 0.0000f, 0.2725f, 0.4261f, 0.0000f, 1.0149f, 0.5214f, 0.0000f, 2.2845f, 2.8058f, 3.5874f, 1.5777f, 0.0000f, 0.0000f, 0.0000f, 0.0534f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0979f, 1.7116f, 0.4519f, 0.0000f, 0.0283f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0904f, 0.0000f, 0.0000f, 0.0000f, 1.2309f, 0.0000f, 0.0000f, 2.7855f, 2.0727f, 1.1436f, 0.0257f, 0.9517f, 0.0000f, 0.2032f, 0.6983f, 1.1623f, 0.0000f, 0.1570f, 0.0000f, 0.0000f, 0.0000f, 0.2112f, 0.0000f, 0.5036f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.3945f, 0.2529f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0293f, 1.4062f, 0.0000f, 0.0000f, 1.2033f, 2.1968f, 1.4955f, 0.0000f, 0.1649f, 0.3132f, 0.1823f, 0.4113f, 0.0000f, 0.5244f, 0.8394f, 0.0000f, 1.0477f, 0.2994f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6855f, 0.3979f, 0.0000f, 0.0000f, 0.9171f, 0.5752f, 0.6346f, 0.7820f, 0.0000f, 0.1413f, 1.3673f, 2.3768f, 0.5994f, 1.7277f, 0.6516f, 0.0000f, 0.7587f, 0.0000f, 0.4478f, 0.0000f, 0.0000f, 0.0000f, 2.6363f, 0.0000f, 1.7034f, 0.0000f, 0.0408f, 1.7640f, 0.0000f, 1.3069f, 0.2052f, 0.9868f, 0.6158f, 2.1244f, 0.0000f, 0.0000f, 0.2779f, 2.9000f, 0.9556f, 0.0000f, 0.0000f, 0.0000f, 2.3953f, 0.2698f, 0.0000f, 0.0000f, 0.3636f, 0.1903f, 1.7160f, 1.3200f, 0.0000f, 1.7476f, 1.0827f, 1.9123f, 0.0000f, 0.0000f, 0.0000f, 0.2142f, 0.0000f, 0.6255f, 0.0000f, 0.8268f, 0.5161f, 0.0000f, 1.3426f, 2.2358f, 0.0000f, 0.0000f, 0.7872f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6621f, 0.1657f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0104f, 0.0000f, 1.4736f, 0.0000f, 0.1036f, 1.4822f, 0.0000f, 0.0000f, 2.1354f, 0.0441f, 0.3341f, 0.0000f, 0.4294f, 0.8371f, 0.8224f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2167f, 0.0478f, 0.6713f, 0.2080f, 0.0000f, 1.1899f, 0.0000f, 0.3360f, 1.0095f, 0.0000f, 2.0369f, 1.4410f, 0.0000f, 0.0000f, 0.7418f, 0.0000f, 0.0000f, 0.6099f, 0.2455f, 0.4465f, 0.0000f, 0.8342f, 2.0218f, 0.0907f, 2.1132f, 0.0000f, 1.5552f, 0.3229f, 0.0000f, 0.0000f, 1.4025f, 0.0000f, 0.0000f, 1.1281f, 1.2729f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.0064f, 0.0000f, 0.0769f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.2345f, 0.0000f, 0.9329f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0438f, 0.0000f, 0.1070f, 1.3298f, 0.0000f, 0.0000f, 0.0000f, 1.4592f, 0.7297f, 0.0000f, 0.0000f, 0.1966f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1781f, 1.6125f, 0.1535f, 1.2032f, 0.8126f, 0.1157f, 0.6911f, 0.0409f, 1.7907f, 1.4563f, 0.1943f, 1.5829f, 0.0000f, 0.0000f, 1.4278f, 0.9773f, 1.0298f, 0.0000f, 0.0000f, 1.4553f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.0917f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1395f, 0.0000f, 0.8450f, 0.6014f, 1.2657f, 0.2577f, 1.0255f, 0.0000f, 1.0423f, 0.0000f, 1.7019f, 0.3428f, 0.0000f, 0.4204f, 0.2321f, 0.2138f, 0.0000f, 0.0000f, 2.6167f, 0.0000f, 0.0000f, 0.9562f, 0.0000f, 0.0000f, 0.3626f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2889f, 0.0000f, 0.3524f, 1.3459f, 1.4066f, 0.7932f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1036f, 1.8165f, 2.2420f, 0.0311f, 0.0000f, 1.4934f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.2047f, 1.0220f, 0.0000f, 0.0000f, 0.0000f, 0.8594f, 0.5690f, 0.1281f, 0.1861f, 1.6732f, 0.3260f, 0.0000f, 0.0000f, 0.0000f, 1.2133f, 0.0000f, 0.0000f, 0.8206f, 0.0000f, 1.1344f, 0.0000f, 0.0000f, 0.3955f, 0.0000f, 0.0000f, 0.1073f, 0.0635f, 1.1323f, 0.0000f, 0.0000f, 1.0590f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.9309f, 4.5159f, 0.0000f, 0.0000f, 0.0000f, 0.7599f, 0.2474f, 2.2719f, 0.6638f, 0.0000f, 2.7366f, 2.2148f, 0.0000f, 0.0000f, 0.0000f, 0.2814f, 0.0000f, 0.0000f, 0.6799f, 0.0000f, 0.2209f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.1430f, 0.0000f, 0.0364f, 1.4111f, 1.3238f, 0.6777f, 2.5226f, 0.0000f, 0.0000f, 0.5769f, 0.7938f, 0.0000f, 0.9097f, 0.0000f, 0.0000f, 0.0000f, 1.0733f, 0.0000f, 0.3071f, 3.3680f, 1.2845f, 1.3104f, 0.8612f, 0.0000f, 0.0000f, 0.0000f, 2.0395f, 1.5702f, 1.2423f, 0.5085f, 2.4769f, 3.0385f, 2.6653f, 2.4255f, 3.4814f, 3.1955f, 3.1394f, 3.2775f, 2.4097f, 4.3077f, 6.2658f, 9.6205f, 5.6577f, 6.7532f, 7.6542f, 7.3224f, 10.4244f, 11.5053f, 12.6813f, 12.9981f, 13.1947f, 14.2313f, 14.0314f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f +}}; + +constexpr DroopCorrectionTable kDroopTable768Ksps{{ + 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 14.2226f, 13.3770f, 11.7052f, 13.6600f, 11.8402f, 11.8643f, 10.5462f, 11.4915f, 7.5891f, 7.2912f, 9.2411f, 6.0843f, 6.5406f, 5.5220f, 4.7743f, 4.1638f, 3.7851f, 4.1767f, 2.7629f, 2.4808f, 1.9196f, 1.3300f, 2.5312f, 0.6661f, 1.0488f, 2.1608f, 1.0207f, 1.2489f, 0.3898f, 0.7685f, 0.0000f, 0.0000f, 2.1901f, 0.2552f, 0.0000f, 1.1480f, 3.2553f, 0.8420f, 1.6616f, 3.9415f, 1.5959f, 0.4970f, 2.3577f, 3.0037f, 2.9380f, 1.6330f, 1.6145f, 0.8487f, 1.0201f, 0.5769f, 0.0000f, 0.0000f, 0.3667f, 0.0000f, 0.0000f, 0.0000f, 0.5371f, 0.6069f, 0.0000f, 0.0000f, 0.4440f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.6007f, 0.4038f, 0.2758f, 1.0052f, 0.2403f, 0.0000f, 1.0896f, 0.6003f, 0.6281f, 0.2834f, 1.8815f, 0.0000f, 0.4330f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.6200f, 0.9707f, 0.6410f, 0.6859f, 0.0000f, 0.0000f, 0.3833f, 2.4313f, 0.0000f, 0.1440f, 1.2101f, 0.8473f, 0.0000f, 0.0000f, 1.6561f, 0.0000f, 0.0000f, 2.1648f, 1.4555f, 1.4159f, 0.0000f, 0.6239f, 0.0000f, 1.0924f, 0.0000f, 0.4720f, 0.8669f, 0.0000f, 0.0000f, 0.0000f, 0.9161f, 1.3264f, 0.0000f, 1.7543f, 0.3781f, 0.0000f, 0.0543f, 1.5436f, 0.4647f, 0.0000f, 0.4842f, 0.2059f, 0.3922f, 1.1725f, 0.5885f, 0.1188f, 0.2463f, 0.0000f, 0.2168f, 0.0000f, 0.9563f, 1.6371f, 0.0689f, 1.5158f, 0.0000f, 0.9228f, 0.0000f, 0.0000f, 1.8315f, 1.1448f, 0.2509f, 0.3641f, 0.0931f, 0.3165f, 0.0000f, 0.5546f, 1.0601f, 0.0000f, 0.1328f, 0.7185f, 1.1039f, 1.7997f, 0.5813f, 0.0000f, 0.1832f, 0.3358f, 2.7763f, 0.9961f, 0.0000f, 0.6302f, 0.0000f, 0.2414f, 0.2495f, 0.5418f, 0.4077f, 0.0000f, 2.0729f, 0.0000f, 0.0000f, 0.0000f, 0.1483f, 0.0000f, 0.0000f, 0.6465f, 4.4988f, 1.2885f, 1.8751f, 0.7333f, 0.8342f, 0.1303f, 0.0000f, 0.3752f, 0.4831f, 0.5401f, 1.0798f, 0.0822f, 0.0000f, 0.8907f, 0.9297f, 4.1933f, 0.7153f, 1.0719f, 1.0485f, 0.0000f, 0.0000f, 0.9412f, 1.5532f, 0.2449f, 1.9498f, 0.4898f, 0.7039f, 0.0221f, 1.2153f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.9881f, 0.0000f, 0.0000f, 2.4956f, 0.0000f, 0.0000f, 0.7576f, 0.0000f, 0.0000f, 0.0000f, 0.4500f, 1.7164f, 1.1043f, 0.1906f, 0.8455f, 2.1165f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3509f, 1.0851f, 0.0000f, 0.0000f, 0.0000f, 1.6892f, 0.8551f, 1.4996f, 0.3292f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.7865f, 1.3098f, 1.2569f, 1.4122f, 0.5472f, 2.4100f, 0.9969f, 0.0000f, 0.0689f, 0.6192f, 0.5394f, 0.8251f, 1.1577f, 1.6002f, 0.3401f, 0.0000f, 0.0000f, 0.0000f, 1.6710f, 1.3159f, 0.9159f, 0.0000f, 0.0000f, 1.2115f, 1.2157f, 0.6005f, 0.0000f, 2.2654f, 0.0000f, 0.0000f, 1.4846f, 0.8541f, 0.0000f, 0.4786f, 0.0000f, 2.4359f, 1.9044f, 1.7835f, 1.9800f, 0.0000f, 0.4780f, 2.1515f, 2.6245f, 0.0000f, 0.6019f, 2.0627f, 2.7160f, 0.9775f, 2.2902f, 1.5977f, 0.0000f, 0.2270f, 0.0000f, 0.0000f, 0.0000f, 0.4596f, 0.0000f, 0.0000f, 0.2580f, 0.0000f, 0.0000f, 0.0000f, 0.1460f, 0.0000f, 0.0000f, 0.0000f, 0.8319f, 1.5628f, 1.0369f, 1.5816f, 2.2269f, 0.8404f, 1.4755f, 1.7649f, 1.0048f, 0.2209f, 1.4315f, 0.4880f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.5646f, 0.1796f, 1.0832f, 1.5403f, 0.0000f, 0.0000f, 0.0000f, 0.2177f, 0.0304f, 0.4825f, 0.2166f, 0.0000f, 0.0000f, 0.0000f, 2.0988f, 0.0309f, 0.8748f, 0.8818f, 0.1716f, 0.0000f, 1.6666f, 1.4642f, 2.6197f, 0.1419f, 0.0000f, 1.2508f, 2.9608f, 1.5326f, 3.0959f, 1.0609f, 0.0000f, 0.3727f, 0.2968f, 0.0209f, 0.0000f, 0.0000f, 0.4881f, 1.0497f, 1.6499f, 0.0000f, 0.7226f, 0.2031f, 0.7123f, 0.8356f, 1.7756f, 0.0000f, 0.0000f, 0.0000f, 0.5459f, 0.6352f, 0.0000f, 0.4273f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4024f, 0.2881f, 0.0000f, 0.0000f, 0.5547f, 0.0000f, 0.0000f, 0.0000f, 0.4387f, 0.0927f, 0.0000f, 0.0000f, 0.6012f, 0.0000f, 1.6384f, 0.0000f, 0.0000f, 0.6795f, 0.2163f, 0.0000f, 0.9918f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.6842f, 0.3409f, 0.0000f, 1.0469f, 1.0702f, 0.0000f, 0.0662f, 4.6598f, 1.8328f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5028f, 0.0000f, 0.0000f, 2.0503f, 0.5504f, 0.0000f, 0.3784f, 0.8111f, 3.2605f, 0.0466f, 0.0000f, 0.2206f, 0.8497f, 1.9801f, 0.6169f, 0.0000f, 1.6502f, 0.0000f, 0.0000f, 0.0000f, 0.4979f, 0.2394f, 0.2643f, 0.0507f, 1.8925f, 0.0000f, 0.0000f, 0.0000f, 1.4396f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.6524f, 5.0606f, 2.8565f, 2.1143f, 2.3286f, 0.0000f, 0.0000f, 0.5383f, 0.3282f, 1.4775f, 1.2346f, 0.0000f, 0.0000f, 0.0017f, 0.8822f, 1.1769f, 1.7515f, 0.5583f, 0.9429f, 0.2286f, 0.0000f, 1.9556f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4570f, 1.5453f, 1.4965f, 0.4635f, 0.0000f, 2.0248f, 1.6587f, 1.3784f, 0.4944f, 0.6171f, 1.1446f, 0.8530f, 0.0000f, 0.4031f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0760f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.3077f, 0.0000f, 1.1752f, 0.2840f, 0.0000f, 0.0000f, 0.1375f, 1.2135f, 1.0441f, 0.2452f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.8473f, 1.4255f, 0.0000f, 1.8123f, 1.4428f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5227f, 0.4438f, 0.0000f, 0.0000f, 0.0000f, 0.2225f, 0.0000f, 0.6527f, 0.0000f, 0.0000f, 0.3513f, 0.1894f, 1.0710f, 0.0000f, 0.0000f, 1.3955f, 0.0000f, 0.0000f, 0.0000f, 3.5557f, 0.6040f, 2.6446f, 0.0000f, 0.1817f, 0.4509f, 0.9652f, 0.4727f, 0.0000f, 0.0000f, 1.9658f, 0.0000f, 0.0853f, 2.2975f, 0.0000f, 0.0000f, 0.0000f, 1.1558f, 2.8384f, 0.0000f, 0.8152f, 1.1785f, 0.0000f, 1.4020f, 1.1476f, 1.1833f, 1.1641f, 0.0000f, 0.0000f, 1.3000f, 2.3189f, 0.0000f, 0.0350f, 0.0000f, 0.0000f, 0.0000f, 1.9632f, 0.7799f, 0.0000f, 0.0000f, 3.1944f, 2.3538f, 2.4087f, 0.2901f, 0.9203f, 1.9198f, 0.0000f, 0.9795f, 0.0000f, 0.0000f, 0.0000f, 2.2084f, 0.0000f, 0.0000f, 0.0000f, 1.3009f, 0.7290f, 0.0000f, 0.1482f, 0.7526f, 0.0000f, 0.0000f, 0.6777f, 1.6668f, 0.5884f, 1.3989f, 0.0000f, 0.0000f, 0.6259f, 0.0000f, 0.0000f, 0.9220f, 1.3170f, 1.8476f, 0.0000f, 0.0000f, 0.0000f, 0.0794f, 0.0000f, 0.0000f, 1.3265f, 1.5943f, 1.0353f, 0.0000f, 0.0000f, 0.0000f, 0.5121f, 1.3388f, 0.3440f, 1.0760f, 1.8241f, 2.9366f, 3.8433f, 0.6106f, 0.2792f, 0.0000f, 0.0000f, 1.0893f, 0.7721f, 0.0000f, 0.0000f, 0.5930f, 1.4141f, 0.0000f, 0.8878f, 0.3258f, 0.0510f, 0.0000f, 0.1953f, 0.1851f, 1.2201f, 3.6681f, 2.5526f, 0.7630f, 0.8968f, 0.0000f, 1.8518f, 1.2921f, 0.0000f, 0.4129f, 1.6449f, 1.8991f, 0.0000f, 0.8903f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3221f, 1.3764f, 0.0000f, 0.8686f, 1.0186f, 0.9223f, 0.4385f, 0.1451f, 0.5131f, 0.0000f, 0.0000f, 0.9553f, 1.7581f, 2.3113f, 1.7032f, 0.0000f, 0.0734f, 0.0000f, 0.1713f, 1.3989f, 0.8761f, 0.6610f, 1.0892f, 2.0748f, 1.0481f, 0.0000f, 0.0253f, 0.2509f, 0.1768f, 0.0000f, 1.9199f, 0.9663f, 0.1044f, 0.0000f, 0.2173f, 1.4136f, 0.0000f, 0.1905f, 0.9839f, 0.8740f, 1.1880f, 0.4869f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.8256f, 1.1758f, 0.0000f, 0.0000f, 0.8021f, 2.2687f, 1.0149f, 0.0000f, 0.0000f, 0.0000f, 0.7307f, 0.4669f, 0.5775f, 1.7810f, 1.6776f, 0.0000f, 0.0000f, 0.1771f, 1.8336f, 2.1590f, 0.3755f, 0.5612f, 1.6832f, 1.2116f, 2.6925f, 2.2014f, 2.2756f, 1.1148f, 0.6435f, 0.0000f, 0.4133f, 1.1209f, 1.5727f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.5544f, 0.9482f, 0.0000f, 0.8366f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.3167f, 0.0000f, 0.0000f, 0.7066f, 1.0247f, 0.0000f, 0.8650f, 0.0000f, 0.0000f, 0.0000f, 0.3675f, 1.3161f, 1.8321f, 0.0000f, 0.4414f, 0.1006f, 1.1277f, 0.0000f, 1.1875f, 0.1918f, 0.5153f, 3.6926f, 0.4772f, 0.8068f, 0.0000f, 0.0000f, 1.9468f, 0.0000f, 0.5568f, 0.0000f, 0.0000f, 0.0000f, 0.9865f, 0.1529f, 0.0000f, 0.0000f, 1.6810f, 0.6753f, 0.8991f, 0.6114f, 0.7109f, 0.0000f, 0.0000f, 0.0000f, 0.6211f, 0.7495f, 2.4745f, 1.0001f, 2.6449f, 0.0000f, 0.0000f, 0.6586f, 0.3336f, 0.5192f, 0.1922f, 0.0000f, 0.0000f, 0.1442f, 0.5714f, 0.0289f, 0.0316f, 0.0000f, 0.5326f, 2.7350f, 0.0000f, 0.2209f, 0.2403f, 2.1128f, 0.0000f, 1.3282f, 0.0000f, 0.6606f, 0.2162f, 0.0767f, 1.1861f, 1.9950f, 0.8227f, 0.5901f, 0.1388f, 0.0000f, 0.5743f, 1.8506f, 1.2988f, 0.9607f, 0.0000f, 0.0000f, 0.1672f, 0.0161f, 0.0000f, 0.2939f, 1.7284f, 2.4635f, 0.6995f, 1.1307f, 1.6862f, 0.0000f, 1.8666f, 1.7748f, 0.0000f, 0.0000f, 0.6986f, 0.0000f, 0.9890f, 2.6918f, 0.9049f, 0.6907f, 2.5132f, 2.9548f, 0.0000f, 0.6673f, 1.5266f, 2.1542f, 0.2276f, 1.4757f, 1.1003f, 0.5658f, 0.3468f, 1.8386f, 0.0585f, 1.8602f, 2.2317f, 1.0695f, 1.8907f, 0.2262f, 0.0000f, 3.2586f, 2.3923f, 3.6856f, 1.5180f, 5.1962f, 4.0943f, 5.0167f, 5.9919f, 4.8024f, 4.7779f, 4.9170f, 4.0170f, 3.5968f, 6.7950f, 5.9457f, 7.5816f, 8.5120f, 7.6803f, 9.5130f, 9.9691f, 10.9645f, 11.4111f, 13.7536f, 14.8609f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f +}}; + +constexpr DroopCorrectionTable kDroopTable1536Ksps{{ + 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 14.1516f, 11.6190f, 11.1733f, 13.6624f, 10.9173f, 7.9585f, 9.8273f, 9.0135f, 9.5240f, 7.7670f, 6.6927f, 6.2068f, 4.6972f, 3.2490f, 4.0082f, 4.0868f, 3.7223f, 4.8194f, 3.8031f, 1.4611f, 3.7665f, 1.4643f, 1.3125f, 0.1017f, 0.6982f, 0.0000f, 0.0000f, 1.3717f, 0.0000f, 0.0000f, 0.0000f, 1.6652f, 0.0000f, 0.5527f, 2.3118f, 0.0000f, 0.2507f, 0.0000f, 0.0000f, 0.4588f, 0.0000f, 0.0000f, 1.7040f, 0.5324f, 0.5057f, 0.0000f, 0.0000f, 0.0000f, 0.1802f, 1.4437f, 2.5575f, 3.5962f, 0.0000f, 0.0000f, 0.4850f, 1.2222f, 0.2198f, 0.9764f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.4880f, 0.2364f, 0.0000f, 0.0000f, 0.9070f, 0.8433f, 0.0973f, 0.0000f, 0.8550f, 0.0000f, 0.5849f, 2.8026f, 1.4312f, 0.0000f, 0.6868f, 0.2669f, 0.1618f, 0.7712f, 0.3716f, 2.9254f, 0.0000f, 0.0000f, 0.5326f, 1.4277f, 0.1412f, 0.0000f, 0.2831f, 0.0000f, 0.2394f, 0.0000f, 0.0000f, 0.0000f, 0.3521f, 1.0957f, 0.0000f, 0.0000f, 1.5152f, 0.0833f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5458f, 2.4581f, 0.6637f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.9143f, 1.6555f, 0.0000f, 2.6165f, 0.9375f, 0.2469f, 0.0000f, 0.0000f, 0.0000f, 0.0766f, 0.0000f, 0.0000f, 0.0000f, 0.7194f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.8688f, 0.0000f, 0.2605f, 0.0000f, 0.0000f, 0.0000f, 0.9577f, 0.8875f, 0.0000f, 0.7403f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4251f, 0.1175f, 0.3895f, 0.0005f, 0.0059f, 2.9444f, 0.3807f, 0.2061f, 0.0000f, 0.7015f, 1.3785f, 0.5235f, 1.5201f, 0.0000f, 0.0000f, 0.2236f, 0.5048f, 0.0000f, 0.0000f, 0.0000f, 0.4777f, 1.6928f, 0.4557f, 0.0000f, 0.0000f, 0.0000f, 0.3698f, 0.1583f, 0.0603f, 0.0000f, 0.0000f, 0.3577f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.9570f, 0.3118f, 0.1163f, 0.9448f, 1.2456f, 0.0000f, 0.0000f, 0.0000f, 0.2135f, 0.5325f, 0.5986f, 0.0000f, 0.0729f, 0.3085f, 0.8030f, 0.0772f, 1.9929f, 0.6359f, 0.5782f, 0.2320f, 0.0518f, 0.0000f, 0.0000f, 0.0000f, 0.9222f, 0.6503f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.7449f, 1.1315f, 1.8105f, 0.0000f, 0.0000f, 0.0847f, 0.4948f, 0.6193f, 0.3957f, 0.0000f, 0.0000f, 1.4284f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.2477f, 0.0000f, 1.8941f, 0.2457f, 0.0000f, 0.0000f, 0.1209f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6069f, 0.0000f, 0.0000f, 0.0000f, 2.0362f, 1.7259f, 1.3395f, 0.7931f, 0.6370f, 0.0000f, 0.0000f, 0.3502f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3572f, 2.8024f, 0.1975f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6499f, 0.2720f, 0.0477f, 0.0000f, 1.0031f, 2.3378f, 0.2395f, 0.0000f, 0.0063f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.3850f, 2.7524f, 0.7278f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.3325f, 0.3782f, 0.0000f, 0.0000f, 0.0000f, 0.4054f, 2.9744f, 1.0748f, 0.0000f, 0.0000f, 0.6605f, 0.0872f, 0.8052f, 0.8915f, 3.2083f, 0.0000f, 0.0000f, 0.5493f, 0.4880f, 0.7793f, 0.0000f, 0.0000f, 0.3242f, 0.8225f, 0.6581f, 1.7783f, 0.0000f, 1.2077f, 0.0000f, 0.4174f, 0.2426f, 0.0000f, 0.2810f, 2.6832f, 0.0000f, 0.0000f, 1.1180f, 0.4674f, 0.0000f, 0.0000f, 0.0000f, 0.7106f, 2.1163f, 3.6808f, 0.0000f, 2.1780f, 0.0000f, 0.0000f, 0.0000f, 0.0067f, 0.0000f, 0.0000f, 0.0000f, 0.2696f, 0.0000f, 0.0000f, 0.9132f, 1.2878f, 0.0683f, 0.0000f, 0.0000f, 1.0741f, 0.4399f, 0.3447f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.5416f, 0.0000f, 2.6024f, 0.0000f, 0.0000f, 0.4108f, 0.8779f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4235f, 0.3244f, 0.1743f, 0.0000f, 0.1315f, 0.0000f, 0.1046f, 0.0000f, 1.5672f, 0.0000f, 0.3423f, 0.0000f, 0.6423f, 0.5961f, 0.2506f, 1.3007f, 0.9258f, 1.8263f, 0.0215f, 0.8692f, 0.5215f, 0.2247f, 0.0994f, 0.3074f, 1.5733f, 0.0000f, 0.4835f, 0.0000f, 0.0000f, 0.0000f, 0.1267f, 0.0000f, 0.0000f, 0.5744f, 0.1207f, 0.0000f, 1.1146f, 0.0880f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.8190f, 0.7790f, 0.0000f, 0.9725f, 1.0324f, 0.0000f, 0.2373f, 1.0575f, 0.2793f, 0.0000f, 0.0000f, 0.0000f, 1.5510f, 0.9407f, 1.3799f, 1.4307f, 0.8948f, 0.2569f, 0.0000f, 1.2235f, 0.0000f, 0.2186f, 0.0000f, 0.0000f, 0.0000f, 0.3695f, 0.0000f, 0.3768f, 0.8682f, 0.3989f, 0.6318f, 1.0056f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.1723f, 0.1286f, 0.0000f, 1.3605f, 0.9290f, 0.8800f, 0.6487f, 0.6219f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.1905f, 0.0000f, 4.2496f, 0.0040f, 0.4658f, 0.0000f, 0.4827f, 2.3833f, 1.4550f, 1.5111f, 0.9889f, 0.0000f, 0.0000f, 0.0764f, 0.0000f, 0.3783f, 1.5862f, 0.0000f, 0.0000f, 0.9926f, 0.4615f, 0.2304f, 0.0000f, 0.0000f, 1.1676f, 2.2615f, 0.6066f, 0.0000f, 0.0000f, 0.9428f, 1.3676f, 0.1350f, 0.0000f, 0.1644f, 0.0000f, 0.0000f, 0.0000f, 2.5858f, 0.3157f, 0.3313f, 0.1351f, 0.0000f, 0.7935f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3290f, 0.0000f, 0.0000f, 1.1776f, 0.6773f, 0.0000f, 0.9139f, 0.0000f, 0.0000f, 0.0012f, 0.8672f, 0.0000f, 0.3452f, 0.8791f, 1.1977f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.7049f, 0.6935f, 0.7741f, 0.9708f, 0.0000f, 0.0000f, 0.6976f, 2.0001f, 0.4195f, 0.0000f, 0.0000f, 0.0000f, 0.2721f, 3.0125f, 0.5845f, 0.1312f, 0.0000f, 0.7419f, 0.0000f, 0.0000f, 0.0000f, 0.3402f, 0.1644f, 0.0000f, 0.0060f, 0.0000f, 0.0000f, 0.0101f, 0.0000f, 0.0000f, 0.8681f, 0.0000f, 0.0000f, 0.7121f, 1.1557f, 1.5279f, 0.1506f, 0.0000f, 0.0000f, 0.0000f, 0.6025f, 0.9866f, 1.1110f, 2.8341f, 1.9000f, 0.0000f, 0.0000f, 0.0000f, 0.9708f, 0.0000f, 1.2593f, 0.0170f, 0.0000f, 2.1670f, 0.0000f, 0.0000f, 0.0592f, 1.1894f, 0.0000f, 0.2641f, 0.3647f, 2.3968f, 0.0000f, 0.4931f, 0.0000f, 0.4114f, 0.0000f, 0.0000f, 0.0000f, 0.3323f, 2.4406f, 0.1865f, 0.7117f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6563f, 0.8639f, 0.4701f, 0.0018f, 0.3599f, 0.0000f, 0.0000f, 0.0000f, 0.8630f, 0.0000f, 1.9045f, 0.4098f, 0.0000f, 0.0000f, 1.2811f, 0.0000f, 0.0000f, 0.5215f, 0.6953f, 2.2500f, 4.0218f, 0.9537f, 0.3215f, 0.0000f, 0.4070f, 0.0000f, 0.0000f, 1.3352f, 1.1283f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5665f, 0.5020f, 0.2462f, 0.0378f, 0.0000f, 0.5801f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2666f, 0.1460f, 0.1562f, 0.0000f, 0.0000f, 0.1661f, 0.1729f, 0.5534f, 1.1115f, 0.0000f, 0.8835f, 0.0000f, 0.0000f, 1.7210f, 2.5907f, 0.0000f, 0.0000f, 2.4566f, 1.5713f, 0.0000f, 0.0000f, 0.0000f, 0.0307f, 0.0000f, 0.0000f, 0.0000f, 1.3386f, 0.0000f, 0.2592f, 0.1415f, 0.2651f, 1.3728f, 0.2201f, 0.4892f, 0.0000f, 0.0000f, 0.0000f, 0.2359f, 1.1067f, 2.4328f, 1.9022f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.1060f, 1.4622f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.0546f, 3.3578f, 1.2965f, 0.0000f, 0.0000f, 0.0000f, 1.1122f, 0.0000f, 0.0000f, 1.0303f, 1.3110f, 0.4233f, 1.1135f, 1.9954f, 0.7313f, 1.4424f, 0.3124f, 0.0000f, 0.0000f, 3.0049f, 1.6544f, 1.5100f, 0.8085f, 1.9188f, 0.9641f, 1.5488f, 0.9167f, 0.2565f, 0.6856f, 0.8680f, 0.4282f, 1.4677f, 1.0901f, 0.0000f, 0.6082f, 1.0928f, 1.3580f, 0.7616f, 0.0000f, 0.0000f, 1.2360f, 1.1097f, 0.0000f, 1.1552f, 0.0000f, 1.2676f, 1.9127f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4424f, 0.0000f, 0.0000f, 0.5429f, 0.0000f, 0.2274f, 0.0923f, 1.2113f, 0.0000f, 0.0712f, 1.2185f, 2.2484f, 1.6956f, 0.1860f, 0.0000f, 0.0000f, 1.5284f, 0.0479f, 0.7754f, 0.0000f, 0.7871f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3144f, 0.0000f, 1.1523f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.2681f, 0.3449f, 0.0000f, 0.5487f, 1.5424f, 0.0642f, 0.2397f, 0.1094f, 0.0000f, 0.3192f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6155f, 0.0000f, 0.0000f, 1.0052f, 0.1896f, 1.2393f, 0.7839f, 0.1130f, 0.7272f, 0.0000f, 0.2873f, 0.0000f, 0.3726f, 1.2699f, 1.0068f, 1.0449f, 0.6383f, 0.0000f, 0.0000f, 0.0000f, 0.3999f, 0.0000f, 1.8246f, 0.4849f, 0.0000f, 0.0000f, 0.0296f, 0.1350f, 0.0000f, 0.8169f, 0.6825f, 0.4134f, 0.0000f, 0.0000f, 0.0000f, 0.0054f, 0.0000f, 0.0000f, 0.2523f, 0.8135f, 0.0000f, 0.3731f, 0.0000f, 1.3492f, 1.7074f, 1.1435f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.9330f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.1923f, 0.0000f, 0.0000f, 0.0000f, 0.4388f, 1.0258f, 0.3207f, 0.2338f, 0.0000f, 0.3525f, 0.0000f, 0.0000f, 0.9613f, 1.5222f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.9772f, 2.5867f, 0.0000f, 0.5172f, 1.3731f, 1.3184f, 0.0000f, 0.5609f, 0.0721f, 0.0000f, 0.2930f, 0.0965f, 0.0000f, 0.0000f, 0.0375f, 0.0000f, 2.5632f, 0.9822f, 0.0000f, 0.0000f, 0.0000f, 0.6032f, 0.0000f, 0.0000f, 1.5955f, 1.6951f, 1.9135f, 1.8930f, 0.0000f, 0.2060f, 0.7381f, 2.1268f, 2.8398f, 1.1456f, 1.3315f, 1.1527f, 2.3935f, 2.8027f, 4.5633f, 4.9195f, 4.0269f, 4.6933f, 3.7455f, 6.2742f, 8.9172f, 7.9264f, 6.5914f, 9.8427f, 10.0703f, 8.7490f, 8.6039f, 7.6702f, 8.8867f, 14.2719f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f +}}; + diff --git a/src/core/backends/anan/AnanRxDsp.cpp b/src/core/backends/anan/AnanRxDsp.cpp index 14e231912..d22c68876 100644 --- a/src/core/backends/anan/AnanRxDsp.cpp +++ b/src/core/backends/anan/AnanRxDsp.cpp @@ -1,5 +1,7 @@ #include "core/backends/anan/AnanRxDsp.h" +#include "core/backends/anan/AnanDroopCorrection.h" + #include #include @@ -277,6 +279,13 @@ 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. + applyDroopCorrectionDb(m_bins, + droopCorrectionTableForRateKsps(m_config.inputSampleRateHz / 1000)); smoothSpectrumBins(m_bins); emit spectrumReady(m_bins); m_lastSpectrumMs = m_spectrumClock.elapsed(); diff --git a/src/gui/SpectrumWidget.cpp b/src/gui/SpectrumWidget.cpp index 3fe18c52b..b4bc0e428 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 tools/anan_droop_calibration.py) 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 calibration script 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/tests/anan_droop_correction_test.cpp b/tests/anan_droop_correction_test.cpp new file mode 100644 index 000000000..7928ce7dc --- /dev/null +++ b/tests/anan_droop_correction_test.cpp @@ -0,0 +1,93 @@ +#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 testTableSizeForEveryValidRate() +{ + for (const int rateKsps : {48, 96, 192, 384, 768, 1536}) { + const DroopCorrectionTable& table = droopCorrectionTableForRateKsps(rateKsps); + if (table.size() != kDroopCorrectionFftSize) + return fail("a valid rate did not return a full-size table"); + } + return 0; +} + +int testUnrecognizedRateReturnsSafeZeroFallback() +{ + // The safety-fallback contract is testable with certainty regardless of + // whether real bench data has landed yet: an unrecognized rate must + // never guess. + const DroopCorrectionTable& table = droopCorrectionTableForRateKsps(9999); + if (&table != &kDroopCorrectionZero) + return fail("an unrecognized rate must return kDroopCorrectionZero"); + for (const float v : table) { + if (v != 0.0f) + return fail("kDroopCorrectionZero must be all-zero"); + } + return 0; +} + +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; +} + +} // namespace + +int main() +{ + if (const int result = testTableSizeForEveryValidRate(); result != 0) + return result; + if (const int result = testUnrecognizedRateReturnsSafeZeroFallback(); result != 0) + return result; + 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; + 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..e20d6f62e 100644 --- a/tests/anan_rxdsp_handedness_test.cpp +++ b/tests/anan_rxdsp_handedness_test.cpp @@ -34,6 +34,7 @@ // 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" @@ -397,6 +398,87 @@ 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 -- WITHOUT depending on the + // actual bench-measured correction values (AnanDroopCorrectionTables.inc + // may still be the all-zero placeholder). 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 droopCorrectionTableForRateKsps() for the configured rate, + // 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()); + + 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 = droopCorrectionTableForRateKsps( + cfg.inputSampleRateHz / 1000); + check(emitted.size() == rawBins.size() && emitted.size() == table.size(), + "emitted/reference/table sizes all agree"); + bool matched = emitted.size() == rawBins.size() && emitted.size() == table.size(); + for (std::size_t k = 0; matched && k < emitted.size(); ++k) { + if (std::fabs(emitted[k] - (rawBins[k] + table[k])) > 1.0e-4f) { + matched = false; + std::fprintf(stderr, + " bin %zu: emitted=%.6f raw+correction=%.6f (raw=%.6f correction=%.6f)\n", + k, emitted[k], rawBins[k] + table[k], rawBins[k], table[k]); + } + } + check(matched, + "the first emitted frame equals 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"); + } + 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 2177d9ab7..3e08266e6 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -1868,6 +1868,17 @@ 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 lookup/apply -- pure C++, no Qt dependency at all, +# tests the safety-fallback contract and correction math regardless of +# whether real bench-measured tables (tools/anan_droop_calibration.py) have +# landed in AnanDroopCorrectionTables.inc yet. +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) + # 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 @@ -2567,6 +2578,11 @@ if(PYTHON3_EXECUTABLE) add_test(NAME bridge_docs_check COMMAND ${PYTHON3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/gen_bridge_docs.py --check) + # ANAN droop-correction calibration script: pure-Python data processing, + # no bench sweep needed to exercise its logic. + add_test(NAME anan_droop_calibration_checks + COMMAND ${PYTHON3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tools/test_anan_droop_calibration.py) endif() # Retired local-listener fixture. Positive behavior is covered through the live diff --git a/tools/anan_droop_calibration.py b/tools/anan_droop_calibration.py new file mode 100644 index 000000000..e126df7cb --- /dev/null +++ b/tools/anan_droop_calibration.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +"""Turn a bench calibration sweep into the ANAN-G2 droop-correction tables. + +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 script turns a +set of `radiocert spectrum` captures (one JSON file per capture, gathered via +the automation bridge across all 6 DDC0 rates with the antenna disconnected +or dummy-loaded, so the true receiver noise floor stands in as a flat +reference) into a per-bin dB correction table per rate, and writes the +generated C++ include consumed by src/core/backends/anan/AnanDroopCorrection.cpp. + +Input: a directory of JSON files, each either a bare `measured` object + {"ddc0RateKsps": ..., "fftSize": ..., "binsDbm": [...]} +or a full radiocert stage record {"id": "spectrum", "measured": {...}, ...} -- +both shapes are accepted so a saved raw bridge_command response works as-is. + +Usage: + tools/anan_droop_calibration.py [--cap-db 15] + [--reference-window-fraction 0.15] [--min-captures-per-rate 5] + +No app, no Qt, no build required -- pure static data processing. +""" + +from __future__ import annotations + +import argparse +import datetime +import json +import math +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +REPO = HERE.parent +OUTPUT = REPO / "src" / "core" / "backends" / "anan" / "AnanDroopCorrectionTables.inc" + +# The six ANAN-G2 DDC0 rates (AnanBackend::capabilities()'s sampleRatesHz, in +# ksps rather than Hz) -- fixed by the radio, not derived from the captures. +VALID_RATES_KSPS = (48, 96, 192, 384, 768, 1536) + +FFT_SIZE = 1024 + + +def load_captures(capture_dir: Path) -> list[dict]: + """Read every *.json file in capture_dir, unwrapping a radiocert stage + record down to its `measured` object if present.""" + captures = [] + for path in sorted(capture_dir.glob("*.json")): + with path.open("r", encoding="utf-8") as f: + doc = json.load(f) + measured = doc.get("measured", doc) if isinstance(doc, dict) else doc + if not isinstance(measured, dict): + continue + if "ddc0RateKsps" not in measured or "binsDbm" not in measured: + continue + captures.append(measured) + return captures + + +def group_by_rate(captures: list[dict]) -> dict[int, list[list[float]]]: + grouped: dict[int, list[list[float]]] = {} + for capture in captures: + rate = int(capture["ddc0RateKsps"]) + bins = capture["binsDbm"] + grouped.setdefault(rate, []).append(bins) + return grouped + + +def median(values: list[float]) -> float: + ordered = sorted(values) + n = len(ordered) + mid = n // 2 + if n % 2 == 1: + return ordered[mid] + return (ordered[mid - 1] + ordered[mid]) / 2.0 + + +def median_power_curve(bin_arrays: list[list[float]]) -> list[float]: + """Combine N per-capture dB curves of the same length 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 -- whichever capture's + value is the middle one stays the middle one either way). It is kept + anyway so this function's contract matches "average in the physically + meaningful domain" even if a future caller swaps the reducer for a mean, + where the log-domain-bias problem is real (Jensen's inequality: the mean + of the logs is not the log of the mean) and this conversion would start + to matter.""" + if not bin_arrays: + return [] + length = len(bin_arrays[0]) + curve = [] + for k in range(length): + powers = [10.0 ** (bins[k] / 10.0) for bins in bin_arrays] + curve.append(10.0 * math.log10(median(powers))) + return curve + + +def reference_level(curve: list[float], window_fraction: float) -> float: + """Median of the curve over a central window, not the exact center bin + (avoids any residual DC-region artifact even though AnanSpectrum already + DC-removes before windowing).""" + n = len(curve) + half_width = max(1, int(n * window_fraction / 2.0)) + center = n // 2 + window = curve[max(0, center - half_width):min(n, center + half_width)] + return median(window) + + +def compute_correction(curve: list[float], reference_db: float, + cap_db: float) -> tuple[list[float], float]: + """correction[k] = clamp(reference - measured[k], 0, cap_db). Returns the + correction curve and the fraction of bins where the RAW (unclamped) + correction exceeded cap_db -- i.e. genuinely unrecoverable, past the + point where the CIC null approaches the noise floor and further gain + would amplify noise rather than recover signal.""" + correction = [] + clamped = 0 + for value in curve: + raw = reference_db - value + if raw > cap_db: + clamped += 1 + correction.append(max(0.0, min(cap_db, raw))) + clamped_fraction = clamped / len(curve) if curve else 0.0 + return correction, clamped_fraction + + +def validate_curve(rate_ksps: int, correction: list[float], cap_db: float, + capture_count: int, min_captures: int) -> list[str]: + problems = [] + if capture_count < min_captures: + problems.append( + f"rate {rate_ksps} ksps: only {capture_count} captures " + f"(minimum {min_captures})") + if len(correction) != FFT_SIZE: + problems.append( + f"rate {rate_ksps} ksps: {len(correction)} bins, expected {FFT_SIZE}") + for k, value in enumerate(correction): + if not math.isfinite(value): + problems.append(f"rate {rate_ksps} ksps: bin {k} is not finite") + elif value < 0.0 or value > cap_db: + problems.append( + f"rate {rate_ksps} ksps: bin {k} correction {value:.2f} dB " + f"outside [0, {cap_db}]") + return problems + + +def warn_bin_jumps(rate_ksps: int, correction: list[float], + jump_threshold_db: float) -> list[str]: + """Non-fatal: real CIC ripple can be legitimately non-monotonic, but a + single-bin spike this large next to smooth neighbours is more likely + measurement noise than filter shape.""" + warnings = [] + for k in range(1, len(correction) - 1): + left = abs(correction[k] - correction[k - 1]) + right = abs(correction[k] - correction[k + 1]) + if left > jump_threshold_db and right > jump_threshold_db: + warnings.append( + f"rate {rate_ksps} ksps: bin {k} looks like a single-bin " + f"spike ({correction[k]:.2f} dB, neighbours " + f"{correction[k - 1]:.2f}/{correction[k + 1]:.2f})") + return warnings + + +def format_table(rate_ksps: int, correction: list[float]) -> str: + name = f"kDroopTable{rate_ksps}Ksps" + values = ", ".join(f"{v:.4f}f" for v in correction) + return ( + f"constexpr DroopCorrectionTable {name}{{{{\n" + f" {values}\n" + f"}}}};\n" + ) + + +def generate_inc_file(tables_by_rate: dict[int, list[float]], + capture_counts: dict[int, int], cap_db: float, + reference_window_fraction: float) -> str: + generated_at = datetime.datetime.now(datetime.timezone.utc).strftime( + "%Y-%m-%d %H:%M UTC") + counts_line = ", ".join( + f"{rate}ksps={capture_counts.get(rate, 0)}" for rate in VALID_RATES_KSPS) + header = ( + "// AnanDroopCorrectionTables.inc\n" + "//\n" + "// DO NOT HAND-EDIT. Generated by tools/anan_droop_calibration.py from a\n" + "// bench calibration sweep of the ANAN-G2's real DDC0 CIC/decimation droop\n" + "// -- see AnanDroopCorrection.h for what this data is and why it exists.\n" + "//\n" + f"// Generated: {generated_at}\n" + f"// Capture counts per rate: {counts_line}\n" + f"// cap_db={cap_db} reference_window_fraction={reference_window_fraction}\n" + "//\n" + "// Regenerate by running the calibration sweep against a real ANAN-G2 and\n" + "// re-running this script; it overwrites this file.\n\n" + ) + body = "\n".join( + format_table(rate, tables_by_rate[rate]) + for rate in VALID_RATES_KSPS if rate in tables_by_rate) + missing = [rate for rate in VALID_RATES_KSPS if rate not in tables_by_rate] + for rate in missing: + body += f"\nconstexpr DroopCorrectionTable kDroopTable{rate}Ksps{{}};" \ + f" // no captures for this rate -- no correction applied\n" + return header + body + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("capture_dir", type=Path, + help="directory of radiocert-spectrum JSON captures") + parser.add_argument("--cap-db", type=float, default=15.0, + help="maximum correction applied to any one bin (default 15)") + parser.add_argument("--reference-window-fraction", type=float, default=0.15, + help="central window (as a fraction of bin count) used " + "as the flat reference level (default 0.15)") + parser.add_argument("--min-captures-per-rate", type=int, default=5, + help="minimum captures required per rate (default 5)") + parser.add_argument("--jump-threshold-db", type=float, default=6.0, + help="single-bin-spike warning threshold (default 6)") + parser.add_argument("--dry-run", action="store_true", + help="print the summary but do not write the .inc file") + args = parser.parse_args() + + captures = load_captures(args.capture_dir) + if not captures: + print(f"no captures found in {args.capture_dir}", file=sys.stderr) + return 1 + + grouped = group_by_rate(captures) + capture_counts = {rate: len(bins) for rate, bins in grouped.items()} + + tables_by_rate: dict[int, list[float]] = {} + clamped_fractions: dict[int, float] = {} + problems: list[str] = [] + warnings: list[str] = [] + + for rate in sorted(grouped): + curve = median_power_curve(grouped[rate]) + reference_db = reference_level(curve, args.reference_window_fraction) + correction, clamped_fraction = compute_correction( + curve, reference_db, args.cap_db) + problems.extend(validate_curve( + rate, correction, args.cap_db, capture_counts[rate], + args.min_captures_per_rate)) + warnings.extend(warn_bin_jumps(rate, correction, args.jump_threshold_db)) + tables_by_rate[rate] = correction + clamped_fractions[rate] = clamped_fraction + print(f"rate {rate:5d} ksps: captures={capture_counts[rate]:3d} " + f"reference={reference_db:7.2f} dB " + f"correction min/max={min(correction):.2f}/{max(correction):.2f} dB " + f"clamped_fraction={clamped_fraction:.4f}") + + for w in warnings: + print(f"WARNING: {w}", file=sys.stderr) + + if problems: + for p in problems: + print(f"FAIL: {p}", file=sys.stderr) + return 1 + + if clamped_fractions: + worst = max(clamped_fractions.values()) + suggested_taper = math.ceil((worst + 0.005) * 200.0) / 200.0 # round up to nearest 0.005 + print(f"\nSuggested SpectrumWidget.cpp kEdgeTaperFraction = {suggested_taper:.3f} " + f"(worst-case clamped fraction {worst:.4f} + margin)") + + if args.dry_run: + print("\n--dry-run: not writing", OUTPUT) + return 0 + + OUTPUT.write_text( + generate_inc_file(tables_by_rate, capture_counts, args.cap_db, + args.reference_window_fraction), + encoding="utf-8") + print(f"\nwrote {OUTPUT}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/test_anan_droop_calibration.py b/tools/test_anan_droop_calibration.py new file mode 100644 index 000000000..9f2dc89cf --- /dev/null +++ b/tools/test_anan_droop_calibration.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Unit tests for tools/anan_droop_calibration.py, on synthetic data only -- +no bench sweep needed.""" + +import json +import math +import os +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import anan_droop_calibration as cal # noqa: E402 + + +def check(condition, message): + if not condition: + raise AssertionError(message) + + +def test_median_odd(): + check(cal.median([3.0, 1.0, 2.0]) == 2.0, "median of 3 values") + + +def test_median_even(): + check(cal.median([1.0, 3.0]) == 2.0, "median of 2 values") + + +def test_median_power_curve_rejects_a_stray_outlier_capture(): + # 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. + quiet = [-10.0, -10.0] + outlier = [-10.0, 40.0] + curve = cal.median_power_curve([quiet, quiet, quiet, quiet, outlier]) + check(abs(curve[0] - (-10.0)) < 1e-6, f"bin 0 should stay -10 dB, got {curve[0]}") + check(abs(curve[1] - (-10.0)) < 1e-6, + f"bin 1 median should reject the outlier, got {curve[1]}") + + +def test_reference_level_uses_central_window(): + # A curve that's flat at 0 dB in the center and droops hard at the edges; + # the reference must come from the flat center, not be dragged down by + # the edges. + n = 100 + curve = [0.0] * n + for k in range(10): + curve[k] = -50.0 + curve[n - 1 - k] = -50.0 + ref = cal.reference_level(curve, window_fraction=0.2) + check(abs(ref - 0.0) < 1e-6, f"reference should read the flat center, got {ref}") + + +def test_compute_correction_clamps(): + curve = [0.0, -5.0, -20.0, -50.0] + correction, clamped_fraction = cal.compute_correction( + curve, reference_db=0.0, cap_db=15.0) + check(correction == [0.0, 5.0, 15.0, 15.0], + f"unexpected correction curve: {correction}") + # Two of four bins (the -20 and -50 dB ones) needed > 15 dB raw correction. + check(abs(clamped_fraction - 0.5) < 1e-9, + f"expected clamped_fraction 0.5, got {clamped_fraction}") + + +def test_compute_correction_never_negative(): + # A bin ABOVE the reference (e.g. a stray signal the median didn't fully + # reject) must not produce a negative "correction" that would attenuate + # a healthy bin. + curve = [5.0] + correction, _ = cal.compute_correction(curve, reference_db=0.0, cap_db=15.0) + check(correction == [0.0], f"correction must clamp to >= 0, got {correction}") + + +def test_validate_curve_flags_short_capture_count(): + correction = [1.0] * cal.FFT_SIZE + problems = cal.validate_curve(48, correction, cap_db=15.0, + capture_count=2, min_captures=5) + check(any("only 2 captures" in p for p in problems), + f"expected a low-capture-count problem, got {problems}") + + +def test_validate_curve_flags_wrong_length(): + problems = cal.validate_curve(48, [1.0, 2.0], cap_db=15.0, + capture_count=10, min_captures=5) + check(any("2 bins" in p for p in problems), + f"expected a wrong-length problem, got {problems}") + + +def test_validate_curve_flags_out_of_range(): + correction = [1.0] * cal.FFT_SIZE + correction[5] = 999.0 + problems = cal.validate_curve(48, correction, cap_db=15.0, + capture_count=10, min_captures=5) + check(any("bin 5" in p for p in problems), + f"expected an out-of-range problem for bin 5, got {problems}") + + +def test_warn_bin_jumps_flags_single_bin_spike(): + correction = [1.0, 1.0, 1.0, 20.0, 1.0, 1.0, 1.0] + warnings = cal.warn_bin_jumps(48, correction, jump_threshold_db=6.0) + check(any("bin 3" in w for w in warnings), + f"expected a spike warning for bin 3, got {warnings}") + + +def test_warn_bin_jumps_ignores_smooth_ramp(): + correction = [float(i) for i in range(10)] + warnings = cal.warn_bin_jumps(48, correction, jump_threshold_db=6.0) + check(warnings == [], f"a smooth ramp should not warn, got {warnings}") + + +def test_load_captures_accepts_bare_and_wrapped_shapes(): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + bare = {"ddc0RateKsps": 48, "fftSize": 4, "binsDbm": [1.0, 2.0, 3.0, 4.0]} + wrapped = {"id": "spectrum", "measured": { + "ddc0RateKsps": 96, "fftSize": 4, "binsDbm": [5.0, 6.0, 7.0, 8.0]}} + (tmp_path / "a.json").write_text(json.dumps(bare), encoding="utf-8") + (tmp_path / "b.json").write_text(json.dumps(wrapped), encoding="utf-8") + captures = cal.load_captures(tmp_path) + rates = sorted(c["ddc0RateKsps"] for c in captures) + check(rates == [48, 96], f"expected both shapes to load, got {rates}") + + +def test_load_captures_skips_incomplete_records(): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + incomplete = {"id": "spectrum", "measured": {"captured": False}} + (tmp_path / "a.json").write_text(json.dumps(incomplete), encoding="utf-8") + captures = cal.load_captures(tmp_path) + check(captures == [], f"an incomplete capture must be skipped, got {captures}") + + +def test_group_by_rate(): + captures = [ + {"ddc0RateKsps": 48, "binsDbm": [1.0]}, + {"ddc0RateKsps": 48, "binsDbm": [2.0]}, + {"ddc0RateKsps": 96, "binsDbm": [3.0]}, + ] + grouped = cal.group_by_rate(captures) + check(len(grouped[48]) == 2 and len(grouped[96]) == 1, + f"unexpected grouping: {grouped}") + + +def test_format_table_parseable_as_cpp_array(): + correction = [1.5] * cal.FFT_SIZE + text = cal.format_table(48, correction) + check("kDroopTable48Ksps" in text, f"table name missing: {text[:80]}") + check(text.count("1.5000f") == cal.FFT_SIZE, + "expected every value literally present") + check(text.strip().startswith("constexpr DroopCorrectionTable"), + f"unexpected table declaration: {text[:60]}") + + +def test_generate_inc_file_includes_all_six_rates_even_if_uncaptured(): + tables = {48: [0.0] * cal.FFT_SIZE} + text = cal.generate_inc_file(tables, {48: 10}, cap_db=15.0, + reference_window_fraction=0.15) + for rate in cal.VALID_RATES_KSPS: + check(f"kDroopTable{rate}Ksps" in text, + f"missing table for rate {rate} in generated .inc") + check("DO NOT HAND-EDIT" in text, "missing provenance header") + + +if __name__ == "__main__": + test_median_odd() + test_median_even() + test_median_power_curve_rejects_a_stray_outlier_capture() + test_reference_level_uses_central_window() + test_compute_correction_clamps() + test_compute_correction_never_negative() + test_validate_curve_flags_short_capture_count() + test_validate_curve_flags_wrong_length() + test_validate_curve_flags_out_of_range() + test_warn_bin_jumps_flags_single_bin_spike() + test_warn_bin_jumps_ignores_smooth_ramp() + test_load_captures_accepts_bare_and_wrapped_shapes() + test_load_captures_skips_incomplete_records() + test_group_by_rate() + test_format_table_parseable_as_cpp_array() + test_generate_inc_file_includes_all_six_rates_even_if_uncaptured() + print("anan droop calibration checks passed") From 58e9acaff404926e61da43969c110c87c5848d14 Mon Sep 17 00:00:00 2001 From: Sotiris Date: Thu, 20 Aug 2026 17:03:33 -0500 Subject: [PATCH 02/14] feat(anan): add AnanDroopCalibrator, the in-app droop sweep engine New, additive, and inert on its own -- nothing yet calls it. Adds the pure-math core of an in-app DDC0 edge-droop calibration sweep, mirroring Hl2FreqCal's shape: static, unit-testable methods (medianPowerCurve across captures, a central-window referenceLevel, computeCorrection's clamp), plus RadioSettingsScope-backed persistence (loadTables), plus the sweep state machine itself (phase enum + poll timer, mirroring MainWindow_SwrSweep's async-hardware-confirmation shape) and the AgcTCalibrator-style started/progress/rateSampled/finished signal vocabulary a live UI will bind to. computeCorrection's capDb defaults to 90 dB, not a smaller "safe" number picked in isolation: the sweep measures with a dummy load, so the curve it corrects IS the noise floor, and bench measurement on a real ANAN-G2 shows that floor sagging deeply at the worst edge bins. The cap exists as a guard against a corrupted measurement, not as a "past this point it's unrecoverable" line -- see the comment on computeCorrection() for the full reasoning. Bench iteration (15 -> 70 -> 90 dB) also showed the cap alone cannot fully close the gap at the true edge: the last few bins are noisy enough, run to run, that no finite cap produces a clean result -- that residual is handled by a separate cosmetic fade downstream (AnanDroopCorrection.h's applyEdgeFade()), not by chasing the cap further. Unit tests cover the outlier-rejecting median, the regression case for a floor-clamping bug this design deliberately avoids (a bin whose captures agree on a deep noise-floor value must not be floored to a higher number), the central-window reference, and the clamp's edges. Principle V. Co-Authored-By: Claude Sonnet 5 --- CMakeLists.txt | 1 + .../backends/anan/AnanDroopCalibrator.cpp | 298 ++++++++++++++++++ src/core/backends/anan/AnanDroopCalibrator.h | 178 +++++++++++ tests/anan_droop_calibrator_test.cpp | 155 +++++++++ tests/tests.cmake | 16 + 5 files changed, 648 insertions(+) create mode 100644 src/core/backends/anan/AnanDroopCalibrator.cpp create mode 100644 src/core/backends/anan/AnanDroopCalibrator.h create mode 100644 tests/anan_droop_calibrator_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 15d5e2dbe..2da85a808 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -714,6 +714,7 @@ set(CORE_SOURCES 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 (empirical, generated tables) + 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/src/core/backends/anan/AnanDroopCalibrator.cpp b/src/core/backends/anan/AnanDroopCalibrator.cpp new file mode 100644 index 000000000..3e9ea882d --- /dev/null +++ b/src/core/backends/anan/AnanDroopCalibrator.cpp @@ -0,0 +1,298 @@ +#include "core/backends/anan/AnanDroopCalibrator.h" + +#include "models/PanadapterModel.h" +#include "models/RadioModel.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; +} + +// ---- 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; + + 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; + + 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); + } + // Fire-and-forget (requestId 0): completes locally, same as + // Hl2Backend's freqcal.set -- no device round trip to await. + m_radio->invokeBackendExtension(QStringLiteral("anan"), QStringLiteral("droop.apply"), + 0, QVariant(byRate)); + emit finished(true); +} + +void AnanDroopCalibrator::clear() +{ + if (isRunning()) + return; + m_measuredTables.clear(); + m_pendingCaptures.clear(); +} + +// ---- phase state machine -------------------------------------------------- + +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(); + 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_lastSampleAtMs = 0; // capture immediately on the first Sampling tick + } + break; + + case Phase::Sampling: { + if (!m_haveLatestFrame) + break; + if (m_lastSampleAtMs != 0 && now - m_lastSampleAtMs < kSampleSpacingMs) + break; + m_lastSampleAtMs = now; + m_pendingCaptures.push_back(m_latestFrame); + 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..fd01b8817 --- /dev/null +++ b/src/core/backends/anan/AnanDroopCalibrator.h @@ -0,0 +1,178 @@ +#pragma once + +#include "core/backends/anan/AnanDroopCorrection.h" +#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, the same generic surface RadioCertification::stageSpectrum() +// already uses -- 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 + // (formerly tools/anan_droop_calibration.py, 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); + + // No saveTables() here -- the one writer is AnanBackend::invokeExtension()'s + // "anan"/"droop.apply" handler (applyResult() below calls into it), + // exactly once, never duplicated between a UI tab and the bridge verb. + +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), + // 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. + 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; + + static constexpr std::array kRatesKsps{48, 96, 192, 384, 768, 1536}; + 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; + + 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; + + bool m_haveLatestFrame = false; + Curve m_latestFrame{}; + QVector m_pendingCaptures; + QMap m_measuredTables; + QMetaObject::Connection m_spectrumConn; +}; + +} // namespace AetherSDR diff --git a/tests/anan_droop_calibrator_test.cpp b/tests/anan_droop_calibrator_test.cpp new file mode 100644 index 000000000..647fdec3e --- /dev/null +++ b/tests/anan_droop_calibrator_test.cpp @@ -0,0 +1,155 @@ +// Pure-math unit tests for AnanDroopCalibrator -- no live radio needed. +// Ports the test cases from this feature's original offline prototype +// (formerly tools/test_anan_droop_calibration.py, 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 +#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; +} + +} // namespace + +int main() +{ + 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; + std::printf("anan_droop_calibrator_test: all checks passed\n"); + return 0; +} diff --git a/tests/tests.cmake b/tests/tests.cmake index 3e08266e6..5eaa92b03 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -1879,6 +1879,22 @@ add_executable(anan_droop_correction_test 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 From 8254655d64eefdc6c9e9e63b4187f65aa46f78d1 Mon Sep 17 00:00:00 2001 From: Sotiris Date: Thu, 20 Aug 2026 15:59:38 -0500 Subject: [PATCH 03/14] fix(anan): AnanRxDsp holds a live droop table, not a compiled switch Cuts over from the compiled-table approach (a Python offline script generating AnanDroopCorrectionTables.inc) to a runtime-settable table set: AnanRxDsp::setDroopCorrectionTable(rateKsps, table) stores one DroopCorrectionTable per valid DDC0 rate in a QMap, ignoring a wrong-size table or an unrecognized rate rather than misaligning bin k against the wrong correction. processIqBlock() now looks up droopTableForRate(m_config.inputSampleRateHz / 1000) instead of the deleted compiled switch. The offline script, its test, and the generated .inc file are all removed -- AnanDroopCorrection.h/.cpp keep only the pure apply math (applyDroopCorrectionDb(), kDroopCorrectionZero), which stays exactly as tested before. Also fixes a real bug this cutover surfaces: installRebuiltChannel() (the path a live DDC0 rate change takes -- including an ordinary panadapter zoom, which snaps to a new rate and rebuilds the DSP chain in the background) swapped in the new WdspChannel/AnanSpectrum but never updated m_config.inputSampleRateHz, so the droop lookup above kept reading whatever rate was live at the last configure() forever, applying one rate's correction curve to a different rate's spectrum after every zoom past the first. RebuildResult now carries the rate buildChannel() actually built for, and installChannel() syncs m_config.inputSampleRateHz from it on every swap, not just the first configure(). Root-caused by reading the rebuild path, not by capturing a live "before" measurement at this specific rate pair -- the staleness was clear enough from the code alone (RebuildResult never carried the rate it was built for; installChannel() never wrote m_config outside the very first configure()). Bench-confirmed FIXED on a real ANAN-G2 across multiple live rate changes (48->384 ksps and 1536->96 ksps): the corrected spectrum is rate-appropriate and flat at every step, not mismatched or lumpy. anan_rxdsp_handedness_test's Group 5 now pins the insertion point via a synthetic setDroopCorrectionTable() call instead of the removed compiled lookup. New Group 6 rebuilds 48->96 ksps like a live rate change and asserts the emitted spectrum uses the 96 ksps table, not the stale 48 ksps one -- the regression test for the bug above. Principle VII. Co-Authored-By: Claude Sonnet 5 --- CMakeLists.txt | 2 +- .../backends/anan/AnanDroopCorrection.cpp | 21 -- src/core/backends/anan/AnanDroopCorrection.h | 16 +- .../anan/AnanDroopCorrectionTables.inc | 37 --- src/core/backends/anan/AnanRxDsp.cpp | 32 +- src/core/backends/anan/AnanRxDsp.h | 33 ++ tests/anan_droop_correction_test.cpp | 40 +-- tests/anan_rxdsp_handedness_test.cpp | 119 +++++++- tests/tests.cmake | 13 +- tools/anan_droop_calibration.py | 286 ------------------ tools/test_anan_droop_calibration.py | 182 ----------- 11 files changed, 194 insertions(+), 587 deletions(-) delete mode 100644 src/core/backends/anan/AnanDroopCorrectionTables.inc delete mode 100644 tools/anan_droop_calibration.py delete mode 100644 tools/test_anan_droop_calibration.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 2da85a808..a434356ed 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -713,7 +713,7 @@ 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 (empirical, generated tables) + 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) diff --git a/src/core/backends/anan/AnanDroopCorrection.cpp b/src/core/backends/anan/AnanDroopCorrection.cpp index 22cf5ab5d..232fdfb3a 100644 --- a/src/core/backends/anan/AnanDroopCorrection.cpp +++ b/src/core/backends/anan/AnanDroopCorrection.cpp @@ -3,30 +3,9 @@ namespace AetherSDR::anan { namespace { - -// Generated bulk data, included from this one .cpp only -- mirrors -// SpectralNR.cpp's own #include "Nr2GammaTables.inc" precedent. Regenerating -// the measured tables (tools/anan_droop_calibration.py) is then a drop-in -// file replace with zero CMakeLists.txt changes. -#include "AnanDroopCorrectionTables.inc" - constexpr DroopCorrectionTable kZeroTable{}; - } // namespace const DroopCorrectionTable& kDroopCorrectionZero = kZeroTable; -const DroopCorrectionTable& droopCorrectionTableForRateKsps(int ddc0RateKsps) noexcept -{ - switch (ddc0RateKsps) { - case 48: return kDroopTable48Ksps; - case 96: return kDroopTable96Ksps; - case 192: return kDroopTable192Ksps; - case 384: return kDroopTable384Ksps; - case 768: return kDroopTable768Ksps; - case 1536: return kDroopTable1536Ksps; - default: return kZeroTable; - } -} - } // namespace AetherSDR::anan diff --git a/src/core/backends/anan/AnanDroopCorrection.h b/src/core/backends/anan/AnanDroopCorrection.h index a47ed3544..7f474aaa7 100644 --- a/src/core/backends/anan/AnanDroopCorrection.h +++ b/src/core/backends/anan/AnanDroopCorrection.h @@ -13,21 +13,21 @@ namespace AetherSDR::anan { // 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 (tools/ -// anan_droop_calibration.py), to the actual FFT magnitude before display. +// 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 rate -- additive zero, not a guess. +// Safe no-op fallback for an unrecognized/uncalibrated rate -- additive +// zero, not a guess. extern const DroopCorrectionTable& kDroopCorrectionZero; -// Selects the measured table for a DDC0 rate in ksps (one of the six ANAN-G2 -// values: 48/96/192/384/768/1536). Returns kDroopCorrectionZero for any -// other rate. -[[nodiscard]] const DroopCorrectionTable& droopCorrectionTableForRateKsps(int ddc0RateKsps) noexcept; - // 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 diff --git a/src/core/backends/anan/AnanDroopCorrectionTables.inc b/src/core/backends/anan/AnanDroopCorrectionTables.inc deleted file mode 100644 index d11eab58b..000000000 --- a/src/core/backends/anan/AnanDroopCorrectionTables.inc +++ /dev/null @@ -1,37 +0,0 @@ -// AnanDroopCorrectionTables.inc -// -// DO NOT HAND-EDIT. Generated by tools/anan_droop_calibration.py from a -// bench calibration sweep of the ANAN-G2's real DDC0 CIC/decimation droop -// -- see AnanDroopCorrection.h for what this data is and why it exists. -// -// Generated: 2026-08-20 06:19 UTC -// Capture counts per rate: 48ksps=10, 96ksps=10, 192ksps=10, 384ksps=10, 768ksps=10, 1536ksps=10 -// cap_db=15.0 reference_window_fraction=0.15 -// -// Regenerate by running the calibration sweep against a real ANAN-G2 and -// re-running this script; it overwrites this file. - -constexpr DroopCorrectionTable kDroopTable48Ksps{{ - 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 13.4814f, 13.7061f, 9.9442f, 8.8462f, 10.1477f, 9.6325f, 8.9634f, 10.4437f, 8.6654f, 6.9795f, 5.2079f, 4.9907f, 5.9567f, 2.9512f, 3.5809f, 2.4377f, 1.5084f, 3.2782f, 2.3018f, 2.5213f, 2.1375f, 1.7515f, 1.8822f, 1.7986f, 0.0000f, 0.8545f, 1.8514f, 0.0000f, 0.3275f, 2.1183f, 0.0000f, 2.0391f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.0286f, 1.2709f, 1.1452f, 0.0000f, 0.3953f, 0.0000f, 0.0000f, 0.2547f, 0.1119f, 0.0000f, 0.0000f, 0.3901f, 0.4051f, 0.4374f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5365f, 1.2339f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2854f, 1.5790f, 1.3311f, 0.1192f, 0.5914f, 0.0000f, 0.0000f, 1.4402f, 0.5714f, 1.3873f, 0.0000f, 0.8119f, 0.0000f, 0.0000f, 1.5709f, 0.9763f, 0.0000f, 0.4146f, 0.7661f, 0.8035f, 0.8981f, 0.0000f, 0.0000f, 1.6894f, 0.4603f, 0.0000f, 0.0000f, 0.0000f, 0.5799f, 0.0000f, 0.0000f, 0.9087f, 0.0000f, 0.0000f, 0.6232f, 0.4111f, 0.0000f, 0.5181f, 0.9105f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.2561f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.7764f, 0.0000f, 0.2241f, 1.7655f, 0.0000f, 0.0000f, 0.0000f, 0.9813f, 0.0000f, 0.0000f, 0.0000f, 0.2852f, 0.1343f, 0.0000f, 0.1802f, 1.5928f, 0.6054f, 0.0000f, 0.0000f, 1.0568f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.9272f, 2.3826f, 1.0206f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.0696f, 0.0000f, 0.4335f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.8489f, 0.4151f, 0.0000f, 0.7568f, 1.0306f, 0.8681f, 0.2274f, 0.0000f, 0.0000f, 0.0000f, 1.3235f, 0.3192f, 0.0000f, 0.6371f, 0.1679f, 0.0000f, 0.0000f, 1.7034f, 1.3502f, 0.0000f, 0.0867f, 0.2967f, 1.0657f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.5081f, 1.1954f, 0.9002f, 0.0000f, 0.0000f, 0.0000f, 0.1020f, 1.4047f, 1.6331f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.4093f, 1.2333f, 0.0000f, 0.8796f, 0.3887f, 0.1427f, 0.0000f, 0.0000f, 1.7004f, 0.0000f, 0.0000f, 0.8309f, 1.2677f, 0.3596f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.8850f, 0.0000f, 0.3694f, 0.2233f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1934f, 0.0001f, 0.0000f, 0.0000f, 1.7725f, 2.8815f, 0.4200f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1537f, 0.0000f, 0.1343f, 0.0000f, 0.0000f, 0.2928f, 0.2901f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6328f, 0.9544f, 0.1129f, 0.0000f, 0.0000f, 1.1325f, 0.1224f, 0.0000f, 0.0000f, 0.3487f, 0.1116f, 0.1537f, 0.3738f, 0.9141f, 0.9248f, 0.0000f, 0.0000f, 0.0000f, 0.3904f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.8637f, 0.5274f, 0.0000f, 0.0000f, 0.4233f, 0.0000f, 0.0000f, 2.7165f, 2.2522f, 2.1447f, 0.3597f, 0.4506f, 1.5723f, 0.1221f, 0.0000f, 0.3946f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.3290f, 0.1790f, 0.0000f, 0.0000f, 0.0000f, 0.3195f, 0.4845f, 0.0000f, 0.0000f, 0.0000f, 0.7296f, 1.6897f, 0.4574f, 0.0000f, 0.0587f, 0.3909f, 1.3722f, 0.0000f, 0.9816f, 0.0000f, 0.0000f, 0.6697f, 2.4767f, 0.0000f, 0.6269f, 0.1433f, 0.0242f, 0.7282f, 0.4257f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.0858f, 2.0484f, 0.6292f, 0.0000f, 1.3205f, 0.5922f, 0.2413f, 0.4691f, 1.4521f, 2.7527f, 2.3297f, 1.0402f, 0.0000f, 0.5316f, 0.0000f, 0.7214f, 0.0000f, 2.7410f, 2.0378f, 0.0000f, 1.6422f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3191f, 0.0000f, 0.0000f, 0.3207f, 0.7114f, 0.0000f, 0.0000f, 0.8182f, 0.0000f, 0.6693f, 0.0000f, 3.1545f, 0.0000f, 1.1582f, 0.2741f, 0.0000f, 1.5401f, 2.0635f, 0.4576f, 0.0000f, 0.0000f, 0.4693f, 0.0476f, 0.7485f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1995f, 0.2798f, 0.0000f, 0.0000f, 0.8097f, 0.7858f, 0.9744f, 1.6739f, 1.0348f, 0.0000f, 0.4450f, 0.2571f, 0.0000f, 0.0000f, 0.0000f, 2.2526f, 0.4502f, 0.6580f, 1.8387f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.9736f, 2.2905f, 0.5476f, 0.9130f, 1.4286f, 0.9042f, 1.5106f, 0.0000f, 0.0000f, 0.0000f, 0.0015f, 0.0000f, 1.0526f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3507f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.1788f, 0.0000f, 0.0000f, 2.1553f, 0.1996f, 1.1890f, 0.0000f, 0.3733f, 3.0834f, 1.8103f, 1.3624f, 0.1799f, 3.6393f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0246f, 0.0000f, 0.0000f, 0.0000f, 1.2456f, 5.5362f, 0.6880f, 0.8220f, 0.0000f, 0.0000f, 1.8915f, 0.9694f, 0.0000f, 0.0000f, 0.0000f, 1.0021f, 0.0000f, 0.0000f, 0.1945f, 0.0802f, 0.1615f, 2.5779f, 0.7944f, 0.0000f, 0.0719f, 1.1499f, 0.0000f, 0.0000f, 0.3275f, 0.0000f, 0.0000f, 0.0000f, 0.4266f, 0.6712f, 1.0116f, 0.9160f, 2.5307f, 1.8061f, 2.8731f, 5.6078f, 0.6126f, 0.0000f, 0.1913f, 0.0000f, 0.4990f, 0.9671f, 0.0000f, 0.0000f, 0.2450f, 0.7997f, 3.4113f, 2.5360f, 0.5994f, 0.0000f, 0.0000f, 0.0000f, 0.9872f, 0.0000f, 0.0000f, 0.0000f, 1.1847f, 0.0000f, 0.0000f, 1.3730f, 1.3042f, 1.7753f, 2.3934f, 0.0000f, 0.0000f, 2.1056f, 1.0122f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5558f, 0.0000f, 1.6542f, 0.0000f, 0.0000f, 0.5723f, 1.6999f, 0.0000f, 1.9240f, 0.8187f, 0.0000f, 0.0000f, 0.3299f, 0.0000f, 0.0000f, 0.0000f, 0.1858f, 0.0000f, 0.3209f, 0.3333f, 0.8088f, 0.0000f, 0.2022f, 0.0000f, 0.0000f, 0.0000f, 0.1536f, 0.5918f, 0.7788f, 0.8055f, 0.0000f, 0.0000f, 0.0000f, 1.7658f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0686f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3143f, 0.0000f, 0.7248f, 0.2883f, 1.1078f, 0.5205f, 0.0000f, 1.5274f, 0.3312f, 1.2843f, 1.3244f, 0.0000f, 0.4652f, 2.8653f, 0.0089f, 2.2951f, 1.3296f, 1.7245f, 0.0000f, 2.0020f, 0.1062f, 0.0000f, 0.0000f, 0.6951f, 1.4572f, 0.0000f, 0.0000f, 0.9975f, 0.5313f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.2058f, 1.0465f, 0.0000f, 0.0000f, 0.0000f, 1.1333f, 0.0000f, 0.8242f, 2.3090f, 1.3197f, 0.0000f, 0.1382f, 0.0000f, 0.0000f, 1.0872f, 1.0831f, 0.9807f, 0.7196f, 0.3918f, 0.0000f, 0.0000f, 0.0000f, 0.6927f, 1.2556f, 0.3501f, 0.0000f, 0.0000f, 0.0000f, 0.7120f, 0.0000f, 0.6855f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.7770f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.5974f, 0.7119f, 0.0000f, 2.0012f, 1.9754f, 0.3622f, 0.0000f, 0.2088f, 1.1927f, 0.0000f, 0.0000f, 0.0000f, 0.9614f, 0.5172f, 2.0933f, 1.7097f, 0.0000f, 0.3827f, 0.3978f, 0.0000f, 0.5422f, 1.3462f, 1.3123f, 4.1652f, 0.8818f, 0.3090f, 0.2929f, 0.0971f, 0.0000f, 0.9314f, 0.2774f, 0.0000f, 0.4154f, 0.4188f, 0.7504f, 0.0000f, 0.1407f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4447f, 0.2667f, 0.0000f, 0.0000f, 0.0000f, 0.5908f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.0705f, 0.0000f, 0.0000f, 0.0000f, 0.6932f, 1.1117f, 0.9196f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3626f, 0.0000f, 0.6230f, 0.4436f, 1.1895f, 0.0000f, 0.0000f, 0.6145f, 0.1877f, 1.0683f, 0.8847f, 0.3241f, 1.7458f, 1.6929f, 0.0000f, 0.0000f, 1.7189f, 1.2531f, 0.7474f, 0.0000f, 0.6987f, 0.5521f, 0.2865f, 0.6794f, 0.0000f, 0.0000f, 0.3724f, 0.0000f, 0.0000f, 0.0000f, 0.9045f, 0.5412f, 0.1327f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0116f, 2.5582f, 0.8648f, 0.1433f, 1.2479f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6262f, 0.9060f, 0.4541f, 0.3386f, 0.0572f, 0.0000f, 1.2386f, 0.2120f, 0.0000f, 0.0588f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6671f, 0.0000f, 0.0000f, 1.3253f, 0.0000f, 0.0000f, 0.0000f, 1.2294f, 0.0000f, 1.6020f, 0.0000f, 0.0000f, 0.0000f, 0.0871f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1655f, 1.0817f, 0.7509f, 1.2724f, 0.0000f, 0.0000f, 0.0000f, 0.1177f, 2.3467f, 0.0000f, 0.0000f, 0.4853f, 0.0000f, 0.0000f, 0.0000f, 1.0855f, 0.6105f, 0.0000f, 1.1497f, 1.0484f, 0.8052f, 0.0000f, 0.0000f, 0.0000f, 1.1316f, 0.5436f, 0.4397f, 1.6287f, 0.0000f, 1.0278f, 0.8590f, 0.0000f, 0.0000f, 0.5955f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3844f, 0.0000f, 0.7676f, 1.3622f, 0.6613f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.3372f, 0.4562f, 1.6358f, 0.0000f, 0.0000f, 1.3428f, 1.2318f, 0.0000f, 0.0000f, 0.7421f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0483f, 1.7780f, 2.0880f, 0.0000f, 0.0000f, 2.4526f, 0.5787f, 2.2037f, 0.4281f, 1.7961f, 0.0000f, 0.0000f, 0.0000f, 2.9694f, 0.0000f, 0.7154f, 2.1476f, 0.0000f, 0.0000f, 0.2173f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.7788f, 0.9950f, 0.3746f, 1.3007f, 0.0000f, 2.7281f, 1.5978f, 1.5807f, 0.3432f, 0.2003f, 0.7247f, 2.6125f, 0.5104f, 0.4995f, 1.6437f, 2.0746f, 1.5794f, 2.4803f, 2.4410f, 4.8194f, 6.0348f, 8.2478f, 8.7138f, 5.9977f, 7.4042f, 8.5741f, 10.1969f, 8.5313f, 9.4121f, 11.9739f, 11.0141f, 13.6485f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f -}}; - -constexpr DroopCorrectionTable kDroopTable96Ksps{{ - 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 13.8509f, 14.6278f, 14.8237f, 12.5809f, 10.6154f, 11.0926f, 8.9885f, 9.3248f, 7.4641f, 5.3688f, 5.6834f, 8.6119f, 6.5192f, 6.8565f, 5.8092f, 5.9600f, 2.5771f, 1.2058f, 1.0725f, 0.0000f, 0.0074f, 1.4565f, 2.1469f, 1.2180f, 0.4066f, 0.0000f, 2.0119f, 2.4430f, 1.9182f, 2.9558f, 1.4879f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.2622f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6708f, 0.9661f, 0.0000f, 0.0000f, 0.1659f, 0.5814f, 0.5701f, 1.0692f, 0.0000f, 2.4405f, 0.0000f, 0.4421f, 0.9760f, 0.8149f, 1.7199f, 1.4307f, 0.5546f, 0.3416f, 0.0000f, 0.0000f, 1.0294f, 0.6944f, 0.3819f, 0.0000f, 2.0894f, 1.0175f, 0.8724f, 1.2810f, 0.5486f, 0.8674f, 0.0000f, 0.0000f, 0.0314f, 0.0000f, 0.0000f, 0.0000f, 0.4554f, 0.2317f, 0.0000f, 0.0000f, 1.0062f, 0.0000f, 1.1544f, 1.5590f, 1.2441f, 1.5918f, 0.0000f, 0.0000f, 0.9351f, 0.0000f, 1.9371f, 1.1230f, 1.5963f, 1.3313f, 0.0012f, 0.0000f, 0.0000f, 0.3632f, 0.0000f, 0.0000f, 0.3225f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6058f, 0.8780f, 1.3622f, 0.5257f, 1.0857f, 0.1691f, 0.0000f, 0.8112f, 3.6796f, 1.6966f, 0.0000f, 1.4552f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.9986f, 0.0000f, 1.7950f, 0.0000f, 0.5283f, 0.0000f, 1.3634f, 0.0000f, 0.1286f, 1.0782f, 0.0000f, 0.0000f, 0.6963f, 0.4254f, 0.8947f, 3.1297f, 2.6512f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5393f, 0.0000f, 1.2314f, 1.1096f, 2.0714f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.9442f, 0.3638f, 0.1317f, 0.0000f, 0.4003f, 1.7233f, 1.3430f, 0.1499f, 0.0000f, 0.0000f, 0.9980f, 0.2585f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 3.9180f, 1.1048f, 0.0000f, 1.1503f, 0.2645f, 0.8511f, 2.4109f, 1.6939f, 0.4093f, 0.5477f, 1.6287f, 0.8864f, 0.5798f, 1.4552f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1293f, 1.8168f, 0.5185f, 0.5203f, 1.2780f, 0.4313f, 0.3799f, 0.5982f, 0.0000f, 0.3679f, 0.2263f, 0.8686f, 0.0830f, 0.0000f, 0.2115f, 1.0077f, 0.0000f, 1.1098f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.1944f, 0.0000f, 0.0000f, 0.0000f, 0.5038f, 0.0000f, 0.6506f, 0.9839f, 0.5664f, 0.0000f, 0.2359f, 0.2973f, 0.9596f, 0.0000f, 0.0000f, 1.2047f, 0.7754f, 0.0000f, 0.0000f, 1.7881f, 0.6642f, 0.0000f, 0.0000f, 1.9323f, 0.7069f, 1.0721f, 0.0000f, 0.2176f, 1.3587f, 1.3091f, 0.2498f, 0.0156f, 0.0000f, 0.0000f, 0.3034f, 5.0173f, 2.1688f, 0.0000f, 0.0000f, 0.0959f, 1.1386f, 3.6346f, 0.0000f, 0.0000f, 1.4887f, 1.6210f, 0.7108f, 0.0000f, 0.0000f, 0.0000f, 0.2334f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.2099f, 0.5563f, 0.0000f, 0.0000f, 1.8906f, 2.0153f, 0.0000f, 0.0000f, 0.8582f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.9391f, 0.2009f, 0.0000f, 1.1321f, 0.8699f, 0.0152f, 0.0000f, 1.2603f, 0.1703f, 0.0000f, 1.9084f, 0.7080f, 0.0000f, 1.4190f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4246f, 0.0549f, 0.5181f, 0.0000f, 0.6451f, 1.3411f, 1.4378f, 0.0000f, 0.6831f, 2.0379f, 0.0000f, 0.0000f, 0.0000f, 0.9897f, 0.0000f, 0.0000f, 0.0000f, 1.2609f, 0.0000f, 0.0000f, 1.4463f, 0.0000f, 0.0000f, 0.8256f, 0.7723f, 0.9601f, 0.5640f, 1.0583f, 0.5111f, 0.0000f, 2.2350f, 0.0000f, 0.0000f, 0.6372f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0608f, 0.4967f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.5277f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.2455f, 0.0000f, 0.0000f, 0.7854f, 0.0000f, 1.2673f, 0.3101f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2403f, 0.0000f, 0.0000f, 0.0000f, 0.3125f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5654f, 0.0000f, 0.0000f, 0.0000f, 0.3312f, 0.6139f, 2.0393f, 0.5218f, 0.0000f, 0.0000f, 0.0000f, 0.5905f, 0.0413f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5704f, 0.5495f, 0.0000f, 0.3541f, 1.1437f, 0.0010f, 0.0000f, 0.0114f, 0.3191f, 1.0672f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.9351f, 0.1028f, 0.9468f, 0.0000f, 0.3641f, 0.0000f, 0.0000f, 1.3653f, 0.5575f, 0.0000f, 0.0000f, 0.0000f, 0.8254f, 1.1679f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.4740f, 0.4007f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4954f, 0.5403f, 1.0605f, 0.8943f, 1.4871f, 0.4772f, 0.0000f, 0.7902f, 0.4451f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 3.0876f, 3.3603f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0303f, 1.1580f, 0.1590f, 1.5131f, 0.0574f, 1.1878f, 1.4561f, 0.0000f, 0.0000f, 0.9184f, 0.0000f, 0.0000f, 0.0000f, 0.2225f, 1.0730f, 2.4113f, 3.0249f, 0.8241f, 0.0000f, 0.8256f, 0.0000f, 0.0000f, 0.1376f, 0.4588f, 0.9627f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.5432f, 1.2148f, 0.5174f, 0.0000f, 0.6064f, 0.0000f, 0.8151f, 1.3650f, 0.4419f, 1.0691f, 0.8943f, 0.0000f, 1.2092f, 0.0800f, 1.0962f, 0.0000f, 0.0000f, 1.1994f, 0.0000f, 0.5969f, 0.0000f, 0.3026f, 0.0241f, 0.0000f, 0.2684f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5802f, 2.9080f, 0.8643f, 0.0000f, 0.4315f, 1.7041f, 1.7051f, 1.0172f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1824f, 1.2964f, 0.0000f, 0.0000f, 2.1853f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.0540f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3438f, 1.0668f, 0.2473f, 0.0000f, 0.5134f, 0.0000f, 0.0000f, 0.3456f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2500f, 2.6484f, 1.9879f, 0.1683f, 0.4365f, 0.0000f, 0.7828f, 1.3157f, 1.0659f, 0.9702f, 0.7339f, 0.0000f, 0.0000f, 0.2550f, 0.5240f, 0.8350f, 0.4808f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0165f, 0.0000f, 0.0000f, 0.8068f, 0.2649f, 0.0000f, 0.1021f, 0.0000f, 0.5570f, 0.0000f, 0.0989f, 0.6911f, 0.0000f, 0.0000f, 0.1701f, 1.2804f, 0.2742f, 0.0000f, 0.0000f, 0.0000f, 0.3486f, 0.2130f, 0.1628f, 0.0000f, 0.0000f, 0.0000f, 0.2511f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.8291f, 0.0000f, 0.0000f, 0.0000f, 0.7668f, 0.0000f, 0.0000f, 0.4771f, 0.0000f, 2.0412f, 0.1635f, 1.2004f, 0.0000f, 0.0000f, 1.3358f, 1.7317f, 1.5047f, 0.0000f, 0.0000f, 0.7383f, 0.0000f, 0.0000f, 3.5022f, 1.9164f, 0.0000f, 0.0000f, 0.1298f, 0.1008f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.5788f, 0.0000f, 1.6440f, 0.9735f, 0.0000f, 0.4575f, 0.0000f, 0.0000f, 0.2326f, 0.2777f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.5037f, 1.6166f, 1.2540f, 2.4782f, 0.4844f, 0.0000f, 0.0000f, 1.0020f, 0.0000f, 1.4583f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0956f, 1.0968f, 0.0000f, 1.0347f, 0.4692f, 0.2421f, 0.0000f, 0.7294f, 0.0000f, 1.1397f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4682f, 0.1701f, 0.0000f, 0.2869f, 0.0000f, 0.0000f, 0.5569f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3161f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6422f, 0.0000f, 0.5287f, 0.0000f, 0.4361f, 0.0000f, 0.0000f, 1.3262f, 0.0000f, 1.9334f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4973f, 0.0000f, 0.1625f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0832f, 1.3147f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1169f, 0.2089f, 0.3363f, 0.0000f, 0.0000f, 0.0000f, 1.4614f, 0.3944f, 1.2477f, 0.0000f, 2.0480f, 0.0000f, 0.6817f, 0.6032f, 0.0000f, 1.9554f, 0.6195f, 0.0000f, 0.4273f, 0.3104f, 1.8175f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2606f, 1.0317f, 0.0000f, 0.0000f, 1.7507f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4393f, 0.0000f, 0.7331f, 0.0000f, 0.0000f, 0.0000f, 0.7378f, 0.0000f, 0.0000f, 0.0000f, 0.4918f, 2.4553f, 3.8872f, 0.0000f, 0.9093f, 0.0000f, 0.0000f, 0.0000f, 1.5643f, 0.7658f, 0.0000f, 0.0000f, 1.3147f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0301f, 0.5495f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4158f, 0.4675f, 1.9949f, 2.1069f, 1.0213f, 0.8469f, 0.1672f, 1.5419f, 0.0000f, 2.3526f, 0.0000f, 0.6400f, 0.0000f, 0.0000f, 0.0000f, 1.0002f, 0.0718f, 1.1654f, 0.0806f, 0.0000f, 0.0000f, 0.0000f, 0.8440f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3810f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.9120f, 0.4244f, 0.0000f, 0.0000f, 0.0000f, 0.6703f, 0.0933f, 1.7438f, 0.0000f, 0.0000f, 1.1497f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1602f, 0.7672f, 0.0000f, 0.0000f, 0.0000f, 0.7893f, 0.7738f, 0.3950f, 0.0000f, 0.0000f, 0.0000f, 1.5843f, 2.6319f, 0.4726f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.3123f, 1.4627f, 0.5512f, 0.5202f, 1.1253f, 2.9569f, 2.8600f, 3.1080f, 3.3344f, 3.3178f, 1.9787f, 2.9589f, 5.9053f, 6.9687f, 6.8184f, 7.1891f, 8.3430f, 8.6203f, 7.9073f, 11.6717f, 10.6669f, 10.5488f, 10.9154f, 12.7241f, 14.3078f, 14.6541f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f -}}; - -constexpr DroopCorrectionTable kDroopTable192Ksps{{ - 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 13.6207f, 14.1448f, 12.3424f, 10.7272f, 10.3810f, 9.3742f, 8.7653f, 7.8668f, 7.3557f, 6.9759f, 6.2347f, 5.4071f, 4.4551f, 4.9006f, 4.3405f, 5.8281f, 3.4548f, 3.8142f, 1.9483f, 2.2179f, 1.0758f, 1.8385f, 2.2758f, 2.0111f, 1.5552f, 0.0211f, 0.0647f, 1.5927f, 1.1501f, 0.0000f, 0.0753f, 0.0000f, 0.0000f, 0.0000f, 0.8247f, 0.8178f, 0.1070f, 0.4797f, 1.8207f, 0.1578f, 0.9324f, 0.0000f, 0.0000f, 2.4778f, 0.0000f, 0.0000f, 0.0000f, 1.9030f, 0.0000f, 0.0000f, 0.5852f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.7432f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.2332f, 0.2851f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.8388f, 0.7314f, 0.0000f, 0.0000f, 0.4790f, 0.2835f, 0.0000f, 0.0668f, 0.0000f, 0.0000f, 0.0000f, 0.9198f, 0.1646f, 0.0000f, 0.6380f, 2.1151f, 1.2369f, 1.6079f, 1.2332f, 0.0873f, 0.0000f, 0.4813f, 1.9377f, 0.5694f, 0.0000f, 2.1981f, 0.0000f, 1.3807f, 0.0000f, 0.0000f, 0.3830f, 0.0000f, 0.0541f, 0.9246f, 0.9066f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2279f, 0.0000f, 0.5042f, 0.1591f, 0.0000f, 0.1005f, 2.9607f, 0.5275f, 0.0000f, 0.0000f, 2.0738f, 1.2474f, 0.0000f, 0.5528f, 0.0000f, 0.3265f, 1.3301f, 0.7342f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4219f, 2.5789f, 1.8661f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.0288f, 0.0000f, 1.0106f, 0.3502f, 1.1205f, 0.7924f, 1.5582f, 0.0143f, 0.8433f, 0.0717f, 1.4608f, 0.6603f, 1.1092f, 0.0000f, 1.2516f, 0.0000f, 0.0000f, 0.0000f, 0.5860f, 0.0000f, 0.2532f, 0.0000f, 1.6306f, 0.0000f, 0.1748f, 2.4043f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2694f, 0.2335f, 0.0000f, 0.0000f, 1.3838f, 0.2805f, 1.3463f, 0.0090f, 0.0000f, 2.6160f, 0.3683f, 0.0000f, 0.0000f, 0.0000f, 0.4145f, 0.5570f, 1.7539f, 0.7134f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.4750f, 0.0652f, 1.0957f, 0.0544f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.7548f, 0.0000f, 0.8685f, 0.0000f, 0.0000f, 1.1817f, 1.2302f, 0.0000f, 0.5969f, 0.0000f, 0.5719f, 1.9289f, 1.3419f, 1.1701f, 0.5990f, 0.7639f, 0.1162f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.0828f, 0.0000f, 0.4361f, 1.1102f, 0.1780f, 0.2734f, 0.4447f, 2.1252f, 0.3566f, 0.8746f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.0176f, 1.7349f, 0.3321f, 0.1678f, 1.7679f, 1.1803f, 3.1730f, 0.0000f, 0.0000f, 2.8390f, 1.1457f, 0.1635f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.7560f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6867f, 0.6884f, 1.3344f, 0.6792f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.1017f, 2.2320f, 0.0000f, 0.0169f, 0.0000f, 0.0000f, 0.0940f, 2.9506f, 0.5271f, 0.7201f, 0.0000f, 0.6975f, 0.4853f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4733f, 0.0000f, 0.2344f, 0.0000f, 0.0000f, 0.0000f, 1.0361f, 1.9852f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.0596f, 0.1390f, 0.2718f, 0.4929f, 0.5141f, 1.5737f, 0.0000f, 0.0000f, 0.0000f, 1.7800f, 0.0000f, 0.1000f, 0.0000f, 0.0000f, 1.0403f, 0.1831f, 1.9964f, 1.7641f, 2.8640f, 1.1193f, 2.0952f, 0.0581f, 0.0000f, 0.0000f, 2.6574f, 0.0000f, 0.0000f, 1.3939f, 0.9218f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0003f, 0.0000f, 1.3367f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5637f, 2.0165f, 0.0000f, 0.0000f, 0.0000f, 0.8469f, 1.4226f, 1.1526f, 1.2911f, 0.0276f, 0.0000f, 1.9379f, 0.0606f, 0.0000f, 0.0000f, 1.0231f, 0.0000f, 0.7176f, 1.7508f, 0.4385f, 1.8774f, 0.0000f, 1.4353f, 0.0000f, 1.9887f, 0.6914f, 0.0000f, 0.0000f, 0.7999f, 1.7408f, 0.0000f, 0.0000f, 0.0000f, 1.0063f, 0.0000f, 1.0704f, 0.4491f, 0.1319f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4513f, 0.6252f, 0.3997f, 0.0000f, 0.0000f, 0.0000f, 0.3291f, 0.3652f, 0.7696f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2371f, 1.4720f, 0.3703f, 0.0000f, 0.0000f, 0.0000f, 2.1427f, 1.5538f, 0.0000f, 0.0000f, 0.0000f, 0.6342f, 0.0000f, 0.4226f, 0.0000f, 0.8209f, 0.3598f, 0.9413f, 0.7286f, 0.8189f, 0.0000f, 0.0000f, 0.6079f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3212f, 0.0000f, 0.1119f, 0.0000f, 0.3478f, 0.7983f, 0.1368f, 0.0000f, 0.0000f, 0.0000f, 0.2376f, 1.4456f, 1.2865f, 0.0000f, 0.0000f, 0.0100f, 0.2453f, 0.2529f, 0.0000f, 0.5463f, 0.3652f, 0.0000f, 2.0853f, 0.0000f, 0.0000f, 0.0000f, 1.0207f, 1.9745f, 5.2984f, 3.3544f, 0.0000f, 0.0000f, 0.2092f, 1.6258f, 0.4643f, 1.2647f, 0.0000f, 3.2759f, 1.8847f, 1.2488f, 0.9878f, 0.0000f, 0.8835f, 0.1729f, 0.0000f, 0.6878f, 0.2924f, 0.0000f, 2.1776f, 2.0143f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.7925f, 1.3123f, 0.5893f, 0.6951f, 1.0861f, 0.0000f, 0.2151f, 0.0000f, 0.0000f, 0.4928f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.2026f, 0.3834f, 1.2583f, 1.2827f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5837f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.1948f, 1.1607f, 0.6703f, 1.1988f, 0.9356f, 0.6100f, 2.7993f, 0.9102f, 0.0000f, 0.0000f, 0.0000f, 0.8922f, 0.3200f, 0.0000f, 0.0000f, 0.0000f, 0.0274f, 0.0508f, 1.0589f, 0.8522f, 0.0000f, 1.4311f, 0.3242f, 0.5747f, 0.3643f, 0.0000f, 0.0000f, 0.0000f, 0.1023f, 0.3575f, 0.0000f, 1.8916f, 0.0000f, 0.3100f, 0.4591f, 1.1732f, 0.2562f, 0.0000f, 0.6025f, 0.9677f, 0.0300f, 0.0000f, 0.1037f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.9880f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3272f, 0.0000f, 0.7065f, 0.0000f, 0.0000f, 0.0000f, 0.6709f, 0.2147f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.7268f, 0.2723f, 0.2686f, 3.6536f, 0.7623f, 0.0000f, 0.8168f, 1.9961f, 0.0000f, 0.0000f, 0.4301f, 0.0000f, 0.0679f, 1.3082f, 0.5894f, 0.0000f, 0.0000f, 0.0000f, 0.2404f, 0.0000f, 0.8783f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.6669f, 0.6676f, 0.0000f, 0.0000f, 0.0000f, 0.4505f, 0.2317f, 0.0000f, 0.5735f, 0.0000f, 0.0000f, 1.8045f, 0.0000f, 0.0000f, 0.8905f, 0.2496f, 0.0000f, 0.0000f, 0.0210f, 0.8840f, 0.3182f, 0.0000f, 0.0000f, 0.4518f, 2.0963f, 0.0000f, 1.9294f, 0.0000f, 0.2320f, 1.0945f, 0.0000f, 0.0997f, 0.2711f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4715f, 0.0000f, 1.7974f, 0.6601f, 0.1798f, 0.0000f, 0.5395f, 0.0000f, 0.0000f, 1.5494f, 0.0000f, 0.6658f, 0.0000f, 1.0416f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2511f, 0.3974f, 1.5720f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5022f, 1.6102f, 0.6656f, 0.0000f, 1.5518f, 0.3789f, 0.0000f, 2.4646f, 0.4117f, 1.1314f, 0.0000f, 2.2533f, 0.0000f, 0.8809f, 0.0000f, 0.4724f, 0.0000f, 0.5745f, 0.2528f, 0.2414f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2593f, 0.0000f, 0.0000f, 0.0000f, 0.2939f, 0.0000f, 0.3825f, 1.4359f, 0.0473f, 0.0262f, 1.0999f, 0.0000f, 0.4263f, 0.0518f, 1.1855f, 0.1615f, 0.0000f, 0.0000f, 0.0000f, 2.1261f, 0.0000f, 0.0000f, 0.0000f, 1.6171f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2023f, 1.7694f, 1.5025f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.7487f, 0.3676f, 0.0000f, 0.0276f, 0.0000f, 0.0000f, 0.0000f, 0.3040f, 0.4028f, 0.8727f, 0.0000f, 0.0000f, 1.0491f, 0.0000f, 0.0000f, 0.0000f, 1.0127f, 0.1536f, 0.0000f, 0.1919f, 0.6662f, 0.0656f, 2.1428f, 0.0000f, 3.4138f, 2.0196f, 0.1173f, 0.0000f, 0.0000f, 0.0000f, 0.4064f, 0.0000f, 0.3162f, 1.5687f, 1.2770f, 0.0000f, 0.0000f, 0.9062f, 1.1399f, 1.0026f, 0.0000f, 1.0010f, 1.8478f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0280f, 0.4899f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.7397f, 0.8152f, 0.1755f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.7716f, 0.3577f, 0.0785f, 0.0000f, 0.5533f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.3312f, 0.0000f, 0.0717f, 0.3558f, 2.2713f, 3.5197f, 0.2315f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0113f, 0.1770f, 1.5452f, 0.8795f, 0.0000f, 0.0000f, 1.0073f, 0.9004f, 1.7056f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.7579f, 2.6532f, 0.9141f, 0.0000f, 0.7376f, 0.0000f, 0.1483f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.8873f, 0.0000f, 1.2889f, 0.0000f, 0.2711f, 0.9446f, 1.2136f, 1.4821f, 0.3810f, 0.0941f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1719f, 0.0379f, 1.5140f, 0.7694f, 0.8859f, 0.2108f, 0.3443f, 0.0000f, 0.0684f, 1.9621f, 1.0580f, 0.0000f, 1.3883f, 0.8511f, 0.0000f, 0.0000f, 0.7644f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3795f, 1.5631f, 0.0000f, 0.0000f, 0.8696f, 0.9457f, 1.1767f, 0.8485f, 1.5642f, 0.1108f, 1.8149f, 2.4111f, 2.9222f, 3.9341f, 3.2645f, 3.9720f, 4.3526f, 5.8608f, 5.2886f, 5.8211f, 8.8346f, 6.9358f, 6.5874f, 9.5157f, 9.3837f, 9.8434f, 10.9590f, 11.0693f, 13.5683f, 13.5300f, 14.0887f, 14.4369f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f -}}; - -constexpr DroopCorrectionTable kDroopTable384Ksps{{ - 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 13.4635f, 12.5058f, 11.9880f, 10.6686f, 9.5310f, 9.7662f, 9.5666f, 10.0386f, 9.0205f, 7.5873f, 7.6019f, 6.9870f, 7.1159f, 5.9678f, 4.6689f, 3.6661f, 3.7944f, 3.2215f, 5.3016f, 3.4910f, 1.9753f, 2.2990f, 1.4351f, 0.5857f, 2.4500f, 0.9881f, 0.0000f, 0.9037f, 1.2105f, 0.0000f, 0.8231f, 0.0000f, 2.0671f, 0.4348f, 1.8088f, 0.1565f, 0.4818f, 1.0210f, 1.0155f, 1.4261f, 0.4976f, 1.4822f, 0.0000f, 0.0000f, 0.6635f, 0.0000f, 0.6501f, 0.0000f, 2.0670f, 0.0000f, 0.1829f, 0.0000f, 0.0000f, 0.0003f, 0.4836f, 0.8217f, 0.0000f, 3.0912f, 0.3549f, 0.0000f, 0.0000f, 0.5837f, 2.3157f, 0.0000f, 0.0000f, 1.8315f, 0.8455f, 0.0000f, 0.0000f, 0.7196f, 0.0000f, 0.3021f, 0.1343f, 1.1060f, 0.0000f, 0.5988f, 0.0000f, 1.5520f, 0.4723f, 0.4160f, 0.2795f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3516f, 0.0000f, 0.0000f, 1.3277f, 0.4805f, 0.8792f, 0.0000f, 0.6142f, 0.5317f, 0.7247f, 0.0000f, 0.8651f, 0.0000f, 1.3643f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6222f, 0.2590f, 0.0000f, 0.0000f, 0.0000f, 2.1008f, 0.0000f, 0.0000f, 0.6334f, 0.0000f, 1.1525f, 0.0000f, 1.1350f, 0.3035f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.1448f, 1.7326f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.6439f, 0.5870f, 0.2294f, 1.3260f, 1.5031f, 0.5538f, 0.0384f, 0.1481f, 0.4662f, 0.9672f, 1.3469f, 0.0000f, 0.4503f, 1.3546f, 0.0000f, 0.0000f, 1.2043f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4256f, 0.0512f, 0.7112f, 1.3782f, 0.0000f, 0.0468f, 0.5491f, 0.1265f, 0.0000f, 0.8048f, 0.0000f, 0.9366f, 1.7536f, 0.0000f, 0.6568f, 1.9011f, 0.1709f, 1.4159f, 0.0000f, 0.0000f, 0.7871f, 0.0000f, 0.0000f, 0.0000f, 0.8685f, 0.0000f, 1.1675f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1867f, 0.4805f, 1.6009f, 0.0731f, 0.0000f, 0.0000f, 0.3293f, 1.2269f, 0.0000f, 0.0712f, 0.0000f, 0.9722f, 0.0000f, 0.7610f, 0.0318f, 0.0000f, 0.6953f, 1.1950f, 0.0831f, 1.3416f, 0.7298f, 0.5992f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.1486f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6703f, 0.0000f, 0.0000f, 0.0000f, 0.7937f, 1.8581f, 1.4335f, 0.0000f, 0.0000f, 0.6772f, 0.9687f, 0.0000f, 0.8885f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3600f, 0.0000f, 0.0000f, 0.4297f, 0.3968f, 0.0000f, 0.7992f, 1.1338f, 1.3845f, 1.1167f, 0.1656f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.8545f, 1.4045f, 1.1217f, 0.0000f, 0.7387f, 1.0324f, 0.0000f, 1.2607f, 2.5404f, 0.4334f, 0.0000f, 1.1235f, 0.0000f, 0.0000f, 1.0727f, 0.0000f, 0.0220f, 0.0000f, 0.0000f, 0.5134f, 0.0000f, 2.1127f, 0.6089f, 0.0000f, 0.9004f, 0.0000f, 0.0000f, 0.0000f, 0.8815f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1786f, 0.1288f, 0.3074f, 1.3870f, 0.0000f, 0.0000f, 0.4376f, 0.2430f, 0.0000f, 0.0000f, 0.0000f, 0.4601f, 0.9945f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.8354f, 1.5910f, 0.2107f, 0.7486f, 0.0000f, 0.0000f, 0.1998f, 0.0000f, 0.0000f, 1.4013f, 1.8425f, 0.7241f, 0.2329f, 0.0000f, 0.5686f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2106f, 0.0000f, 0.0842f, 0.0000f, 0.4040f, 0.3277f, 0.0000f, 0.2565f, 1.0654f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3023f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6056f, 0.3778f, 0.0000f, 0.3564f, 0.0000f, 0.0000f, 0.7691f, 0.7645f, 0.7074f, 1.3329f, 0.5421f, 1.3079f, 0.0000f, 0.0000f, 0.0000f, 1.4767f, 0.0000f, 1.3246f, 0.9519f, 0.0000f, 0.6511f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2103f, 2.3671f, 0.0000f, 2.6074f, 0.7049f, 0.0000f, 1.1406f, 0.0000f, 0.8049f, 0.2650f, 0.0000f, 1.8585f, 0.2326f, 0.0000f, 2.0243f, 1.4831f, 0.0000f, 0.0000f, 0.4871f, 0.0000f, 0.0000f, 0.9715f, 0.0000f, 0.0000f, 1.2605f, 0.1466f, 0.0101f, 0.0000f, 0.0344f, 0.7328f, 2.2687f, 1.7489f, 0.5113f, 2.2390f, 1.1618f, 1.1205f, 1.3570f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.1142f, 0.0886f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6155f, 0.7946f, 0.9698f, 0.0000f, 0.2504f, 1.1206f, 0.2359f, 0.3918f, 0.0000f, 0.0510f, 0.0000f, 0.0000f, 0.7299f, 0.0000f, 0.0000f, 1.6021f, 0.1638f, 0.0000f, 0.4808f, 0.0000f, 0.0459f, 0.5274f, 0.0000f, 0.0000f, 0.0000f, 0.0465f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.0953f, 3.4191f, 1.7060f, 0.7915f, 1.3640f, 2.0848f, 0.4540f, 1.1554f, 0.0000f, 2.2736f, 0.7144f, 1.3352f, 4.5830f, 1.7642f, 0.2400f, 0.0000f, 0.2503f, 1.6223f, 0.0000f, 0.0000f, 0.2725f, 0.4261f, 0.0000f, 1.0149f, 0.5214f, 0.0000f, 2.2845f, 2.8058f, 3.5874f, 1.5777f, 0.0000f, 0.0000f, 0.0000f, 0.0534f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0979f, 1.7116f, 0.4519f, 0.0000f, 0.0283f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0904f, 0.0000f, 0.0000f, 0.0000f, 1.2309f, 0.0000f, 0.0000f, 2.7855f, 2.0727f, 1.1436f, 0.0257f, 0.9517f, 0.0000f, 0.2032f, 0.6983f, 1.1623f, 0.0000f, 0.1570f, 0.0000f, 0.0000f, 0.0000f, 0.2112f, 0.0000f, 0.5036f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.3945f, 0.2529f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0293f, 1.4062f, 0.0000f, 0.0000f, 1.2033f, 2.1968f, 1.4955f, 0.0000f, 0.1649f, 0.3132f, 0.1823f, 0.4113f, 0.0000f, 0.5244f, 0.8394f, 0.0000f, 1.0477f, 0.2994f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6855f, 0.3979f, 0.0000f, 0.0000f, 0.9171f, 0.5752f, 0.6346f, 0.7820f, 0.0000f, 0.1413f, 1.3673f, 2.3768f, 0.5994f, 1.7277f, 0.6516f, 0.0000f, 0.7587f, 0.0000f, 0.4478f, 0.0000f, 0.0000f, 0.0000f, 2.6363f, 0.0000f, 1.7034f, 0.0000f, 0.0408f, 1.7640f, 0.0000f, 1.3069f, 0.2052f, 0.9868f, 0.6158f, 2.1244f, 0.0000f, 0.0000f, 0.2779f, 2.9000f, 0.9556f, 0.0000f, 0.0000f, 0.0000f, 2.3953f, 0.2698f, 0.0000f, 0.0000f, 0.3636f, 0.1903f, 1.7160f, 1.3200f, 0.0000f, 1.7476f, 1.0827f, 1.9123f, 0.0000f, 0.0000f, 0.0000f, 0.2142f, 0.0000f, 0.6255f, 0.0000f, 0.8268f, 0.5161f, 0.0000f, 1.3426f, 2.2358f, 0.0000f, 0.0000f, 0.7872f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6621f, 0.1657f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0104f, 0.0000f, 1.4736f, 0.0000f, 0.1036f, 1.4822f, 0.0000f, 0.0000f, 2.1354f, 0.0441f, 0.3341f, 0.0000f, 0.4294f, 0.8371f, 0.8224f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2167f, 0.0478f, 0.6713f, 0.2080f, 0.0000f, 1.1899f, 0.0000f, 0.3360f, 1.0095f, 0.0000f, 2.0369f, 1.4410f, 0.0000f, 0.0000f, 0.7418f, 0.0000f, 0.0000f, 0.6099f, 0.2455f, 0.4465f, 0.0000f, 0.8342f, 2.0218f, 0.0907f, 2.1132f, 0.0000f, 1.5552f, 0.3229f, 0.0000f, 0.0000f, 1.4025f, 0.0000f, 0.0000f, 1.1281f, 1.2729f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.0064f, 0.0000f, 0.0769f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.2345f, 0.0000f, 0.9329f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0438f, 0.0000f, 0.1070f, 1.3298f, 0.0000f, 0.0000f, 0.0000f, 1.4592f, 0.7297f, 0.0000f, 0.0000f, 0.1966f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1781f, 1.6125f, 0.1535f, 1.2032f, 0.8126f, 0.1157f, 0.6911f, 0.0409f, 1.7907f, 1.4563f, 0.1943f, 1.5829f, 0.0000f, 0.0000f, 1.4278f, 0.9773f, 1.0298f, 0.0000f, 0.0000f, 1.4553f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.0917f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1395f, 0.0000f, 0.8450f, 0.6014f, 1.2657f, 0.2577f, 1.0255f, 0.0000f, 1.0423f, 0.0000f, 1.7019f, 0.3428f, 0.0000f, 0.4204f, 0.2321f, 0.2138f, 0.0000f, 0.0000f, 2.6167f, 0.0000f, 0.0000f, 0.9562f, 0.0000f, 0.0000f, 0.3626f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2889f, 0.0000f, 0.3524f, 1.3459f, 1.4066f, 0.7932f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.1036f, 1.8165f, 2.2420f, 0.0311f, 0.0000f, 1.4934f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.2047f, 1.0220f, 0.0000f, 0.0000f, 0.0000f, 0.8594f, 0.5690f, 0.1281f, 0.1861f, 1.6732f, 0.3260f, 0.0000f, 0.0000f, 0.0000f, 1.2133f, 0.0000f, 0.0000f, 0.8206f, 0.0000f, 1.1344f, 0.0000f, 0.0000f, 0.3955f, 0.0000f, 0.0000f, 0.1073f, 0.0635f, 1.1323f, 0.0000f, 0.0000f, 1.0590f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.9309f, 4.5159f, 0.0000f, 0.0000f, 0.0000f, 0.7599f, 0.2474f, 2.2719f, 0.6638f, 0.0000f, 2.7366f, 2.2148f, 0.0000f, 0.0000f, 0.0000f, 0.2814f, 0.0000f, 0.0000f, 0.6799f, 0.0000f, 0.2209f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.1430f, 0.0000f, 0.0364f, 1.4111f, 1.3238f, 0.6777f, 2.5226f, 0.0000f, 0.0000f, 0.5769f, 0.7938f, 0.0000f, 0.9097f, 0.0000f, 0.0000f, 0.0000f, 1.0733f, 0.0000f, 0.3071f, 3.3680f, 1.2845f, 1.3104f, 0.8612f, 0.0000f, 0.0000f, 0.0000f, 2.0395f, 1.5702f, 1.2423f, 0.5085f, 2.4769f, 3.0385f, 2.6653f, 2.4255f, 3.4814f, 3.1955f, 3.1394f, 3.2775f, 2.4097f, 4.3077f, 6.2658f, 9.6205f, 5.6577f, 6.7532f, 7.6542f, 7.3224f, 10.4244f, 11.5053f, 12.6813f, 12.9981f, 13.1947f, 14.2313f, 14.0314f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f -}}; - -constexpr DroopCorrectionTable kDroopTable768Ksps{{ - 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 14.2226f, 13.3770f, 11.7052f, 13.6600f, 11.8402f, 11.8643f, 10.5462f, 11.4915f, 7.5891f, 7.2912f, 9.2411f, 6.0843f, 6.5406f, 5.5220f, 4.7743f, 4.1638f, 3.7851f, 4.1767f, 2.7629f, 2.4808f, 1.9196f, 1.3300f, 2.5312f, 0.6661f, 1.0488f, 2.1608f, 1.0207f, 1.2489f, 0.3898f, 0.7685f, 0.0000f, 0.0000f, 2.1901f, 0.2552f, 0.0000f, 1.1480f, 3.2553f, 0.8420f, 1.6616f, 3.9415f, 1.5959f, 0.4970f, 2.3577f, 3.0037f, 2.9380f, 1.6330f, 1.6145f, 0.8487f, 1.0201f, 0.5769f, 0.0000f, 0.0000f, 0.3667f, 0.0000f, 0.0000f, 0.0000f, 0.5371f, 0.6069f, 0.0000f, 0.0000f, 0.4440f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.6007f, 0.4038f, 0.2758f, 1.0052f, 0.2403f, 0.0000f, 1.0896f, 0.6003f, 0.6281f, 0.2834f, 1.8815f, 0.0000f, 0.4330f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.6200f, 0.9707f, 0.6410f, 0.6859f, 0.0000f, 0.0000f, 0.3833f, 2.4313f, 0.0000f, 0.1440f, 1.2101f, 0.8473f, 0.0000f, 0.0000f, 1.6561f, 0.0000f, 0.0000f, 2.1648f, 1.4555f, 1.4159f, 0.0000f, 0.6239f, 0.0000f, 1.0924f, 0.0000f, 0.4720f, 0.8669f, 0.0000f, 0.0000f, 0.0000f, 0.9161f, 1.3264f, 0.0000f, 1.7543f, 0.3781f, 0.0000f, 0.0543f, 1.5436f, 0.4647f, 0.0000f, 0.4842f, 0.2059f, 0.3922f, 1.1725f, 0.5885f, 0.1188f, 0.2463f, 0.0000f, 0.2168f, 0.0000f, 0.9563f, 1.6371f, 0.0689f, 1.5158f, 0.0000f, 0.9228f, 0.0000f, 0.0000f, 1.8315f, 1.1448f, 0.2509f, 0.3641f, 0.0931f, 0.3165f, 0.0000f, 0.5546f, 1.0601f, 0.0000f, 0.1328f, 0.7185f, 1.1039f, 1.7997f, 0.5813f, 0.0000f, 0.1832f, 0.3358f, 2.7763f, 0.9961f, 0.0000f, 0.6302f, 0.0000f, 0.2414f, 0.2495f, 0.5418f, 0.4077f, 0.0000f, 2.0729f, 0.0000f, 0.0000f, 0.0000f, 0.1483f, 0.0000f, 0.0000f, 0.6465f, 4.4988f, 1.2885f, 1.8751f, 0.7333f, 0.8342f, 0.1303f, 0.0000f, 0.3752f, 0.4831f, 0.5401f, 1.0798f, 0.0822f, 0.0000f, 0.8907f, 0.9297f, 4.1933f, 0.7153f, 1.0719f, 1.0485f, 0.0000f, 0.0000f, 0.9412f, 1.5532f, 0.2449f, 1.9498f, 0.4898f, 0.7039f, 0.0221f, 1.2153f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.9881f, 0.0000f, 0.0000f, 2.4956f, 0.0000f, 0.0000f, 0.7576f, 0.0000f, 0.0000f, 0.0000f, 0.4500f, 1.7164f, 1.1043f, 0.1906f, 0.8455f, 2.1165f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3509f, 1.0851f, 0.0000f, 0.0000f, 0.0000f, 1.6892f, 0.8551f, 1.4996f, 0.3292f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.7865f, 1.3098f, 1.2569f, 1.4122f, 0.5472f, 2.4100f, 0.9969f, 0.0000f, 0.0689f, 0.6192f, 0.5394f, 0.8251f, 1.1577f, 1.6002f, 0.3401f, 0.0000f, 0.0000f, 0.0000f, 1.6710f, 1.3159f, 0.9159f, 0.0000f, 0.0000f, 1.2115f, 1.2157f, 0.6005f, 0.0000f, 2.2654f, 0.0000f, 0.0000f, 1.4846f, 0.8541f, 0.0000f, 0.4786f, 0.0000f, 2.4359f, 1.9044f, 1.7835f, 1.9800f, 0.0000f, 0.4780f, 2.1515f, 2.6245f, 0.0000f, 0.6019f, 2.0627f, 2.7160f, 0.9775f, 2.2902f, 1.5977f, 0.0000f, 0.2270f, 0.0000f, 0.0000f, 0.0000f, 0.4596f, 0.0000f, 0.0000f, 0.2580f, 0.0000f, 0.0000f, 0.0000f, 0.1460f, 0.0000f, 0.0000f, 0.0000f, 0.8319f, 1.5628f, 1.0369f, 1.5816f, 2.2269f, 0.8404f, 1.4755f, 1.7649f, 1.0048f, 0.2209f, 1.4315f, 0.4880f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.5646f, 0.1796f, 1.0832f, 1.5403f, 0.0000f, 0.0000f, 0.0000f, 0.2177f, 0.0304f, 0.4825f, 0.2166f, 0.0000f, 0.0000f, 0.0000f, 2.0988f, 0.0309f, 0.8748f, 0.8818f, 0.1716f, 0.0000f, 1.6666f, 1.4642f, 2.6197f, 0.1419f, 0.0000f, 1.2508f, 2.9608f, 1.5326f, 3.0959f, 1.0609f, 0.0000f, 0.3727f, 0.2968f, 0.0209f, 0.0000f, 0.0000f, 0.4881f, 1.0497f, 1.6499f, 0.0000f, 0.7226f, 0.2031f, 0.7123f, 0.8356f, 1.7756f, 0.0000f, 0.0000f, 0.0000f, 0.5459f, 0.6352f, 0.0000f, 0.4273f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4024f, 0.2881f, 0.0000f, 0.0000f, 0.5547f, 0.0000f, 0.0000f, 0.0000f, 0.4387f, 0.0927f, 0.0000f, 0.0000f, 0.6012f, 0.0000f, 1.6384f, 0.0000f, 0.0000f, 0.6795f, 0.2163f, 0.0000f, 0.9918f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.6842f, 0.3409f, 0.0000f, 1.0469f, 1.0702f, 0.0000f, 0.0662f, 4.6598f, 1.8328f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5028f, 0.0000f, 0.0000f, 2.0503f, 0.5504f, 0.0000f, 0.3784f, 0.8111f, 3.2605f, 0.0466f, 0.0000f, 0.2206f, 0.8497f, 1.9801f, 0.6169f, 0.0000f, 1.6502f, 0.0000f, 0.0000f, 0.0000f, 0.4979f, 0.2394f, 0.2643f, 0.0507f, 1.8925f, 0.0000f, 0.0000f, 0.0000f, 1.4396f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.6524f, 5.0606f, 2.8565f, 2.1143f, 2.3286f, 0.0000f, 0.0000f, 0.5383f, 0.3282f, 1.4775f, 1.2346f, 0.0000f, 0.0000f, 0.0017f, 0.8822f, 1.1769f, 1.7515f, 0.5583f, 0.9429f, 0.2286f, 0.0000f, 1.9556f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4570f, 1.5453f, 1.4965f, 0.4635f, 0.0000f, 2.0248f, 1.6587f, 1.3784f, 0.4944f, 0.6171f, 1.1446f, 0.8530f, 0.0000f, 0.4031f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0760f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.3077f, 0.0000f, 1.1752f, 0.2840f, 0.0000f, 0.0000f, 0.1375f, 1.2135f, 1.0441f, 0.2452f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.8473f, 1.4255f, 0.0000f, 1.8123f, 1.4428f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5227f, 0.4438f, 0.0000f, 0.0000f, 0.0000f, 0.2225f, 0.0000f, 0.6527f, 0.0000f, 0.0000f, 0.3513f, 0.1894f, 1.0710f, 0.0000f, 0.0000f, 1.3955f, 0.0000f, 0.0000f, 0.0000f, 3.5557f, 0.6040f, 2.6446f, 0.0000f, 0.1817f, 0.4509f, 0.9652f, 0.4727f, 0.0000f, 0.0000f, 1.9658f, 0.0000f, 0.0853f, 2.2975f, 0.0000f, 0.0000f, 0.0000f, 1.1558f, 2.8384f, 0.0000f, 0.8152f, 1.1785f, 0.0000f, 1.4020f, 1.1476f, 1.1833f, 1.1641f, 0.0000f, 0.0000f, 1.3000f, 2.3189f, 0.0000f, 0.0350f, 0.0000f, 0.0000f, 0.0000f, 1.9632f, 0.7799f, 0.0000f, 0.0000f, 3.1944f, 2.3538f, 2.4087f, 0.2901f, 0.9203f, 1.9198f, 0.0000f, 0.9795f, 0.0000f, 0.0000f, 0.0000f, 2.2084f, 0.0000f, 0.0000f, 0.0000f, 1.3009f, 0.7290f, 0.0000f, 0.1482f, 0.7526f, 0.0000f, 0.0000f, 0.6777f, 1.6668f, 0.5884f, 1.3989f, 0.0000f, 0.0000f, 0.6259f, 0.0000f, 0.0000f, 0.9220f, 1.3170f, 1.8476f, 0.0000f, 0.0000f, 0.0000f, 0.0794f, 0.0000f, 0.0000f, 1.3265f, 1.5943f, 1.0353f, 0.0000f, 0.0000f, 0.0000f, 0.5121f, 1.3388f, 0.3440f, 1.0760f, 1.8241f, 2.9366f, 3.8433f, 0.6106f, 0.2792f, 0.0000f, 0.0000f, 1.0893f, 0.7721f, 0.0000f, 0.0000f, 0.5930f, 1.4141f, 0.0000f, 0.8878f, 0.3258f, 0.0510f, 0.0000f, 0.1953f, 0.1851f, 1.2201f, 3.6681f, 2.5526f, 0.7630f, 0.8968f, 0.0000f, 1.8518f, 1.2921f, 0.0000f, 0.4129f, 1.6449f, 1.8991f, 0.0000f, 0.8903f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3221f, 1.3764f, 0.0000f, 0.8686f, 1.0186f, 0.9223f, 0.4385f, 0.1451f, 0.5131f, 0.0000f, 0.0000f, 0.9553f, 1.7581f, 2.3113f, 1.7032f, 0.0000f, 0.0734f, 0.0000f, 0.1713f, 1.3989f, 0.8761f, 0.6610f, 1.0892f, 2.0748f, 1.0481f, 0.0000f, 0.0253f, 0.2509f, 0.1768f, 0.0000f, 1.9199f, 0.9663f, 0.1044f, 0.0000f, 0.2173f, 1.4136f, 0.0000f, 0.1905f, 0.9839f, 0.8740f, 1.1880f, 0.4869f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.8256f, 1.1758f, 0.0000f, 0.0000f, 0.8021f, 2.2687f, 1.0149f, 0.0000f, 0.0000f, 0.0000f, 0.7307f, 0.4669f, 0.5775f, 1.7810f, 1.6776f, 0.0000f, 0.0000f, 0.1771f, 1.8336f, 2.1590f, 0.3755f, 0.5612f, 1.6832f, 1.2116f, 2.6925f, 2.2014f, 2.2756f, 1.1148f, 0.6435f, 0.0000f, 0.4133f, 1.1209f, 1.5727f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.5544f, 0.9482f, 0.0000f, 0.8366f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.3167f, 0.0000f, 0.0000f, 0.7066f, 1.0247f, 0.0000f, 0.8650f, 0.0000f, 0.0000f, 0.0000f, 0.3675f, 1.3161f, 1.8321f, 0.0000f, 0.4414f, 0.1006f, 1.1277f, 0.0000f, 1.1875f, 0.1918f, 0.5153f, 3.6926f, 0.4772f, 0.8068f, 0.0000f, 0.0000f, 1.9468f, 0.0000f, 0.5568f, 0.0000f, 0.0000f, 0.0000f, 0.9865f, 0.1529f, 0.0000f, 0.0000f, 1.6810f, 0.6753f, 0.8991f, 0.6114f, 0.7109f, 0.0000f, 0.0000f, 0.0000f, 0.6211f, 0.7495f, 2.4745f, 1.0001f, 2.6449f, 0.0000f, 0.0000f, 0.6586f, 0.3336f, 0.5192f, 0.1922f, 0.0000f, 0.0000f, 0.1442f, 0.5714f, 0.0289f, 0.0316f, 0.0000f, 0.5326f, 2.7350f, 0.0000f, 0.2209f, 0.2403f, 2.1128f, 0.0000f, 1.3282f, 0.0000f, 0.6606f, 0.2162f, 0.0767f, 1.1861f, 1.9950f, 0.8227f, 0.5901f, 0.1388f, 0.0000f, 0.5743f, 1.8506f, 1.2988f, 0.9607f, 0.0000f, 0.0000f, 0.1672f, 0.0161f, 0.0000f, 0.2939f, 1.7284f, 2.4635f, 0.6995f, 1.1307f, 1.6862f, 0.0000f, 1.8666f, 1.7748f, 0.0000f, 0.0000f, 0.6986f, 0.0000f, 0.9890f, 2.6918f, 0.9049f, 0.6907f, 2.5132f, 2.9548f, 0.0000f, 0.6673f, 1.5266f, 2.1542f, 0.2276f, 1.4757f, 1.1003f, 0.5658f, 0.3468f, 1.8386f, 0.0585f, 1.8602f, 2.2317f, 1.0695f, 1.8907f, 0.2262f, 0.0000f, 3.2586f, 2.3923f, 3.6856f, 1.5180f, 5.1962f, 4.0943f, 5.0167f, 5.9919f, 4.8024f, 4.7779f, 4.9170f, 4.0170f, 3.5968f, 6.7950f, 5.9457f, 7.5816f, 8.5120f, 7.6803f, 9.5130f, 9.9691f, 10.9645f, 11.4111f, 13.7536f, 14.8609f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f -}}; - -constexpr DroopCorrectionTable kDroopTable1536Ksps{{ - 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 14.1516f, 11.6190f, 11.1733f, 13.6624f, 10.9173f, 7.9585f, 9.8273f, 9.0135f, 9.5240f, 7.7670f, 6.6927f, 6.2068f, 4.6972f, 3.2490f, 4.0082f, 4.0868f, 3.7223f, 4.8194f, 3.8031f, 1.4611f, 3.7665f, 1.4643f, 1.3125f, 0.1017f, 0.6982f, 0.0000f, 0.0000f, 1.3717f, 0.0000f, 0.0000f, 0.0000f, 1.6652f, 0.0000f, 0.5527f, 2.3118f, 0.0000f, 0.2507f, 0.0000f, 0.0000f, 0.4588f, 0.0000f, 0.0000f, 1.7040f, 0.5324f, 0.5057f, 0.0000f, 0.0000f, 0.0000f, 0.1802f, 1.4437f, 2.5575f, 3.5962f, 0.0000f, 0.0000f, 0.4850f, 1.2222f, 0.2198f, 0.9764f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.4880f, 0.2364f, 0.0000f, 0.0000f, 0.9070f, 0.8433f, 0.0973f, 0.0000f, 0.8550f, 0.0000f, 0.5849f, 2.8026f, 1.4312f, 0.0000f, 0.6868f, 0.2669f, 0.1618f, 0.7712f, 0.3716f, 2.9254f, 0.0000f, 0.0000f, 0.5326f, 1.4277f, 0.1412f, 0.0000f, 0.2831f, 0.0000f, 0.2394f, 0.0000f, 0.0000f, 0.0000f, 0.3521f, 1.0957f, 0.0000f, 0.0000f, 1.5152f, 0.0833f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5458f, 2.4581f, 0.6637f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.9143f, 1.6555f, 0.0000f, 2.6165f, 0.9375f, 0.2469f, 0.0000f, 0.0000f, 0.0000f, 0.0766f, 0.0000f, 0.0000f, 0.0000f, 0.7194f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.8688f, 0.0000f, 0.2605f, 0.0000f, 0.0000f, 0.0000f, 0.9577f, 0.8875f, 0.0000f, 0.7403f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4251f, 0.1175f, 0.3895f, 0.0005f, 0.0059f, 2.9444f, 0.3807f, 0.2061f, 0.0000f, 0.7015f, 1.3785f, 0.5235f, 1.5201f, 0.0000f, 0.0000f, 0.2236f, 0.5048f, 0.0000f, 0.0000f, 0.0000f, 0.4777f, 1.6928f, 0.4557f, 0.0000f, 0.0000f, 0.0000f, 0.3698f, 0.1583f, 0.0603f, 0.0000f, 0.0000f, 0.3577f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.9570f, 0.3118f, 0.1163f, 0.9448f, 1.2456f, 0.0000f, 0.0000f, 0.0000f, 0.2135f, 0.5325f, 0.5986f, 0.0000f, 0.0729f, 0.3085f, 0.8030f, 0.0772f, 1.9929f, 0.6359f, 0.5782f, 0.2320f, 0.0518f, 0.0000f, 0.0000f, 0.0000f, 0.9222f, 0.6503f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.7449f, 1.1315f, 1.8105f, 0.0000f, 0.0000f, 0.0847f, 0.4948f, 0.6193f, 0.3957f, 0.0000f, 0.0000f, 1.4284f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.2477f, 0.0000f, 1.8941f, 0.2457f, 0.0000f, 0.0000f, 0.1209f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6069f, 0.0000f, 0.0000f, 0.0000f, 2.0362f, 1.7259f, 1.3395f, 0.7931f, 0.6370f, 0.0000f, 0.0000f, 0.3502f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3572f, 2.8024f, 0.1975f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6499f, 0.2720f, 0.0477f, 0.0000f, 1.0031f, 2.3378f, 0.2395f, 0.0000f, 0.0063f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.3850f, 2.7524f, 0.7278f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 2.3325f, 0.3782f, 0.0000f, 0.0000f, 0.0000f, 0.4054f, 2.9744f, 1.0748f, 0.0000f, 0.0000f, 0.6605f, 0.0872f, 0.8052f, 0.8915f, 3.2083f, 0.0000f, 0.0000f, 0.5493f, 0.4880f, 0.7793f, 0.0000f, 0.0000f, 0.3242f, 0.8225f, 0.6581f, 1.7783f, 0.0000f, 1.2077f, 0.0000f, 0.4174f, 0.2426f, 0.0000f, 0.2810f, 2.6832f, 0.0000f, 0.0000f, 1.1180f, 0.4674f, 0.0000f, 0.0000f, 0.0000f, 0.7106f, 2.1163f, 3.6808f, 0.0000f, 2.1780f, 0.0000f, 0.0000f, 0.0000f, 0.0067f, 0.0000f, 0.0000f, 0.0000f, 0.2696f, 0.0000f, 0.0000f, 0.9132f, 1.2878f, 0.0683f, 0.0000f, 0.0000f, 1.0741f, 0.4399f, 0.3447f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.5416f, 0.0000f, 2.6024f, 0.0000f, 0.0000f, 0.4108f, 0.8779f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4235f, 0.3244f, 0.1743f, 0.0000f, 0.1315f, 0.0000f, 0.1046f, 0.0000f, 1.5672f, 0.0000f, 0.3423f, 0.0000f, 0.6423f, 0.5961f, 0.2506f, 1.3007f, 0.9258f, 1.8263f, 0.0215f, 0.8692f, 0.5215f, 0.2247f, 0.0994f, 0.3074f, 1.5733f, 0.0000f, 0.4835f, 0.0000f, 0.0000f, 0.0000f, 0.1267f, 0.0000f, 0.0000f, 0.5744f, 0.1207f, 0.0000f, 1.1146f, 0.0880f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.8190f, 0.7790f, 0.0000f, 0.9725f, 1.0324f, 0.0000f, 0.2373f, 1.0575f, 0.2793f, 0.0000f, 0.0000f, 0.0000f, 1.5510f, 0.9407f, 1.3799f, 1.4307f, 0.8948f, 0.2569f, 0.0000f, 1.2235f, 0.0000f, 0.2186f, 0.0000f, 0.0000f, 0.0000f, 0.3695f, 0.0000f, 0.3768f, 0.8682f, 0.3989f, 0.6318f, 1.0056f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.1723f, 0.1286f, 0.0000f, 1.3605f, 0.9290f, 0.8800f, 0.6487f, 0.6219f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.1905f, 0.0000f, 4.2496f, 0.0040f, 0.4658f, 0.0000f, 0.4827f, 2.3833f, 1.4550f, 1.5111f, 0.9889f, 0.0000f, 0.0000f, 0.0764f, 0.0000f, 0.3783f, 1.5862f, 0.0000f, 0.0000f, 0.9926f, 0.4615f, 0.2304f, 0.0000f, 0.0000f, 1.1676f, 2.2615f, 0.6066f, 0.0000f, 0.0000f, 0.9428f, 1.3676f, 0.1350f, 0.0000f, 0.1644f, 0.0000f, 0.0000f, 0.0000f, 2.5858f, 0.3157f, 0.3313f, 0.1351f, 0.0000f, 0.7935f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3290f, 0.0000f, 0.0000f, 1.1776f, 0.6773f, 0.0000f, 0.9139f, 0.0000f, 0.0000f, 0.0012f, 0.8672f, 0.0000f, 0.3452f, 0.8791f, 1.1977f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.7049f, 0.6935f, 0.7741f, 0.9708f, 0.0000f, 0.0000f, 0.6976f, 2.0001f, 0.4195f, 0.0000f, 0.0000f, 0.0000f, 0.2721f, 3.0125f, 0.5845f, 0.1312f, 0.0000f, 0.7419f, 0.0000f, 0.0000f, 0.0000f, 0.3402f, 0.1644f, 0.0000f, 0.0060f, 0.0000f, 0.0000f, 0.0101f, 0.0000f, 0.0000f, 0.8681f, 0.0000f, 0.0000f, 0.7121f, 1.1557f, 1.5279f, 0.1506f, 0.0000f, 0.0000f, 0.0000f, 0.6025f, 0.9866f, 1.1110f, 2.8341f, 1.9000f, 0.0000f, 0.0000f, 0.0000f, 0.9708f, 0.0000f, 1.2593f, 0.0170f, 0.0000f, 2.1670f, 0.0000f, 0.0000f, 0.0592f, 1.1894f, 0.0000f, 0.2641f, 0.3647f, 2.3968f, 0.0000f, 0.4931f, 0.0000f, 0.4114f, 0.0000f, 0.0000f, 0.0000f, 0.3323f, 2.4406f, 0.1865f, 0.7117f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6563f, 0.8639f, 0.4701f, 0.0018f, 0.3599f, 0.0000f, 0.0000f, 0.0000f, 0.8630f, 0.0000f, 1.9045f, 0.4098f, 0.0000f, 0.0000f, 1.2811f, 0.0000f, 0.0000f, 0.5215f, 0.6953f, 2.2500f, 4.0218f, 0.9537f, 0.3215f, 0.0000f, 0.4070f, 0.0000f, 0.0000f, 1.3352f, 1.1283f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.5665f, 0.5020f, 0.2462f, 0.0378f, 0.0000f, 0.5801f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.2666f, 0.1460f, 0.1562f, 0.0000f, 0.0000f, 0.1661f, 0.1729f, 0.5534f, 1.1115f, 0.0000f, 0.8835f, 0.0000f, 0.0000f, 1.7210f, 2.5907f, 0.0000f, 0.0000f, 2.4566f, 1.5713f, 0.0000f, 0.0000f, 0.0000f, 0.0307f, 0.0000f, 0.0000f, 0.0000f, 1.3386f, 0.0000f, 0.2592f, 0.1415f, 0.2651f, 1.3728f, 0.2201f, 0.4892f, 0.0000f, 0.0000f, 0.0000f, 0.2359f, 1.1067f, 2.4328f, 1.9022f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.1060f, 1.4622f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.0546f, 3.3578f, 1.2965f, 0.0000f, 0.0000f, 0.0000f, 1.1122f, 0.0000f, 0.0000f, 1.0303f, 1.3110f, 0.4233f, 1.1135f, 1.9954f, 0.7313f, 1.4424f, 0.3124f, 0.0000f, 0.0000f, 3.0049f, 1.6544f, 1.5100f, 0.8085f, 1.9188f, 0.9641f, 1.5488f, 0.9167f, 0.2565f, 0.6856f, 0.8680f, 0.4282f, 1.4677f, 1.0901f, 0.0000f, 0.6082f, 1.0928f, 1.3580f, 0.7616f, 0.0000f, 0.0000f, 1.2360f, 1.1097f, 0.0000f, 1.1552f, 0.0000f, 1.2676f, 1.9127f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.4424f, 0.0000f, 0.0000f, 0.5429f, 0.0000f, 0.2274f, 0.0923f, 1.2113f, 0.0000f, 0.0712f, 1.2185f, 2.2484f, 1.6956f, 0.1860f, 0.0000f, 0.0000f, 1.5284f, 0.0479f, 0.7754f, 0.0000f, 0.7871f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.3144f, 0.0000f, 1.1523f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.2681f, 0.3449f, 0.0000f, 0.5487f, 1.5424f, 0.0642f, 0.2397f, 0.1094f, 0.0000f, 0.3192f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.6155f, 0.0000f, 0.0000f, 1.0052f, 0.1896f, 1.2393f, 0.7839f, 0.1130f, 0.7272f, 0.0000f, 0.2873f, 0.0000f, 0.3726f, 1.2699f, 1.0068f, 1.0449f, 0.6383f, 0.0000f, 0.0000f, 0.0000f, 0.3999f, 0.0000f, 1.8246f, 0.4849f, 0.0000f, 0.0000f, 0.0296f, 0.1350f, 0.0000f, 0.8169f, 0.6825f, 0.4134f, 0.0000f, 0.0000f, 0.0000f, 0.0054f, 0.0000f, 0.0000f, 0.2523f, 0.8135f, 0.0000f, 0.3731f, 0.0000f, 1.3492f, 1.7074f, 1.1435f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 0.9330f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.1923f, 0.0000f, 0.0000f, 0.0000f, 0.4388f, 1.0258f, 0.3207f, 0.2338f, 0.0000f, 0.3525f, 0.0000f, 0.0000f, 0.9613f, 1.5222f, 0.0000f, 0.0000f, 0.0000f, 0.0000f, 1.9772f, 2.5867f, 0.0000f, 0.5172f, 1.3731f, 1.3184f, 0.0000f, 0.5609f, 0.0721f, 0.0000f, 0.2930f, 0.0965f, 0.0000f, 0.0000f, 0.0375f, 0.0000f, 2.5632f, 0.9822f, 0.0000f, 0.0000f, 0.0000f, 0.6032f, 0.0000f, 0.0000f, 1.5955f, 1.6951f, 1.9135f, 1.8930f, 0.0000f, 0.2060f, 0.7381f, 2.1268f, 2.8398f, 1.1456f, 1.3315f, 1.1527f, 2.3935f, 2.8027f, 4.5633f, 4.9195f, 4.0269f, 4.6933f, 3.7455f, 6.2742f, 8.9172f, 7.9264f, 6.5914f, 9.8427f, 10.0703f, 8.7490f, 8.6039f, 7.6702f, 8.8867f, 14.2719f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f, 15.0000f -}}; - diff --git a/src/core/backends/anan/AnanRxDsp.cpp b/src/core/backends/anan/AnanRxDsp.cpp index d22c68876..bc37385a6 100644 --- a/src/core/backends/anan/AnanRxDsp.cpp +++ b/src/core/backends/anan/AnanRxDsp.cpp @@ -1,7 +1,5 @@ #include "core/backends/anan/AnanRxDsp.h" -#include "core/backends/anan/AnanDroopCorrection.h" - #include #include @@ -95,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; @@ -122,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); @@ -201,6 +207,26 @@ 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 : {48, 96, 192, 384, 768, 1536}) + valid |= (r == rateKsps); + if (!valid) + return; + DroopCorrectionTable t; + std::copy(table.begin(), table.end(), t.begin()); + m_droopTables[rateKsps] = t; +} + +const DroopCorrectionTable& AnanRxDsp::droopTableForRate(int rateKsps) const noexcept +{ + const auto it = m_droopTables.constFind(rateKsps); + return it != m_droopTables.constEnd() ? it.value() : kDroopCorrectionZero; +} + void AnanRxDsp::setShift(double shiftHz) { m_shiftHz = shiftHz; @@ -285,7 +311,7 @@ void AnanRxDsp::processIqBlock(const std::vector>& iq) // AnanDroopCorrection.h. inputSampleRateHz is always an exact // multiple of 1000 for the six valid DDC0 rates. applyDroopCorrectionDb(m_bins, - droopCorrectionTableForRateKsps(m_config.inputSampleRateHz / 1000)); + droopTableForRate(m_config.inputSampleRateHz / 1000)); 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..84ffcc44a 100644 --- a/src/core/backends/anan/AnanRxDsp.h +++ b/src/core/backends/anan/AnanRxDsp.h @@ -9,9 +9,12 @@ #include #include +#include "core/backends/anan/AnanDroopCorrection.h" #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 +92,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 +163,20 @@ 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); + // 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 +300,14 @@ 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; + [[nodiscard]] const DroopCorrectionTable& droopTableForRate(int rateKsps) const noexcept; }; } // namespace AetherSDR::anan diff --git a/tests/anan_droop_correction_test.cpp b/tests/anan_droop_correction_test.cpp index 7928ce7dc..8ff9a1386 100644 --- a/tests/anan_droop_correction_test.cpp +++ b/tests/anan_droop_correction_test.cpp @@ -12,31 +12,6 @@ int fail(const char* message) return 1; } -int testTableSizeForEveryValidRate() -{ - for (const int rateKsps : {48, 96, 192, 384, 768, 1536}) { - const DroopCorrectionTable& table = droopCorrectionTableForRateKsps(rateKsps); - if (table.size() != kDroopCorrectionFftSize) - return fail("a valid rate did not return a full-size table"); - } - return 0; -} - -int testUnrecognizedRateReturnsSafeZeroFallback() -{ - // The safety-fallback contract is testable with certainty regardless of - // whether real bench data has landed yet: an unrecognized rate must - // never guess. - const DroopCorrectionTable& table = droopCorrectionTableForRateKsps(9999); - if (&table != &kDroopCorrectionZero) - return fail("an unrecognized rate must return kDroopCorrectionZero"); - for (const float v : table) { - if (v != 0.0f) - return fail("kDroopCorrectionZero must be all-zero"); - } - return 0; -} - int testApplyDroopCorrectionDbAddsElementwise() { DroopCorrectionTable table{}; @@ -74,20 +49,27 @@ int testApplyDroopCorrectionDbZeroFallbackIsNumericallyInert() return 0; } +int testDroopCorrectionZeroIsAllZero() +{ + for (const float v : kDroopCorrectionZero) { + if (v != 0.0f) + return fail("kDroopCorrectionZero must be all-zero"); + } + return 0; +} + } // namespace int main() { - if (const int result = testTableSizeForEveryValidRate(); result != 0) - return result; - if (const int result = testUnrecognizedRateReturnsSafeZeroFallback(); result != 0) - return result; 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; 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 e20d6f62e..69c1fe79e 100644 --- a/tests/anan_rxdsp_handedness_test.cpp +++ b/tests/anan_rxdsp_handedness_test.cpp @@ -401,15 +401,16 @@ int main(int argc, char** argv) // ---- 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 -- WITHOUT depending on the - // actual bench-measured correction values (AnanDroopCorrectionTables.inc - // may still be the all-zero placeholder). 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 droopCorrectionTableForRateKsps() for the configured rate, - // bin for bin. + // 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 @@ -432,6 +433,14 @@ int main(int argc, char** argv) 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; @@ -460,8 +469,7 @@ int main(int argc, char** argv) check(refSpectrum.process(conjugated, rawBins) == 1, "reference AnanSpectrum produces exactly one frame from the same IQ"); - const DroopCorrectionTable& table = droopCorrectionTableForRateKsps( - cfg.inputSampleRateHz / 1000); + const DroopCorrectionTable& table = syntheticTable; check(emitted.size() == rawBins.size() && emitted.size() == table.size(), "emitted/reference/table sizes all agree"); bool matched = emitted.size() == rawBins.size() && emitted.size() == table.size(); @@ -479,6 +487,95 @@ int main(int argc, char** argv) "process() and the EMA, not before the FFT and not after smoothing"); } + // ---- 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"); + + bool matched = emitted.size() == rawBins.size() && emitted.size() == table96.size(); + for (std::size_t k = 0; matched && k < emitted.size(); ++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 uses 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"); + } + 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 5eaa92b03..4a6ec19d6 100644 --- a/tests/tests.cmake +++ b/tests/tests.cmake @@ -1868,10 +1868,10 @@ 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 lookup/apply -- pure C++, no Qt dependency at all, -# tests the safety-fallback contract and correction math regardless of -# whether real bench-measured tables (tools/anan_droop_calibration.py) have -# landed in AnanDroopCorrectionTables.inc yet. +# 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 @@ -2594,11 +2594,6 @@ if(PYTHON3_EXECUTABLE) add_test(NAME bridge_docs_check COMMAND ${PYTHON3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/gen_bridge_docs.py --check) - # ANAN droop-correction calibration script: pure-Python data processing, - # no bench sweep needed to exercise its logic. - add_test(NAME anan_droop_calibration_checks - COMMAND ${PYTHON3_EXECUTABLE} - ${CMAKE_CURRENT_SOURCE_DIR}/tools/test_anan_droop_calibration.py) endif() # Retired local-listener fixture. Positive behavior is covered through the live diff --git a/tools/anan_droop_calibration.py b/tools/anan_droop_calibration.py deleted file mode 100644 index e126df7cb..000000000 --- a/tools/anan_droop_calibration.py +++ /dev/null @@ -1,286 +0,0 @@ -#!/usr/bin/env python3 -"""Turn a bench calibration sweep into the ANAN-G2 droop-correction tables. - -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 script turns a -set of `radiocert spectrum` captures (one JSON file per capture, gathered via -the automation bridge across all 6 DDC0 rates with the antenna disconnected -or dummy-loaded, so the true receiver noise floor stands in as a flat -reference) into a per-bin dB correction table per rate, and writes the -generated C++ include consumed by src/core/backends/anan/AnanDroopCorrection.cpp. - -Input: a directory of JSON files, each either a bare `measured` object - {"ddc0RateKsps": ..., "fftSize": ..., "binsDbm": [...]} -or a full radiocert stage record {"id": "spectrum", "measured": {...}, ...} -- -both shapes are accepted so a saved raw bridge_command response works as-is. - -Usage: - tools/anan_droop_calibration.py [--cap-db 15] - [--reference-window-fraction 0.15] [--min-captures-per-rate 5] - -No app, no Qt, no build required -- pure static data processing. -""" - -from __future__ import annotations - -import argparse -import datetime -import json -import math -import sys -from pathlib import Path - -HERE = Path(__file__).resolve().parent -REPO = HERE.parent -OUTPUT = REPO / "src" / "core" / "backends" / "anan" / "AnanDroopCorrectionTables.inc" - -# The six ANAN-G2 DDC0 rates (AnanBackend::capabilities()'s sampleRatesHz, in -# ksps rather than Hz) -- fixed by the radio, not derived from the captures. -VALID_RATES_KSPS = (48, 96, 192, 384, 768, 1536) - -FFT_SIZE = 1024 - - -def load_captures(capture_dir: Path) -> list[dict]: - """Read every *.json file in capture_dir, unwrapping a radiocert stage - record down to its `measured` object if present.""" - captures = [] - for path in sorted(capture_dir.glob("*.json")): - with path.open("r", encoding="utf-8") as f: - doc = json.load(f) - measured = doc.get("measured", doc) if isinstance(doc, dict) else doc - if not isinstance(measured, dict): - continue - if "ddc0RateKsps" not in measured or "binsDbm" not in measured: - continue - captures.append(measured) - return captures - - -def group_by_rate(captures: list[dict]) -> dict[int, list[list[float]]]: - grouped: dict[int, list[list[float]]] = {} - for capture in captures: - rate = int(capture["ddc0RateKsps"]) - bins = capture["binsDbm"] - grouped.setdefault(rate, []).append(bins) - return grouped - - -def median(values: list[float]) -> float: - ordered = sorted(values) - n = len(ordered) - mid = n // 2 - if n % 2 == 1: - return ordered[mid] - return (ordered[mid - 1] + ordered[mid]) / 2.0 - - -def median_power_curve(bin_arrays: list[list[float]]) -> list[float]: - """Combine N per-capture dB curves of the same length 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 -- whichever capture's - value is the middle one stays the middle one either way). It is kept - anyway so this function's contract matches "average in the physically - meaningful domain" even if a future caller swaps the reducer for a mean, - where the log-domain-bias problem is real (Jensen's inequality: the mean - of the logs is not the log of the mean) and this conversion would start - to matter.""" - if not bin_arrays: - return [] - length = len(bin_arrays[0]) - curve = [] - for k in range(length): - powers = [10.0 ** (bins[k] / 10.0) for bins in bin_arrays] - curve.append(10.0 * math.log10(median(powers))) - return curve - - -def reference_level(curve: list[float], window_fraction: float) -> float: - """Median of the curve over a central window, not the exact center bin - (avoids any residual DC-region artifact even though AnanSpectrum already - DC-removes before windowing).""" - n = len(curve) - half_width = max(1, int(n * window_fraction / 2.0)) - center = n // 2 - window = curve[max(0, center - half_width):min(n, center + half_width)] - return median(window) - - -def compute_correction(curve: list[float], reference_db: float, - cap_db: float) -> tuple[list[float], float]: - """correction[k] = clamp(reference - measured[k], 0, cap_db). Returns the - correction curve and the fraction of bins where the RAW (unclamped) - correction exceeded cap_db -- i.e. genuinely unrecoverable, past the - point where the CIC null approaches the noise floor and further gain - would amplify noise rather than recover signal.""" - correction = [] - clamped = 0 - for value in curve: - raw = reference_db - value - if raw > cap_db: - clamped += 1 - correction.append(max(0.0, min(cap_db, raw))) - clamped_fraction = clamped / len(curve) if curve else 0.0 - return correction, clamped_fraction - - -def validate_curve(rate_ksps: int, correction: list[float], cap_db: float, - capture_count: int, min_captures: int) -> list[str]: - problems = [] - if capture_count < min_captures: - problems.append( - f"rate {rate_ksps} ksps: only {capture_count} captures " - f"(minimum {min_captures})") - if len(correction) != FFT_SIZE: - problems.append( - f"rate {rate_ksps} ksps: {len(correction)} bins, expected {FFT_SIZE}") - for k, value in enumerate(correction): - if not math.isfinite(value): - problems.append(f"rate {rate_ksps} ksps: bin {k} is not finite") - elif value < 0.0 or value > cap_db: - problems.append( - f"rate {rate_ksps} ksps: bin {k} correction {value:.2f} dB " - f"outside [0, {cap_db}]") - return problems - - -def warn_bin_jumps(rate_ksps: int, correction: list[float], - jump_threshold_db: float) -> list[str]: - """Non-fatal: real CIC ripple can be legitimately non-monotonic, but a - single-bin spike this large next to smooth neighbours is more likely - measurement noise than filter shape.""" - warnings = [] - for k in range(1, len(correction) - 1): - left = abs(correction[k] - correction[k - 1]) - right = abs(correction[k] - correction[k + 1]) - if left > jump_threshold_db and right > jump_threshold_db: - warnings.append( - f"rate {rate_ksps} ksps: bin {k} looks like a single-bin " - f"spike ({correction[k]:.2f} dB, neighbours " - f"{correction[k - 1]:.2f}/{correction[k + 1]:.2f})") - return warnings - - -def format_table(rate_ksps: int, correction: list[float]) -> str: - name = f"kDroopTable{rate_ksps}Ksps" - values = ", ".join(f"{v:.4f}f" for v in correction) - return ( - f"constexpr DroopCorrectionTable {name}{{{{\n" - f" {values}\n" - f"}}}};\n" - ) - - -def generate_inc_file(tables_by_rate: dict[int, list[float]], - capture_counts: dict[int, int], cap_db: float, - reference_window_fraction: float) -> str: - generated_at = datetime.datetime.now(datetime.timezone.utc).strftime( - "%Y-%m-%d %H:%M UTC") - counts_line = ", ".join( - f"{rate}ksps={capture_counts.get(rate, 0)}" for rate in VALID_RATES_KSPS) - header = ( - "// AnanDroopCorrectionTables.inc\n" - "//\n" - "// DO NOT HAND-EDIT. Generated by tools/anan_droop_calibration.py from a\n" - "// bench calibration sweep of the ANAN-G2's real DDC0 CIC/decimation droop\n" - "// -- see AnanDroopCorrection.h for what this data is and why it exists.\n" - "//\n" - f"// Generated: {generated_at}\n" - f"// Capture counts per rate: {counts_line}\n" - f"// cap_db={cap_db} reference_window_fraction={reference_window_fraction}\n" - "//\n" - "// Regenerate by running the calibration sweep against a real ANAN-G2 and\n" - "// re-running this script; it overwrites this file.\n\n" - ) - body = "\n".join( - format_table(rate, tables_by_rate[rate]) - for rate in VALID_RATES_KSPS if rate in tables_by_rate) - missing = [rate for rate in VALID_RATES_KSPS if rate not in tables_by_rate] - for rate in missing: - body += f"\nconstexpr DroopCorrectionTable kDroopTable{rate}Ksps{{}};" \ - f" // no captures for this rate -- no correction applied\n" - return header + body + "\n" - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("capture_dir", type=Path, - help="directory of radiocert-spectrum JSON captures") - parser.add_argument("--cap-db", type=float, default=15.0, - help="maximum correction applied to any one bin (default 15)") - parser.add_argument("--reference-window-fraction", type=float, default=0.15, - help="central window (as a fraction of bin count) used " - "as the flat reference level (default 0.15)") - parser.add_argument("--min-captures-per-rate", type=int, default=5, - help="minimum captures required per rate (default 5)") - parser.add_argument("--jump-threshold-db", type=float, default=6.0, - help="single-bin-spike warning threshold (default 6)") - parser.add_argument("--dry-run", action="store_true", - help="print the summary but do not write the .inc file") - args = parser.parse_args() - - captures = load_captures(args.capture_dir) - if not captures: - print(f"no captures found in {args.capture_dir}", file=sys.stderr) - return 1 - - grouped = group_by_rate(captures) - capture_counts = {rate: len(bins) for rate, bins in grouped.items()} - - tables_by_rate: dict[int, list[float]] = {} - clamped_fractions: dict[int, float] = {} - problems: list[str] = [] - warnings: list[str] = [] - - for rate in sorted(grouped): - curve = median_power_curve(grouped[rate]) - reference_db = reference_level(curve, args.reference_window_fraction) - correction, clamped_fraction = compute_correction( - curve, reference_db, args.cap_db) - problems.extend(validate_curve( - rate, correction, args.cap_db, capture_counts[rate], - args.min_captures_per_rate)) - warnings.extend(warn_bin_jumps(rate, correction, args.jump_threshold_db)) - tables_by_rate[rate] = correction - clamped_fractions[rate] = clamped_fraction - print(f"rate {rate:5d} ksps: captures={capture_counts[rate]:3d} " - f"reference={reference_db:7.2f} dB " - f"correction min/max={min(correction):.2f}/{max(correction):.2f} dB " - f"clamped_fraction={clamped_fraction:.4f}") - - for w in warnings: - print(f"WARNING: {w}", file=sys.stderr) - - if problems: - for p in problems: - print(f"FAIL: {p}", file=sys.stderr) - return 1 - - if clamped_fractions: - worst = max(clamped_fractions.values()) - suggested_taper = math.ceil((worst + 0.005) * 200.0) / 200.0 # round up to nearest 0.005 - print(f"\nSuggested SpectrumWidget.cpp kEdgeTaperFraction = {suggested_taper:.3f} " - f"(worst-case clamped fraction {worst:.4f} + margin)") - - if args.dry_run: - print("\n--dry-run: not writing", OUTPUT) - return 0 - - OUTPUT.write_text( - generate_inc_file(tables_by_rate, capture_counts, args.cap_db, - args.reference_window_fraction), - encoding="utf-8") - print(f"\nwrote {OUTPUT}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/test_anan_droop_calibration.py b/tools/test_anan_droop_calibration.py deleted file mode 100644 index 9f2dc89cf..000000000 --- a/tools/test_anan_droop_calibration.py +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env python3 -"""Unit tests for tools/anan_droop_calibration.py, on synthetic data only -- -no bench sweep needed.""" - -import json -import math -import os -import sys -import tempfile -from pathlib import Path - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import anan_droop_calibration as cal # noqa: E402 - - -def check(condition, message): - if not condition: - raise AssertionError(message) - - -def test_median_odd(): - check(cal.median([3.0, 1.0, 2.0]) == 2.0, "median of 3 values") - - -def test_median_even(): - check(cal.median([1.0, 3.0]) == 2.0, "median of 2 values") - - -def test_median_power_curve_rejects_a_stray_outlier_capture(): - # 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. - quiet = [-10.0, -10.0] - outlier = [-10.0, 40.0] - curve = cal.median_power_curve([quiet, quiet, quiet, quiet, outlier]) - check(abs(curve[0] - (-10.0)) < 1e-6, f"bin 0 should stay -10 dB, got {curve[0]}") - check(abs(curve[1] - (-10.0)) < 1e-6, - f"bin 1 median should reject the outlier, got {curve[1]}") - - -def test_reference_level_uses_central_window(): - # A curve that's flat at 0 dB in the center and droops hard at the edges; - # the reference must come from the flat center, not be dragged down by - # the edges. - n = 100 - curve = [0.0] * n - for k in range(10): - curve[k] = -50.0 - curve[n - 1 - k] = -50.0 - ref = cal.reference_level(curve, window_fraction=0.2) - check(abs(ref - 0.0) < 1e-6, f"reference should read the flat center, got {ref}") - - -def test_compute_correction_clamps(): - curve = [0.0, -5.0, -20.0, -50.0] - correction, clamped_fraction = cal.compute_correction( - curve, reference_db=0.0, cap_db=15.0) - check(correction == [0.0, 5.0, 15.0, 15.0], - f"unexpected correction curve: {correction}") - # Two of four bins (the -20 and -50 dB ones) needed > 15 dB raw correction. - check(abs(clamped_fraction - 0.5) < 1e-9, - f"expected clamped_fraction 0.5, got {clamped_fraction}") - - -def test_compute_correction_never_negative(): - # A bin ABOVE the reference (e.g. a stray signal the median didn't fully - # reject) must not produce a negative "correction" that would attenuate - # a healthy bin. - curve = [5.0] - correction, _ = cal.compute_correction(curve, reference_db=0.0, cap_db=15.0) - check(correction == [0.0], f"correction must clamp to >= 0, got {correction}") - - -def test_validate_curve_flags_short_capture_count(): - correction = [1.0] * cal.FFT_SIZE - problems = cal.validate_curve(48, correction, cap_db=15.0, - capture_count=2, min_captures=5) - check(any("only 2 captures" in p for p in problems), - f"expected a low-capture-count problem, got {problems}") - - -def test_validate_curve_flags_wrong_length(): - problems = cal.validate_curve(48, [1.0, 2.0], cap_db=15.0, - capture_count=10, min_captures=5) - check(any("2 bins" in p for p in problems), - f"expected a wrong-length problem, got {problems}") - - -def test_validate_curve_flags_out_of_range(): - correction = [1.0] * cal.FFT_SIZE - correction[5] = 999.0 - problems = cal.validate_curve(48, correction, cap_db=15.0, - capture_count=10, min_captures=5) - check(any("bin 5" in p for p in problems), - f"expected an out-of-range problem for bin 5, got {problems}") - - -def test_warn_bin_jumps_flags_single_bin_spike(): - correction = [1.0, 1.0, 1.0, 20.0, 1.0, 1.0, 1.0] - warnings = cal.warn_bin_jumps(48, correction, jump_threshold_db=6.0) - check(any("bin 3" in w for w in warnings), - f"expected a spike warning for bin 3, got {warnings}") - - -def test_warn_bin_jumps_ignores_smooth_ramp(): - correction = [float(i) for i in range(10)] - warnings = cal.warn_bin_jumps(48, correction, jump_threshold_db=6.0) - check(warnings == [], f"a smooth ramp should not warn, got {warnings}") - - -def test_load_captures_accepts_bare_and_wrapped_shapes(): - with tempfile.TemporaryDirectory() as tmp: - tmp_path = Path(tmp) - bare = {"ddc0RateKsps": 48, "fftSize": 4, "binsDbm": [1.0, 2.0, 3.0, 4.0]} - wrapped = {"id": "spectrum", "measured": { - "ddc0RateKsps": 96, "fftSize": 4, "binsDbm": [5.0, 6.0, 7.0, 8.0]}} - (tmp_path / "a.json").write_text(json.dumps(bare), encoding="utf-8") - (tmp_path / "b.json").write_text(json.dumps(wrapped), encoding="utf-8") - captures = cal.load_captures(tmp_path) - rates = sorted(c["ddc0RateKsps"] for c in captures) - check(rates == [48, 96], f"expected both shapes to load, got {rates}") - - -def test_load_captures_skips_incomplete_records(): - with tempfile.TemporaryDirectory() as tmp: - tmp_path = Path(tmp) - incomplete = {"id": "spectrum", "measured": {"captured": False}} - (tmp_path / "a.json").write_text(json.dumps(incomplete), encoding="utf-8") - captures = cal.load_captures(tmp_path) - check(captures == [], f"an incomplete capture must be skipped, got {captures}") - - -def test_group_by_rate(): - captures = [ - {"ddc0RateKsps": 48, "binsDbm": [1.0]}, - {"ddc0RateKsps": 48, "binsDbm": [2.0]}, - {"ddc0RateKsps": 96, "binsDbm": [3.0]}, - ] - grouped = cal.group_by_rate(captures) - check(len(grouped[48]) == 2 and len(grouped[96]) == 1, - f"unexpected grouping: {grouped}") - - -def test_format_table_parseable_as_cpp_array(): - correction = [1.5] * cal.FFT_SIZE - text = cal.format_table(48, correction) - check("kDroopTable48Ksps" in text, f"table name missing: {text[:80]}") - check(text.count("1.5000f") == cal.FFT_SIZE, - "expected every value literally present") - check(text.strip().startswith("constexpr DroopCorrectionTable"), - f"unexpected table declaration: {text[:60]}") - - -def test_generate_inc_file_includes_all_six_rates_even_if_uncaptured(): - tables = {48: [0.0] * cal.FFT_SIZE} - text = cal.generate_inc_file(tables, {48: 10}, cap_db=15.0, - reference_window_fraction=0.15) - for rate in cal.VALID_RATES_KSPS: - check(f"kDroopTable{rate}Ksps" in text, - f"missing table for rate {rate} in generated .inc") - check("DO NOT HAND-EDIT" in text, "missing provenance header") - - -if __name__ == "__main__": - test_median_odd() - test_median_even() - test_median_power_curve_rejects_a_stray_outlier_capture() - test_reference_level_uses_central_window() - test_compute_correction_clamps() - test_compute_correction_never_negative() - test_validate_curve_flags_short_capture_count() - test_validate_curve_flags_wrong_length() - test_validate_curve_flags_out_of_range() - test_warn_bin_jumps_flags_single_bin_spike() - test_warn_bin_jumps_ignores_smooth_ramp() - test_load_captures_accepts_bare_and_wrapped_shapes() - test_load_captures_skips_incomplete_records() - test_group_by_rate() - test_format_table_parseable_as_cpp_array() - test_generate_inc_file_includes_all_six_rates_even_if_uncaptured() - print("anan droop calibration checks passed") From 163f0603c3468667a68e4578fe7f50999e202534 Mon Sep 17 00:00:00 2001 From: Sotiris Date: Thu, 20 Aug 2026 15:04:01 -0500 Subject: [PATCH 04/14] feat(anan): declare hostDroopCalibration and wire AnanBackend's apply path Adds RadioCapabilities::hostDroopCalibration next to hostFrequencyCalibration, with the same "NOT does this radio have a droop -- what varies is whether the client has measured and can correct it" framing. Set true only in AnanBackend::capabilities(); Flex, HL2, and Sim all declare it explicitly false with a one-line reason, per this struct's own "set every field explicitly" rule. Documented in docs/architecture/radio-capabilities-map.md alongside hostFrequencyCalibration's row. AnanBackend gains a per-radio identity (m_radioSerial, set at connectRadio()) and two things that use it: a connect-time seed that loads any previously-persisted droop tables via AnanDroopCalibrator::loadTables() and pushes them to AnanRxDsp before the first spectrum frame renders, and an "anan"/"droop.apply" invokeExtension() handler that is the single place a measured table gets pushed live AND persisted via RadioSettingsScope -- never duplicated between whatever UI or bridge verb calls it. Co-Authored-By: Claude Sonnet 5 --- docs/architecture/radio-capabilities-map.md | 1 + src/core/backends/RadioCapabilities.h | 11 +++ src/core/backends/anan/AnanBackend.cpp | 86 ++++++++++++++++++--- src/core/backends/anan/AnanBackend.h | 13 ++++ src/core/backends/flex/FlexBackend.cpp | 1 + src/core/backends/hl2/Hl2Backend.cpp | 5 ++ src/core/backends/sim/SimBackend.cpp | 1 + 7 files changed, 108 insertions(+), 10 deletions(-) diff --git a/docs/architecture/radio-capabilities-map.md b/docs/architecture/radio-capabilities-map.md index f8db125ea..8c2856613 100644 --- a/docs/architecture/radio-capabilities-map.md +++ b/docs/architecture/radio-capabilities-map.md @@ -101,6 +101,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) | selects the active working store: native radio slots or the host database. 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 always ❌ because its working model is the host database; model-specific Sync support is declared independently by `canRefreshMemories`. | | `canWriteMemories` | ✅ | ❌ | ❌ | `RadioModel::memoriesWritable`, memory dialog and panadapter memory panel | Separates native ownership from mutation support. Icom's radio-side store stays read-only, while the shared AetherSDR database remains writable for Add, Import, inline edits, Remove, and Tune on every Icom model. | | `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/src/core/backends/RadioCapabilities.h b/src/core/backends/RadioCapabilities.h index 62e0afa7c..1b99d3423 100644 --- a/src/core/backends/RadioCapabilities.h +++ b/src/core/backends/RadioCapabilities.h @@ -324,6 +324,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 a1f8f5b21..3bd83c634 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 @@ -297,8 +305,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 @@ -340,6 +349,21 @@ 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) { + 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(); @@ -879,15 +903,57 @@ void AnanBackend::setKeying(bool key) 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. + if (verb == QLatin1String("droop.apply")) { + const QVariantMap byRate = arg.toMap(); + QJsonObject doc; + 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 + std::vector table(static_cast(list.size())); + QJsonArray jsonArr; + for (int i = 0; i < list.size(); ++i) { + table[static_cast(i)] = list[i].toFloat(); + jsonArr.append(static_cast(table[static_cast(i)])); + } + if (m_dsp) { + QMetaObject::invokeMethod(m_dsp, "setDroopCorrectionTable", + Qt::QueuedConnection, Q_ARG(int, rateKsps), + Q_ARG(std::vector, table)); + } + doc.insert(it.key(), jsonArr); + } + // 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 -- same guard Hl2Backend::applyFreqCalPpb() uses. + if (m_radioSerial.isEmpty()) { + qWarning("AnanBackend: not persisting droop calibration -- " + "no radio identity yet; applying for this session only"); + } else if (!doc.isEmpty()) { + RadioSettingsScope(QStringLiteral("anan"), m_radioSerial) + .setFeature(QLatin1String(AnanDroopCalibrator::kFeature), + AnanDroopCalibrator::kSchemaVersion, doc); + AppSettings::instance().save(); + } + if (requestId != 0) + emit extensionResult(requestId, 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..ef34d133e 100644 --- a/src/core/backends/anan/AnanBackend.h +++ b/src/core/backends/anan/AnanBackend.h @@ -259,6 +259,19 @@ 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; }; } // namespace AetherSDR::anan diff --git a/src/core/backends/flex/FlexBackend.cpp b/src/core/backends/flex/FlexBackend.cpp index 52df6a445..fa10159c6 100644 --- a/src/core/backends/flex/FlexBackend.cpp +++ b/src/core/backends/flex/FlexBackend.cpp @@ -193,6 +193,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 28ebf39e9..222573f8e 100644 --- a/src/core/backends/hl2/Hl2Backend.cpp +++ b/src/core/backends/hl2/Hl2Backend.cpp @@ -1489,6 +1489,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 2022059a4..ddcf8bc0b 100644 --- a/src/core/backends/sim/SimBackend.cpp +++ b/src/core/backends/sim/SimBackend.cpp @@ -306,6 +306,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; From 193a002268165777f6f6f0101a750de8ab3aed0f Mon Sep 17 00:00:00 2001 From: Sotiris Date: Thu, 20 Aug 2026 15:04:38 -0500 Subject: [PATCH 05/14] feat(anan): RadioModel owns the live AnanDroopCalibrator instance Adds AnanDroopCalibrator m_droopCalibrator and its droopCalibrator() accessor to RadioModel, plus a connectionStateChanged(false) -> stop() wire so a radio disconnecting mid-sweep can't leave it polling a panadapter/rate that no longer exists. Flagging this diff for review on its own, deliberately: it grows the shared RadioModel class with an object that, today, only ANAN uses -- the same shape as the existing FlexWaveformModel member, but not the lightest possible option. The alternative is owning the calibrator inside AnanBackend and reaching it through the extension seam the way Hl2FreqCal does (pure static functions, zero RadioModel footprint). That path was not taken here because invokeBackendExtension() is a synchronous, fire-and-forget call correlated only by requestId -- it cannot carry this sweep's live progress/rateSampled signals from a background bridge verb call to a UI tab watching the same sweep, which only a persistent, directly-reachable object can do. Mechanically the calibrator is family-agnostic (it drives only RadioModel::setPanBandwidth() and panFeedSpectrumReady, the same generic surface RadioCertification::stageSpectrum() already uses), and its one live consumer (RadioSetupDialog's Droop Correction tab) is gated on the hostDroopCalibration capability, so a non-ANAN backend never touches it -- but the member itself is unconditional. Co-Authored-By: Claude Sonnet 5 --- src/models/RadioModel.cpp | 7 +++++++ src/models/RadioModel.h | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/src/models/RadioModel.cpp b/src/models/RadioModel.cpp index ba921382c..dac0e1fc5 100644 --- a/src/models/RadioModel.cpp +++ b/src/models/RadioModel.cpp @@ -1883,7 +1883,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 ba612ab98..ca0d6a3f4 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; } @@ -1683,6 +1689,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; From b8fffc32141b1cee20e49270ce4a94410b18f986 Mon Sep 17 00:00:00 2001 From: Sotiris Date: Thu, 20 Aug 2026 15:05:18 -0500 Subject: [PATCH 06/14] feat(anan): add the droopcal automation bridge verb doDroopCal(action, value), registered as droopcal , mirrors freqcal's shape exactly: refuses if the connected radio's capabilities don't declare hostDroopCalibration, refuses start with no radio identity yet, and reads the live RadioModel::droopCalibrator() directly for status rather than a round trip through the backend extension seam -- unlike Hl2FreqCal's state, this object is directly reachable. start/stop/apply are fire-and-forget, matching pan bandwidth's own convention (returns once issued, not once it lands); a caller polls droopcal status the same way this feature's own bench verification polled pan.bandwidthMhz. docs/automation-bridge.md regenerated via tools/gen_bridge_docs.py (66 verbs, up from 65). Co-Authored-By: Claude Sonnet 5 --- docs/automation-bridge.md | 1 + src/core/AutomationServer.cpp | 61 +++++++++++++++++++++++++++++++++++ src/core/AutomationServer.h | 1 + 3 files changed, 63 insertions(+) diff --git a/docs/automation-bridge.md b/docs/automation-bridge.md index d30d7ee82..8f30bd497 100644 --- a/docs/automation-bridge.md +++ b/docs/automation-bridge.md @@ -3844,6 +3844,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) | diff --git a/src/core/AutomationServer.cpp b/src/core/AutomationServer.cpp index 57df283d5..2642f3706 100644 --- a/src/core/AutomationServer.cpp +++ b/src/core/AutomationServer.cpp @@ -3227,6 +3227,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, @@ -7876,6 +7883,60 @@ 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(); + return report(); + } + if (verb == QLatin1String("stop")) { + cal.stop(); + return report(); + } + if (verb == QLatin1String("apply")) { + cal.applyResult(); + 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 a90ff0644..a059f63cb 100644 --- a/src/core/AutomationServer.h +++ b/src/core/AutomationServer.h @@ -654,6 +654,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-> From 336d1fd66c40cc68c683523d23d7a4d27fb0f465 Mon Sep 17 00:00:00 2001 From: Sotiris Date: Thu, 20 Aug 2026 16:00:21 -0500 Subject: [PATCH 07/14] feat(anan): add the Droop Correction tab to RadioSetupDialog buildDroopCalibrationTab() mirrors buildCalibrationTab()'s structure exactly: same addPage()/page-index/reseed-lambda registration shape, same hostDroopCalibration-gated visibility set at registration and re-set on RadioModel::connectionStateChanged, same showEvent() reseed, same "Connect the radio first" no-identity guard. Intro text carries the antenna-disconnect instruction the sweep needs (it measures the receiver's own noise floor as a flat reference). A single Start/Stop toggle, a progress bar plus status text fed by the calibrator's started/progress/rateSampled/finished signals, and a per-rate text summary (min/max correction, sample count) rather than a live curve plot -- the simpler of the two UI options, matching AGC-T's own simpler cousin more than its full AgcCurveWidget. Bench-verified the UI mechanics directly: opened the tab, ran Start Sweep (progress bar and per-rate status text updated live through "Sweeping -- rate N of 6..."), stopped it mid-sweep with a partial 3-rate result, and Discard cleared it correctly -- confirmed via a follow-up spectrum capture that discarding left the already-good, bridge-applied table untouched. Did NOT run a full UI-driven sweep to completion and Apply for a bit-for-bit comparison against a bridge-driven run: the antenna was live at the time, and completing a UI sweep under those conditions would have overwritten the verified calibration with live-signal-biased data. That comparison is still open. Co-Authored-By: Claude Sonnet 5 --- src/gui/RadioSetupDialog.cpp | 227 +++++++++++++++++++++++++++++++++++ src/gui/RadioSetupDialog.h | 8 ++ 2 files changed, 235 insertions(+) diff --git a/src/gui/RadioSetupDialog.cpp b/src/gui/RadioSetupDialog.cpp index 11f6efff4..e1f0e47d8 100644 --- a/src/gui/RadioSetupDialog.cpp +++ b/src/gui/RadioSetupDialog.cpp @@ -794,6 +794,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"), @@ -949,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 @@ -3431,6 +3446,218 @@ 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); + + auto* progressBar = new QProgressBar; + progressBar->setRange(0, 100); + progressBar->setValue(0); + progressBar->setTextVisible(false); + progressBar->setFixedHeight(startStopBtn->sizeHint().height()); + + 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); + gvb->addWidget(statusLbl); + + auto* summaryLbl = new QLabel; + themed(summaryLbl, QStringLiteral( + "QLabel { color: {{color.text.secondary}}; font-size: 11px; " + "font-family: monospace; }")); + summaryLbl->setWordWrap(true); + gvb->addWidget(summaryLbl); + + auto* applyBtn = new QPushButton("Apply"); + themed(applyBtn, kButton); + applyBtn->setFixedWidth(90); + applyBtn->setEnabled(false); + 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->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); + + connect(&cal, &AnanDroopCalibrator::started, this, [startStopGuard, statusGuard, &cal] { + 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, &cal](bool applied) { + if (startStopGuard) + startStopGuard->setText(QStringLiteral("Start Sweep")); + if (statusGuard) { + 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](const QString& reason) { + if (statusGuard) + statusGuard->setText(QStringLiteral("Error: %1").arg(reason)); + }); + + connect(startStopBtn, &QPushButton::clicked, this, [&cal] { + if (cal.isRunning()) + cal.stop(); + else + cal.start(); + }); + connect(applyBtn, &QPushButton::clicked, this, [&cal] { 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 924436a81..a1e32ce7e 100644 --- a/src/gui/RadioSetupDialog.h +++ b/src/gui/RadioSetupDialog.h @@ -115,6 +115,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(); @@ -252,6 +257,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 From f2c77749bca24b36193d7791261dfc9d1b663dc4 Mon Sep 17 00:00:00 2001 From: Sotiris Date: Thu, 20 Aug 2026 17:04:07 -0500 Subject: [PATCH 08/14] feat(anan): cosmetic fade for the true edge, instead of chasing more gain Adds applyEdgeFade() to AnanDroopCorrection.h and wires it into AnanRxDsp::processIqBlock() right after applyDroopCorrectionDb(), for any rate with a real (non-zero) calibration table. The measured droop at the true edge of the span is deep enough, and noisy enough bin to bin, that no per-bin dB correction produces a clean result -- bench iteration on a real ANAN-G2 raised AnanDroopCalibrator's capDb from 15 to 70 to 90 dB, and the outermost bins were unchanged or worse from one calibration sweep to the next at every step, because the limiting factor there is measurement noise near the ADC's effective floor, not correction headroom. Chasing more gain just amplifies that noise instead of recovering real signal, and on the panadapter it showed up as a dark, unpredictable band right at the edges of the waterfall -- sometimes there, sometimes not, frame to frame. applyEdgeFade() does not try to recover that data. It overwrites the outermost ~3% of bins on each side with a deterministic raised-cosine fade from the corrected value at the tail boundary down to a fixed offset below it, replacing whatever noisy value the real droop plus correction produced -- so the display always shows the same smooth roll-off at the true edge instead of an unpredictable one. This is the same judgment call WDSP's own Display/Analyzer API makes: SetAnalyzer's `clp` parameter exists specifically to clip a decimation filter's roll-off rather than display it ("It is generally not desirable to display the roll-off area... A primary use of this capability is to clip off those bins", WDSP_Guide Rev 2.00 Section 7.2, reference/wdsp/). We fade instead of literally clipping bins because changing bin count/reported bandwidth already broke zoom-out once -- see AnanBackend::emitPanState()'s own comment. Bench-confirmed on a real ANAN-G2: the previously visible dark band at both panadapter/waterfall edges is now a smooth, gentle roll-off with no hard cutoff. anan_droop_correction_test.cpp adds direct coverage of the new function (middle untouched, continuous at the boundary, exact fade at the true edge, monotonic, safe no-op on a too-small array). anan_rxdsp_handedness_test.cpp's Groups 5 and 6 -- which pin the correction's insertion point and the rate-change regression via exact bin-for-bin comparison -- now exclude the tail bins from their main check and add a second check that the tail matches applyEdgeFade() run on raw+table, so the fade's own behavior stays covered rather than just excluded. Co-Authored-By: Claude Sonnet 5 --- src/core/backends/anan/AnanDroopCorrection.h | 64 ++++++++++++++++++ src/core/backends/anan/AnanRxDsp.cpp | 11 ++- tests/anan_droop_correction_test.cpp | 71 ++++++++++++++++++++ tests/anan_rxdsp_handedness_test.cpp | 60 ++++++++++++++--- 4 files changed, 194 insertions(+), 12 deletions(-) diff --git a/src/core/backends/anan/AnanDroopCorrection.h b/src/core/backends/anan/AnanDroopCorrection.h index 7f474aaa7..c132fae69 100644 --- a/src/core/backends/anan/AnanDroopCorrection.h +++ b/src/core/backends/anan/AnanDroopCorrection.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -42,4 +43,67 @@ inline void applyDroopCorrectionDb(std::vector& binsDbfs, 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 bc37385a6..f2d77339d 100644 --- a/src/core/backends/anan/AnanRxDsp.cpp +++ b/src/core/backends/anan/AnanRxDsp.cpp @@ -310,8 +310,15 @@ void AnanRxDsp::processIqBlock(const std::vector>& iq) // reflects the corrected value at every step -- see // AnanDroopCorrection.h. inputSampleRateHz is always an exact // multiple of 1000 for the six valid DDC0 rates. - applyDroopCorrectionDb(m_bins, - droopTableForRate(m_config.inputSampleRateHz / 1000)); + 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/tests/anan_droop_correction_test.cpp b/tests/anan_droop_correction_test.cpp index 8ff9a1386..d5ba87e07 100644 --- a/tests/anan_droop_correction_test.cpp +++ b/tests/anan_droop_correction_test.cpp @@ -58,6 +58,67 @@ int testDroopCorrectionZeroIsAllZero() 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() @@ -70,6 +131,16 @@ int main() 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 69c1fe79e..b527aea10 100644 --- a/tests/anan_rxdsp_handedness_test.cpp +++ b/tests/anan_rxdsp_handedness_test.cpp @@ -472,19 +472,54 @@ int main(int argc, char** argv) 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 = 0; matched && k < emitted.size(); ++k) { - if (std::fabs(emitted[k] - (rawBins[k] + table[k])) > 1.0e-4f) { + 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], rawBins[k] + table[k], rawBins[k], table[k]); + k, emitted[k], rawPlusTable[k], rawBins[k], table[k]); } } check(matched, - "the first emitted frame equals 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"); + "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 ---- @@ -565,15 +600,20 @@ int main(int argc, char** argv) 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 = 0; matched && k < emitted.size(); ++k) { + 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 uses 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"); + "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"); } if (g_failures == 0) From e79a344da6f85df5f32f3015123fc738e6f9d085 Mon Sep 17 00:00:00 2001 From: Sotiris Date: Wed, 2 Sep 2026 09:29:33 -0500 Subject: [PATCH 09/14] fix(anan): drop unused capture surface, gate Droop row. Principle II. Two review findings from #5357, both about surface that should not be there. RadioCertification::Phase::Spectrum and the `pan bandwidth` bridge verb existed to serve tools/anan_droop_calibration.py -- an offline prototype that was never in this tree and that AnanDroopCalibrator superseded before either landed. The in-app engine needs neither: it drives RadioModel::setPanBandwidth() and taps RadioModel::panFeedSpectrumReady directly, both generic RadioModel surfaces. New public automation surface with no in-tree consumer outlives the fix that motivated it, so it goes now rather than becoming a compatibility obligation nobody asked for. RadioCertification.cpp/.h return to exactly their origin/main content; what remains in AutomationServer is only the `droopcal` verb, which does have a consumer. Six comments naming the absent script are reworded -- one of them justified this class's design by pointing at stageSpectrum(), a justification that no longer exists. docs/automation-bridge.md regenerated by tools/gen_bridge_docs.py (72 verbs, unchanged count: droopcal is the one net-new verb). The settings-search filter recomputed setHidden() from the keyword match alone and exempted capability-gated rows by name -- a list the new Droop Correction row was missing from. Typing anything, or typing and then clearing, un-hid it on a Flex, HL2 or Icom, and Start Sweep is family-agnostic: it would drive setPanBandwidth through six zoom levels on a radio whose DDC0 the correction can never reach. Same failure shape the adjacent comment already describes for the HL2-only Calibration page, so the fix is the same one line in the same expression. This also gives m_droopCalibrationPageIndex its first read. Build clean (191/191). anan_droop_correction_test, anan_droop_calibrator_test, anan_rxdsp_handedness_test and anan_backend_test all pass standalone on this branch. Co-Authored-By: Claude Opus 5 --- docs/automation-bridge.md | 4 +- src/core/AutomationServer.cpp | 35 +------ src/core/RadioCertification.cpp | 99 +------------------- src/core/RadioCertification.h | 12 +-- src/core/backends/anan/AnanDroopCalibrator.h | 9 +- src/gui/RadioSetupDialog.cpp | 4 +- src/gui/SpectrumWidget.cpp | 4 +- tests/anan_droop_calibrator_test.cpp | 2 +- 8 files changed, 22 insertions(+), 147 deletions(-) diff --git a/docs/automation-bridge.md b/docs/automation-bridge.md index 8f30bd497..f1c477416 100644 --- a/docs/automation-bridge.md +++ b/docs/automation-bridge.md @@ -3851,7 +3851,7 @@ The complete registry, generated from the `add(...)` table in `AutomationServer. | `sim` | — | sim [arg] — demo fault injection (RFC #4288; only valid when the demo is connected) | | `record` | — | record [args] | | `testtone` | — | testtone [freqHz levelDb] | -| `pan` | — | pan [value] — float/dock drive PanadapterStack's real reparent path (#4864); bandwidth requests a DDC0 rate change in ksps and returns once ISSUED, not once it lands | +| `pan` | — | pan [value] — float/dock drive PanadapterStack's real reparent path (#4864) | | `workspace` | — | workspace — the canvas, its workspaces and its extra windows as data; arg shapes in docs/automation-bridge.md (#4887 ph4/ph6/ph7) | | `layout` | — | layout \|get> — splitter layout exerciser | | `scale` | — | scale [pct] — report/persist the UI scale factor | @@ -3868,7 +3868,7 @@ The complete registry, generated from the `add(...)` table in `AutomationServer. | `liveness` | — | liveness — per-class data ages and the producer->consumer meter join | | `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); spectrum captures one FFT frame for calibration tooling and does not key | +| `radiocert` | — | radiocert [freqMhz] — radio bring-up diagnostic, in dependency order (tx/meters key) | | `transmit` | — | transmit <0..100> — transmit drive (TX-gated) | | `key` | — | key — semantic keying (TX-gated) | | `station` | — | station — set the GUI-client station name | diff --git a/src/core/AutomationServer.cpp b/src/core/AutomationServer.cpp index 2642f3706..f5f61a47a 100644 --- a/src/core/AutomationServer.cpp +++ b/src/core/AutomationServer.cpp @@ -3287,9 +3287,8 @@ const std::vector& AutomationServer::verbRegistry() // rfgain branch already splits the joined value and handles both shapes, // so the handler was right and only the parser choice was wrong. add("pan", {}, - "pan [value] — " - "float/dock drive PanadapterStack's real reparent path (#4864); " - "bandwidth requests a DDC0 rate change in ksps and returns once ISSUED, not once it lands", + "pan [value] — " + "float/dock drive PanadapterStack's real reparent path (#4864)", parseActionRest, [](AutomationServer& s, A& a, QLocalSocket*) -> QJsonObject { if (a.action.isEmpty()) @@ -3513,8 +3512,7 @@ const std::vector& AutomationServer::verbRegistry() }); add("radiocert", {}, - "radiocert [freqMhz] — radio bring-up diagnostic, in dependency order (tx/meters key); " - "spectrum captures one FFT frame for calibration tooling and does not key", + "radiocert [freqMhz] — radio bring-up diagnostic, in dependency order (tx/meters key)", parseActionValue, [](AutomationServer& s, A& a, QLocalSocket*) -> QJsonObject { return s.doRadioCert(a.action, a.value); @@ -8407,7 +8405,6 @@ QJsonObject AutomationServer::doRadioCert(const QString& phaseArg, const QString else if (phase == QLatin1String("rx")) opts.phase = RadioCertification::Phase::Rx; else if (phase == QLatin1String("tx")) opts.phase = RadioCertification::Phase::Tx; else if (phase == QLatin1String("meters")) opts.phase = RadioCertification::Phase::Meters; - else if (phase == QLatin1String("spectrum")) opts.phase = RadioCertification::Phase::Spectrum; else if (phase == QLatin1String("all")) opts.phase = RadioCertification::Phase::All; else // FAIL CLOSED. An unrecognised phase used to leave opts.phase at its @@ -8415,7 +8412,7 @@ QJsonObject AutomationServer::doRadioCert(const QString& phaseArg, const QString // `radiocert`, or a typo like `radiocert reciever`, silently started a // multi-minute transmit sequence nobody asked for. return err(QStringLiteral( - "radiocert: unknown phase '%1' — expected tune|rx|tx|meters|spectrum|all. " + "radiocert: unknown phase '%1' — expected tune|rx|tx|meters|all. " "Refusing to default to 'all', which keys the transmitter") .arg(phaseArg.trimmed())); @@ -10423,30 +10420,6 @@ QJsonObject AutomationServer::doPan(const QString& action, const QString& arg) {QStringLiteral("gain"), gain}, {QStringLiteral("requested"), true}}; } - if (action == QLatin1String("bandwidth") || action == QLatin1String("bw")) { - // `pan bandwidth `. Added for the same "hidden control needs a - // direct verb" reason as rfgain above, plus a bring-up-tooling need: - // tools/anan_droop_calibration.py drives a full sweep across a - // DDC-stepped radio's fixed rate set (ANAN-G2: 48/96/192/384/768/ - // 1536 ksps) and there was no bridge verb to select a rate directly - // — only the zoom buttons, which snap to the nearest of a few fixed - // steps and give no way to target a SPECIFIC rate by value. - // - // FIRE-AND-FORGET: on a DDC-stepped radio a rate change can take up - // to ~a minute cold (AnanRxDsp::buildChannel(), a never-before-used - // rate/block-size combination in this process), so this returns once - // the request is ISSUED, not once it lands. Poll - // `get_state model=pan property=bandwidthMhz` or use `wait_for` to - // know when it actually took. - bool okKsps = false; - const double ksps = arg.trimmed().toDouble(&okKsps); - if (!okKsps || ksps <= 0.0) - return err(QStringLiteral("pan bandwidth requires a positive rate in ksps")); - radio->setPanBandwidth(ksps / 1000.0); - return QJsonObject{{QStringLiteral("ok"), true}, {QStringLiteral("pan"), QStringLiteral("bandwidth")}, - {QStringLiteral("requestedKsps"), ksps}, {QStringLiteral("requested"), true}}; - } - if (action == QLatin1String("float") || action == QLatin1String("dock")) { // `pan float ` / `pan dock …` — the reparent + // GPU re-initialize path (#2495/#4319/#4617) made drivable headlessly diff --git a/src/core/RadioCertification.cpp b/src/core/RadioCertification.cpp index b0a16ddde..44724ca3b 100644 --- a/src/core/RadioCertification.cpp +++ b/src/core/RadioCertification.cpp @@ -956,88 +956,6 @@ void RadioCertification::stageTuning(const Options& o) QStringLiteral("docs/HERMES.md 14.1 — connect-time state")); } -void RadioCertification::stageSpectrum(const Options& o) -{ - // Data capture, not a verdict -- see Phase::Spectrum's own comment. Tunes - // to o.frequencyMhz and grabs exactly one live spectrum frame at - // whatever DDC0 rate/bandwidth the pan is CURRENTLY at. It does NOT - // change rate itself -- a rate change on a DDC-stepped radio can take up - // to ~a minute cold (AnanRxDsp::buildChannel()), and folding a wait for - // that into this stage would make it an outlier against every other - // stage's few-second budget. Drive the rate with the `pan bandwidth - // ` bridge verb and confirm it landed (get_state / wait_for) - // BEFORE calling `radiocert spectrum`. - // - // EVERY CALL MUST PASS AN EXPLICIT freqMhz. Options::frequencyMhz - // defaults to 14.200 and doRadioCert() only overrides it when a value is - // supplied -- an omitted argument silently re-tunes to the default on - // every call, which would corrupt a same-frequency averaging sweep. - if (!m_radio) - return; - auto* slice = m_radio->slice(0); - if (!slice) - return; - - if (o.frequencyMhz > 0.0) { - slice->setFrequency(o.frequencyMhz); - spin(600); - } - - const QString panId = m_radio->panId(); - PanadapterModel* pan = panId.isEmpty() ? nullptr : m_radio->panadapter(panId); - - // Captured at the panFeedSpectrumReady boundary -- the exact signal the - // panadapter paints from -- rather than reaching into AnanRxDsp directly, - // so a calibration capture and what the operator sees agree by - // construction (see tools/anan_droop_calibration.py for how these - // captures get turned into a correction table). - QVector bins; - quint32 streamId = 0; - qint64 tsNs = 0; - bool captured = false; - QMetaObject::Connection conn = QObject::connect( - m_radio, &RadioModel::panFeedSpectrumReady, m_radio, - [&](quint32 id, const QVector& b, qint64 ts) { - if (captured) - return; // keep the FIRST frame after arming, not the latest - streamId = id; - bins = b; - tsNs = ts; - captured = true; - }); - for (int waited = 0; waited < 3000 && !captured; waited += 100) - spin(100); - QObject::disconnect(conn); - - QJsonObject m; - m[QStringLiteral("captured")] = captured; - m[QStringLiteral("streamId")] = static_cast(streamId); - m[QStringLiteral("tsNs")] = static_cast(tsNs); - m[QStringLiteral("centerMhz")] = pan ? pan->centerMhz() : slice->frequency(); - m[QStringLiteral("ddc0RateKsps")] = - pan ? static_cast(pan->bandwidthMhz() * 1000.0 + 0.5) : 0; - m[QStringLiteral("fftSize")] = static_cast(bins.size()); - QJsonArray arr; - for (const float v : bins) - arr.append(static_cast(v)); - m[QStringLiteral("binsDbm")] = arr; - - record(QStringLiteral("spectrum"), - QStringLiteral("One FFT frame at the current DDC0 rate, for offline calibration capture"), - m, - QStringLiteral( - "Captures RadioModel::panFeedSpectrumReady verbatim -- the same " - "bins the panadapter paints. tools/anan_droop_calibration.py " - "issues this repeatedly across frequency and rate and averages " - "offline; this stage takes no average of its own. Change the " - "DDC0 rate with the `pan bandwidth ` verb and wait for it " - "to land BEFORE calling this -- a rate change can take up to " - "~a minute cold, and this stage stays a few-second capture."), - captured ? QString() : QStringLiteral("no spectrum frame arrived within 3 s"), - QStringLiteral("reference/saturn/New_protocol_FPGA_Block_diagrams.pdf " - "'Receiver(3)'; AnanSpectrum::computeFrame()")); -} - void RadioCertification::stageModeMap() { // A mode name the backend does not recognise falls through to a default — @@ -1986,8 +1904,6 @@ QJsonObject RadioCertification::run(const Options& o) const bool doRx = all || o.phase == Phase::Rx; const bool doTx = all || o.phase == Phase::Tx; const bool doMet = all || o.phase == Phase::Meters; - // NOT folded into `all` -- see Phase::Spectrum's own comment. - const bool doSpectrum = o.phase == Phase::Spectrum; // RECEIVE FIRST WHEN BOTH ARE SELECTED, and not for tidiness. The wire's // handedness is ONE fact that both directions consume, and transmit cannot @@ -2057,10 +1973,6 @@ QJsonObject RadioCertification::run(const Options& o) stageMeterInventory(); } - if (doSpectrum) { - stageSpectrum(o); - } - // Unkey, restore and re-tune all happen in `epilogue` above. // What this instrument CANNOT determine, stated as work for a human rather @@ -2129,12 +2041,11 @@ QJsonObject RadioCertification::run(const Options& o) {QStringLiteral("ok"), true}, {QStringLiteral("kind"), QStringLiteral("radio-bringup-diagnostic")}, {QStringLiteral("phase"), - o.phase == Phase::Tune ? QStringLiteral("tune") - : o.phase == Phase::Rx ? QStringLiteral("rx") - : o.phase == Phase::Tx ? QStringLiteral("tx") - : o.phase == Phase::Meters ? QStringLiteral("meters") - : o.phase == Phase::Spectrum ? QStringLiteral("spectrum") - : QStringLiteral("all")}, + o.phase == Phase::Tune ? QStringLiteral("tune") + : o.phase == Phase::Rx ? QStringLiteral("rx") + : o.phase == Phase::Tx ? QStringLiteral("tx") + : o.phase == Phase::Meters ? QStringLiteral("meters") + : QStringLiteral("all")}, {QStringLiteral("note"), QStringLiteral( "Diagnostic only — this deliberately does not pass or fail. Read the " "concerns, then the measurements.")}, diff --git a/src/core/RadioCertification.h b/src/core/RadioCertification.h index 14235f082..fd35e7e78 100644 --- a/src/core/RadioCertification.h +++ b/src/core/RadioCertification.h @@ -66,11 +66,7 @@ class RadioCertification { // meters have been validated. Where a stage does use a meter, it labels the // conclusion meterDependent so a failure can be attributed to the right // subsystem instead of the nearest one. - // Spectrum is deliberately NOT folded into All: it is a data-capture tool - // for offline calibration work (e.g. tools/anan_droop_calibration.py), - // not a bring-up verdict stage, and dumping a multi-KB bin array into - // every routine `radiocert all` report would serve nobody. - enum class Phase { Tune, Rx, Tx, Meters, Spectrum, All }; + enum class Phase { Tune, Rx, Tx, Meters, All }; struct Options { Phase phase = Phase::All; @@ -145,12 +141,6 @@ class RadioCertification { void stageModeMap(); void stageTuning(const Options& o); - // ---- calibration-capture stage (no verdict, no key) ---- - // - // Captures one live spectrum frame for offline tooling. NOT a receive - // handedness/bring-up stage -- see the Phase::Spectrum comment above. - void stageSpectrum(const Options& o); - // ---- meter stages, LAST ---- void stageMeterInventory(); void stageMeterScale(const Options& o); diff --git a/src/core/backends/anan/AnanDroopCalibrator.h b/src/core/backends/anan/AnanDroopCalibrator.h index fd01b8817..801e0c248 100644 --- a/src/core/backends/anan/AnanDroopCalibrator.h +++ b/src/core/backends/anan/AnanDroopCalibrator.h @@ -27,9 +27,8 @@ class RadioModel; // feature document, kFeature/kSchemaVersion, a pure static load function) // mirrors Hl2FreqCal. Family-agnostic mechanically -- it drives // RadioModel::setPanBandwidth() and taps RadioModel::panFeedSpectrumReady -// only, the same generic surface RadioCertification::stageSpectrum() -// already uses -- and is exposed only for ANAN via -// RadioCapabilities::hostDroopCalibration. +// 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 @@ -57,8 +56,8 @@ class AnanDroopCalibrator : public QObject { // ---- pure math (static, unit-testable without a live radio) ---- // Ported directly from this feature's original offline prototype - // (formerly tools/anan_droop_calibration.py, since superseded by this - // in-app engine) -- same algorithm, same reasoning, now C++. + // (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 diff --git a/src/gui/RadioSetupDialog.cpp b/src/gui/RadioSetupDialog.cpp index e1f0e47d8..28f9946c2 100644 --- a/src/gui/RadioSetupDialog.cpp +++ b/src/gui/RadioSetupDialog.cpp @@ -882,12 +882,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); } diff --git a/src/gui/SpectrumWidget.cpp b/src/gui/SpectrumWidget.cpp index b4bc0e428..11ebd735b 100644 --- a/src/gui/SpectrumWidget.cpp +++ b/src/gui/SpectrumWidget.cpp @@ -13942,12 +13942,12 @@ void SpectrumWidget::renderGpuFrame(QRhiCommandBuffer* cb, if (m_edgeTaperEnabled) { // 0.09 (9% margin per side) -- derived, not guessed: once // AnanDroopCorrection applies a real per-bin dB correction - // (measured by tools/anan_droop_calibration.py) to most of + // (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 calibration script reported a + // 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 diff --git a/tests/anan_droop_calibrator_test.cpp b/tests/anan_droop_calibrator_test.cpp index 647fdec3e..fd1062527 100644 --- a/tests/anan_droop_calibrator_test.cpp +++ b/tests/anan_droop_calibrator_test.cpp @@ -1,6 +1,6 @@ // Pure-math unit tests for AnanDroopCalibrator -- no live radio needed. // Ports the test cases from this feature's original offline prototype -// (formerly tools/test_anan_droop_calibration.py, since superseded by this +// (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. From e43af75dd0b6e01c82d38bd13a5cf23bce1f34cc Mon Sep 17 00:00:00 2001 From: Sotiris Date: Wed, 2 Sep 2026 10:24:39 -0500 Subject: [PATCH 10/14] fix(anan): stop reporting a failed rate change as landed. Principle II. `finishRateChange(ok=false)`'s own comment says the old channel and old session keep running at the OLD rate -- but the pending fields had already been overwritten with the requested one, and `emitPanState()` reports from `m_pendingDspConfig.inputSampleRateHz`. So a rate change that failed still told every consumer of pan bandwidth that it had succeeded. Principle II: the radio is authoritative on live state, and this was the seam telling the GUI a rate the radio was not running. Capture the live rate at the top of `beginRateChange()`, before the overwrite, and put it back on the failure path before the emit. The sharp edge is the calibration sweep. `AnanDroopCalibrator` decides "the rate landed" by watching pan bandwidth reach its target, so a failed 768 k channel build would have it measure eight frames of 384 k data into the 768 k correction table and persist that -- one rate's droop curve applied to another rate's spectrum, the exact cross-rate corruption `anan_rxdsp_handedness_test`'s Group 6 exists to prevent, reintroduced through the measurement side rather than the display side. Zoom math and anything else reading pan bandwidth were wrong on that path too; they just failed less visibly. Found in review of #5357 (blocker 6). Build clean. anan_droop_correction_test, anan_droop_calibrator_test, anan_rxdsp_handedness_test and anan_backend_test all pass. Co-Authored-By: Claude Opus 5 --- src/core/backends/anan/AnanBackend.cpp | 18 ++++++++++++++++++ src/core/backends/anan/AnanBackend.h | 6 ++++++ 2 files changed, 24 insertions(+) diff --git a/src/core/backends/anan/AnanBackend.cpp b/src/core/backends/anan/AnanBackend.cpp index 3bd83c634..1f77cf563 100644 --- a/src/core/backends/anan/AnanBackend.cpp +++ b/src/core/backends/anan/AnanBackend.cpp @@ -771,6 +771,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 @@ -822,6 +829,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 diff --git a/src/core/backends/anan/AnanBackend.h b/src/core/backends/anan/AnanBackend.h index ef34d133e..7d6db328e 100644 --- a/src/core/backends/anan/AnanBackend.h +++ b/src/core/backends/anan/AnanBackend.h @@ -272,6 +272,12 @@ class AnanBackend : public IRadioBackend { // `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 From f95338837ee3742ef8ffcb59cab2f9b4b243c066 Mon Sep 17 00:00:00 2001 From: Sotiris Date: Wed, 2 Sep 2026 10:33:37 -0500 Subject: [PATCH 11/14] fix(anan): repair the droop sweep's lifecycle and its write path. Principle XIV. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review blockers from #5357. All of them are about the sweep's lifecycle rather than its math, and they share one shape: a failure that looks like success until the next connect. BLOCKER 1 -- the sweep measured its own output. AnanDroopCalibrator taps panFeedSpectrumReady, which carries bins AFTER applyDroopCorrectionDb() and applyEdgeFade(), and nothing suspended the live tables for the sweep's duration while connectRadio() re-seeds them every session. The second sweep an operator ever ran therefore saw an already-flat curve, computed a near-zero table from it, and Apply persisted that over the good one -- with the synthetic 12 dB edge fade baked in as if it were hardware. Fixed with AnanRxDsp::setDroopCorrectionBypassed(), armed for the sweep and lifted in finishSweep() (so completion, stop() and abort all restore it), routed through a new `droop.bypass` extension verb because m_dsp lives on another thread. A flag, not "push a zero-valued table for each rate": pushing zeros stores a COPY, so droopTableForRate() would stop returning the kDroopCorrectionZero OBJECT and processIqBlock()'s `&droopTable != &kDroopCorrectionZero` identity test would still read true -- the fade would stay on and be measured as hardware droop. It is also non-destructive, so an abort or a crash mid-sweep cannot lose a calibration that was only ever hidden. BLOCKER 2 -- the persist path failed the feature-document rules four ways: it built the document from only the current sweep's rates and setFeature()'d the lot (wiping previously calibrated rates from disk while the DSP kept them live), discarded the write result (AGENTS.md's #4621 rule verbatim), never read the row back via featureExact() nor refused a newer schema_version, and reported success unconditionally. Now AnanDroopCalibrator::saveTables() -- symmetric with loadTables(), so the float<->JSON codec and its validity rules live in one place instead of being hand-copied between reader and writer -- reads the exact row, refuses a newer schema, MERGES, checks the write, and returns the reason when it fails. AnanBackend::persistDroopTables() keeps only the identity guard it alone can make. BLOCKER 3 -- Phase::Sampling had no timeout. Only WaitingForRateLanded was bounded, so a stalled feed (hidden or paused panadapter, a frame size that is not kDroopCorrectionFftSize, a quiet network drop) span forever: isRunning() stayed latched, making start() and applyResult() permanent no-ops, with the correction bypassed and the radio parked at the sweep's rate. Bounded like the rate wait, and every exit already restores the rate. Captures are now gated on a genuinely NEW frame too -- the 8-sample median could otherwise be eight copies of one frame at a low spectrum FPS, defeating the outlier rejection medianPowerCurve() exists to provide -- and anything in hand is dropped when Sampling begins, since frames for the previous rate are still in flight then. BLOCKER 4 -- tables outlived the radio they were measured on. m_dsp is constructed once and survives disconnect/reconnect while the seed only ever inserted. Calibrated G2 #1, disconnect, connect G2 #2, and #2 rendered through #1's per-bin corrections with the Droop tab showing nothing. AnanRxDsp::clearDroopCorrectionTables(), called on disconnect and again before each connect's seed. And the outcome now reaches the operator. applyResult() correlates a real requestId against the backend's extensionResult/extensionError rather than firing and forgetting, so a radio dropped between the sweep and Apply -- which makes invokeBackendExtension() a documented no-op -- no longer prints "Applied — the measured correction is now live and saved". The dialog keeps the specific reason instead of overwriting it with generic finished() text (an aborted sweep had the same bug), `droopcal apply` returns the failure, and `droopcal start` no longer answers ok:true when start() refused for want of a panadapter. Tests: anan_rxdsp_handedness_test grows Group 7, which pins that a bypassed and a cleared table both emit bins equal to the raw FFT across the WHOLE frame, tails included -- the tails are what catch the zero-table trap, since the edge fade would survive it -- plus a negative control and a check that lifting the bypass restores the identical frame. anan_droop_calibrator_test grows four saveTables cases against a real settings store (temporary home): a partial save merges, a re-measured rate overwrites only itself, a newer schema is refused without writing anything, and empty/invalid inputs report why. Build clean. anan_droop_correction_test, anan_droop_calibrator_test, anan_rxdsp_handedness_test, anan_backend_test, radio_capability_gating_test and anan_settings_test all pass. Co-Authored-By: Claude Opus 5 --- src/core/AutomationServer.cpp | 22 +++ src/core/backends/anan/AnanBackend.cpp | 108 ++++++++++--- src/core/backends/anan/AnanBackend.h | 10 ++ .../backends/anan/AnanDroopCalibrator.cpp | 143 +++++++++++++++++- src/core/backends/anan/AnanDroopCalibrator.h | 55 ++++++- src/core/backends/anan/AnanRxDsp.cpp | 16 ++ src/core/backends/anan/AnanRxDsp.h | 33 ++++ src/gui/RadioSetupDialog.cpp | 50 ++++-- tests/anan_droop_calibrator_test.cpp | 133 +++++++++++++++- tests/anan_rxdsp_handedness_test.cpp | 135 +++++++++++++++++ 10 files changed, 657 insertions(+), 48 deletions(-) diff --git a/src/core/AutomationServer.cpp b/src/core/AutomationServer.cpp index f5f61a47a..2f1a7095a 100644 --- a/src/core/AutomationServer.cpp +++ b/src/core/AutomationServer.cpp @@ -7920,6 +7920,15 @@ QJsonObject AutomationServer::doDroopCal(const QString& action, const QString& v "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")) { @@ -7927,7 +7936,20 @@ QJsonObject AutomationServer::doDroopCal(const QString& action, const QString& v 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(); } diff --git a/src/core/backends/anan/AnanBackend.cpp b/src/core/backends/anan/AnanBackend.cpp index 1f77cf563..98d1b758b 100644 --- a/src/core/backends/anan/AnanBackend.cpp +++ b/src/core/backends/anan/AnanBackend.cpp @@ -355,6 +355,14 @@ void AnanBackend::connectRadio(const RadioConnectRequest& request) // 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) { @@ -558,6 +566,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. } @@ -918,6 +939,24 @@ 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) { @@ -928,43 +967,66 @@ void AnanBackend::invokeExtension(const QString& ns, const QString& verb, // 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(); - QJsonObject doc; + + // 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 - std::vector table(static_cast(list.size())); - QJsonArray jsonArr; - for (int i = 0; i < list.size(); ++i) { + DroopCorrectionTable table{}; + for (int i = 0; i < list.size(); ++i) table[static_cast(i)] = list[i].toFloat(); - jsonArr.append(static_cast(table[static_cast(i)])); - } + 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, rateKsps), - Q_ARG(std::vector, table)); + Qt::QueuedConnection, Q_ARG(int, it.key()), + Q_ARG(std::vector, + std::vector(it.value().begin(), it.value().end()))); } - doc.insert(it.key(), jsonArr); } - // 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 -- same guard Hl2Backend::applyFreqCalPpb() uses. - if (m_radioSerial.isEmpty()) { - qWarning("AnanBackend: not persisting droop calibration -- " - "no radio identity yet; applying for this session only"); - } else if (!doc.isEmpty()) { - RadioSettingsScope(QStringLiteral("anan"), m_radioSerial) - .setFeature(QLatin1String(AnanDroopCalibrator::kFeature), - AnanDroopCalibrator::kSchemaVersion, doc); - AppSettings::instance().save(); + + 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}}); } - if (requestId != 0) - emit extensionResult(requestId, true); return; } } diff --git a/src/core/backends/anan/AnanBackend.h b/src/core/backends/anan/AnanBackend.h index 7d6db328e..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() diff --git a/src/core/backends/anan/AnanDroopCalibrator.cpp b/src/core/backends/anan/AnanDroopCalibrator.cpp index 3e9ea882d..01acabf11 100644 --- a/src/core/backends/anan/AnanDroopCalibrator.cpp +++ b/src/core/backends/anan/AnanDroopCalibrator.cpp @@ -1,8 +1,11 @@ #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 @@ -106,6 +109,44 @@ QMap AnanDroopCalibrator::loadTables( 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() @@ -129,6 +170,10 @@ void AnanDroopCalibrator::start() 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); @@ -153,6 +198,19 @@ 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; @@ -161,10 +219,46 @@ void AnanDroopCalibrator::applyResult() list.append(static_cast(v)); byRate.insert(QString::number(it.key()), list); } - // Fire-and-forget (requestId 0): completes locally, same as - // Hl2Backend's freqcal.set -- no device round trip to await. + + // 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"), - 0, QVariant(byRate)); + 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); } @@ -178,6 +272,18 @@ void AnanDroopCalibrator::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) @@ -196,6 +302,12 @@ 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; @@ -241,17 +353,38 @@ void AnanDroopCalibrator::advance() case Phase::Settling: if (now - m_phaseStartedAtMs >= kPostLandSettleMs) { m_phase = Phase::Sampling; - m_lastSampleAtMs = 0; // capture immediately on the first Sampling tick + 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) + 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 = diff --git a/src/core/backends/anan/AnanDroopCalibrator.h b/src/core/backends/anan/AnanDroopCalibrator.h index 801e0c248..81ac6bccd 100644 --- a/src/core/backends/anan/AnanDroopCalibrator.h +++ b/src/core/backends/anan/AnanDroopCalibrator.h @@ -107,9 +107,29 @@ class AnanDroopCalibrator : public QObject { [[nodiscard]] static QMap loadTables( const RadioSettingsScope& scope); - // No saveTables() here -- the one writer is AnanBackend::invokeExtension()'s - // "anan"/"droop.apply" handler (applyResult() below calls into it), - // exactly once, never duplicated between a UI tab and the bridge verb. + // 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 @@ -119,7 +139,8 @@ public slots: // 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), - // best-effort restores the pre-sweep rate, and returns to Idle. + // 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. @@ -129,6 +150,13 @@ public slots: // 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. @@ -151,12 +179,30 @@ private slots: 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); + static constexpr std::array kRatesKsps{48, 96, 192, 384, 768, 1536}; 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; @@ -167,6 +213,7 @@ private slots: 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; diff --git a/src/core/backends/anan/AnanRxDsp.cpp b/src/core/backends/anan/AnanRxDsp.cpp index f2d77339d..19278da53 100644 --- a/src/core/backends/anan/AnanRxDsp.cpp +++ b/src/core/backends/anan/AnanRxDsp.cpp @@ -221,8 +221,24 @@ void AnanRxDsp::setDroopCorrectionTable(int rateKsps, const std::vector& 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; } diff --git a/src/core/backends/anan/AnanRxDsp.h b/src/core/backends/anan/AnanRxDsp.h index 84ffcc44a..a53747c84 100644 --- a/src/core/backends/anan/AnanRxDsp.h +++ b/src/core/backends/anan/AnanRxDsp.h @@ -177,6 +177,35 @@ class AnanRxDsp : public QObject { // 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. @@ -307,6 +336,10 @@ public slots: // 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; }; diff --git a/src/gui/RadioSetupDialog.cpp b/src/gui/RadioSetupDialog.cpp index 28f9946c2..c976b1896 100644 --- a/src/gui/RadioSetupDialog.cpp +++ b/src/gui/RadioSetupDialog.cpp @@ -3573,13 +3573,23 @@ QWidget* RadioSetupDialog::buildDroopCalibrationTab() QPointer applyGuard(applyBtn); QPointer cancelGuard(cancelBtn); - connect(&cal, &AnanDroopCalibrator::started, this, [startStopGuard, statusGuard, &cal] { - if (startStopGuard) - startStopGuard->setText(QStringLiteral("Stop")); - if (statusGuard) - statusGuard->setText(QStringLiteral("Sweeping — rate 1 of %1…") - .arg(cal.totalRates())); - }); + // 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) @@ -3590,10 +3600,14 @@ QWidget* RadioSetupDialog::buildDroopCalibrationTab() } }); connect(&cal, &AnanDroopCalibrator::finished, this, - [startStopGuard, statusGuard, applyGuard, cancelGuard, refreshSummary, &cal](bool applied) { + [startStopGuard, statusGuard, applyGuard, cancelGuard, refreshSummary, errorLatch, + &cal](bool applied) { if (startStopGuard) startStopGuard->setText(QStringLiteral("Start Sweep")); - if (statusGuard) { + // "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() @@ -3606,18 +3620,24 @@ QWidget* RadioSetupDialog::buildDroopCalibrationTab() cancelGuard->setEnabled(cal.hasResult()); refreshSummary(); }); - connect(&cal, &AnanDroopCalibrator::error, this, [statusGuard](const QString& reason) { - if (statusGuard) - statusGuard->setText(QStringLiteral("Error: %1").arg(reason)); - }); + 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] { + connect(startStopBtn, &QPushButton::clicked, this, [&cal, errorLatch] { + *errorLatch = false; if (cal.isRunning()) cal.stop(); else cal.start(); }); - connect(applyBtn, &QPushButton::clicked, this, [&cal] { cal.applyResult(); }); + connect(applyBtn, &QPushButton::clicked, this, [&cal, errorLatch] { + *errorLatch = false; + cal.applyResult(); + }); connect(cancelBtn, &QPushButton::clicked, this, [&cal, applyGuard, cancelGuard, refreshSummary, statusGuard] { cal.clear(); diff --git a/tests/anan_droop_calibrator_test.cpp b/tests/anan_droop_calibrator_test.cpp index fd1062527..545933c88 100644 --- a/tests/anan_droop_calibrator_test.cpp +++ b/tests/anan_droop_calibrator_test.cpp @@ -6,6 +6,14 @@ #include "core/backends/anan/AnanDroopCalibrator.h" +#include "TestSettingsProfile.h" +#include "core/AppSettings.h" +#include "core/RadioSettingsScope.h" + +#include +#include +#include + #include #include @@ -132,10 +140,125 @@ int testLoadTablesOnInvalidScopeIsEmpty() 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 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) @@ -150,6 +273,14 @@ int main() 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_rxdsp_handedness_test.cpp b/tests/anan_rxdsp_handedness_test.cpp index b527aea10..588004ef5 100644 --- a/tests/anan_rxdsp_handedness_test.cpp +++ b/tests/anan_rxdsp_handedness_test.cpp @@ -41,6 +41,7 @@ #include #include +#include #include #include #include @@ -616,6 +617,140 @@ int main(int argc, char** argv) "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; From b2cb9dea36edde788c767c61b743c9a1ed5c1212 Mon Sep 17 00:00:00 2001 From: Sotiris Date: Wed, 2 Sep 2026 11:48:09 -0500 Subject: [PATCH 12/14] fix(anan): one DDC0 rate set, and name the Droop tab's controls Two review nits from #5357, both small enough that deferring them cost more than doing them. THE RATE SET. The six DDC0 rates were spelled out independently in five places. anan::kDdc0RatesKsps now lives in P2Protocol.h -- socket-free, Qt-free, already home to kDspClockHz and to buildDdcSpecific(int ddc0RateKsps), and already included by the one GUI consumer -- and every site reads from it: capabilities().sampleRatesHz, nearestDdc0RateKsps(), AnanRxDsp::setDroopCorrectionTable()'s validity check, AnanDroopCalibrator's sweep, and ConnectionPanel's picker. The failure this prevents is specific and silent: add a seventh rate to the capability list and the picker but not to AnanRxDsp's check, and every correction table for that rate is rejected by a `return` with no log line. To the operator that is "the calibration didn't take", with nothing anywhere to explain it. A sixth site turned up that the review did not list -- the hardcoded 48'000.0 / 1'536'000.0 in the pan-bandwidth-limits emit, whose own comment already claimed it matched capabilities().sampleRatesHz's endpoints. It reads from the constant now, so the comment is true. ACCESSIBLE NAMES, but deliberately not uniformly: two of these widgets are made WORSE by a name, and the a11y rules for src/gui/ are about what a screen reader actually announces, not about coverage. - startStopBtn toggles its text between "Start Sweep" and "Stop". A button takes its accessible name from its text unless one is set explicitly, so a fixed name would freeze the announcement at "Start sweep" while the button reads "Stop". It gets a DESCRIPTION, which supplements the live text instead of replacing it. - statusLbl is the same trap inverted: a QLabel's accessible name IS its text, and that text is the live sweep status -- including the failure reasons the calibrator now reports. Naming it would hide exactly the content worth hearing. Description again. - progressBar has setTextVisible(false), so it had no text at all and was announced as an unlabelled progress bar. It gets a real name. - applyBtn/cancelBtn have static text, and "Apply"/"Discard" on their own say nothing about what is being applied or discarded. Names. Deliberately still open, and called out in the PR reply rather than skipped quietly: the RadioModel layering (a shared-seam change whose lazily-constructed calibrator would invalidate RadioSetupDialog's build-once lambda captures), the cosmetic edge fade's owner (a design decision needing bench confirmation), deduplicating the median helper (six call sites outside ANAN, a conflict magnet for zero behaviour change), and caching the DSP hot path (no measurement says it matters). Build clean. anan_droop_correction_test, anan_droop_calibrator_test, anan_rxdsp_handedness_test, anan_backend_test, radio_capability_gating_test and connection_panel_size_test all pass. Co-Authored-By: Claude Opus 5 --- src/core/backends/anan/AnanBackend.cpp | 14 +++++++----- src/core/backends/anan/AnanDroopCalibrator.h | 5 ++++- src/core/backends/anan/AnanRxDsp.cpp | 2 +- src/core/backends/anan/AnanRxDsp.h | 1 + src/core/backends/anan/P2Protocol.h | 14 ++++++++++++ src/gui/ConnectionPanel.cpp | 2 +- src/gui/RadioSetupDialog.cpp | 23 ++++++++++++++++++++ 7 files changed, 52 insertions(+), 9 deletions(-) diff --git a/src/core/backends/anan/AnanBackend.cpp b/src/core/backends/anan/AnanBackend.cpp index 98d1b758b..a3bf11133 100644 --- a/src/core/backends/anan/AnanBackend.cpp +++ b/src/core/backends/anan/AnanBackend.cpp @@ -172,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 @@ -285,7 +287,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. @@ -702,12 +705,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; diff --git a/src/core/backends/anan/AnanDroopCalibrator.h b/src/core/backends/anan/AnanDroopCalibrator.h index 81ac6bccd..4f093daf1 100644 --- a/src/core/backends/anan/AnanDroopCalibrator.h +++ b/src/core/backends/anan/AnanDroopCalibrator.h @@ -1,6 +1,7 @@ #pragma once #include "core/backends/anan/AnanDroopCorrection.h" +#include "core/backends/anan/P2Protocol.h" // kDdc0RatesKsps #include "core/RadioSettingsScope.h" #include @@ -189,7 +190,9 @@ private slots: // AnanRxDsp::setDroopCorrectionBypassed(). void setDspBypass(bool bypassed); - static constexpr std::array kRatesKsps{48, 96, 192, 384, 768, 1536}; + // 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 diff --git a/src/core/backends/anan/AnanRxDsp.cpp b/src/core/backends/anan/AnanRxDsp.cpp index 19278da53..de31d69d2 100644 --- a/src/core/backends/anan/AnanRxDsp.cpp +++ b/src/core/backends/anan/AnanRxDsp.cpp @@ -212,7 +212,7 @@ void AnanRxDsp::setDroopCorrectionTable(int rateKsps, const std::vector& if (table.size() != kDroopCorrectionFftSize) return; bool valid = false; - for (const int r : {48, 96, 192, 384, 768, 1536}) + for (const int r : kDdc0RatesKsps) valid |= (r == rateKsps); if (!valid) return; diff --git a/src/core/backends/anan/AnanRxDsp.h b/src/core/backends/anan/AnanRxDsp.h index a53747c84..0cb23adaf 100644 --- a/src/core/backends/anan/AnanRxDsp.h +++ b/src/core/backends/anan/AnanRxDsp.h @@ -10,6 +10,7 @@ #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" 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/gui/ConnectionPanel.cpp b/src/gui/ConnectionPanel.cpp index 15f847a5d..01b94c0cc 100644 --- a/src/gui/ConnectionPanel.cpp +++ b/src/gui/ConnectionPanel.cpp @@ -908,7 +908,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 c976b1896..37ccb3338 100644 --- a/src/gui/RadioSetupDialog.cpp +++ b/src/gui/RadioSetupDialog.cpp @@ -3503,12 +3503,24 @@ QWidget* RadioSetupDialog::buildDroopCalibrationTab() 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); @@ -3518,6 +3530,11 @@ QWidget* RadioSetupDialog::buildDroopCalibrationTab() 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; @@ -3525,12 +3542,17 @@ QWidget* RadioSetupDialog::buildDroopCalibrationTab() "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")); @@ -3538,6 +3560,7 @@ QWidget* RadioSetupDialog::buildDroopCalibrationTab() 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; From 8770287341dac216f8374cce42352de9d62fe2dc Mon Sep 17 00:00:00 2001 From: Sotiris Date: Sun, 6 Sep 2026 09:41:26 -0500 Subject: [PATCH 13/14] docs: regenerate the bridge verb table after rebasing onto main The generated table's count comment drifted during the rebase: main and this branch both bumped it 71 -> 72 independently, so git took the two identical edits as one and the count stayed at 72 while the table grew to 73 rows. Regenerated with tools/gen_bridge_docs.py; no hand edits. --- docs/automation-bridge.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/automation-bridge.md b/docs/automation-bridge.md index f1c477416..716b9f9b1 100644 --- a/docs/automation-bridge.md +++ b/docs/automation-bridge.md @@ -3806,7 +3806,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 | |---|---|---| From f31825100f130341100e2478039c7f96b0abead4 Mon Sep 17 00:00:00 2001 From: Sotiris Date: Sun, 6 Sep 2026 18:16:55 -0500 Subject: [PATCH 14/14] fix(anan): refuse a droop Apply out loud, not silently. Principle II. applyResult() opened with one silent early return covering three unrelated states, and told nobody about any of them. `droopcal apply` answered ok:true for a correction that was never applied or saved -- AutomationServer's apply branch reports a failure only when error() fires -- and the dialog's Apply button, which stays enabled mid-sweep whenever a previous result is staged, did nothing visible. The guard belongs in applyResult() rather than the bridge so both callers are covered by one fix. Each state is now separately loud. "Sweep still running" emits error() only, deliberately not finished(): the dialog's finished handler unconditionally relabels the button "Start Sweep", which would be a lie mid-sweep. The other two also emit finished(false), matching the "radio went away" case one branch down that was already loud -- that asymmetry is what made the silence look accidental. `droopcal start` had the same shape one branch up and needed the opposite placement: start() is a documented no-op while running, so testing isRunning() after the call passes on the FIRST sweep and reports ok:true as if a second had begun. Checked before the call instead. Alongside, three related holes: - The Sampling phase never re-checked the pan rate. WaitingForRateLanded's target-match test has already passed by then, so a mouse-wheel zoom or a `zoom`/`panbw` call between the settle and the 8th capture filed the new rate's frames under the old rate's key and persisted a table measured at the wrong rate. Now aborts, keeping the rates already measured. - loadTables() ignored schema_version while saveTables() refused to write over a newer one. Accidental, not a decision: a v2 row's numbers need not mean what v1's mean, and nothing ever reads a table back off the radio to notice a wrong correction. Ignored with a warning now; kDroopCorrectionZero already covers a rate with no table. - kEdgeTaperFraction was changed to 0.09 in renderGpuFrame() only. paintEvent() carries the paired copy and stayed at 0.05, so the same radio faded 18% of span on the GPU path and 10% on the software path, while the software comment still said "see renderGpuFrame()'s own comment for why this exact fraction". Both 0.09, each naming the other. Tests: testLoadTablesRefusesANewerSchema pins that a newer row is refused and the identical document at the current schema loads, so the refusal is about the version and not the shape. testApplyResultRefusesLoudlyRatherThanSilently pins that an Apply with nothing measured emits error() rather than returning silently -- it fails against the old code. Co-Authored-By: Claude Opus 5 --- src/core/AutomationServer.cpp | 23 ++++-- .../backends/anan/AnanDroopCalibrator.cpp | 65 ++++++++++++++++- src/gui/SpectrumWidget.cpp | 18 +++-- tests/anan_droop_calibrator_test.cpp | 72 +++++++++++++++++++ 4 files changed, 166 insertions(+), 12 deletions(-) diff --git a/src/core/AutomationServer.cpp b/src/core/AutomationServer.cpp index 2f1a7095a..5003e4c2c 100644 --- a/src/core/AutomationServer.cpp +++ b/src/core/AutomationServer.cpp @@ -7919,6 +7919,15 @@ QJsonObject AutomationServer::doDroopCal(const QString& action, const QString& v return err(QStringLiteral("droopcal: no radio identity yet — connect the radio " "before calibrating")); } + // BEFORE the call, not after. start() is a documented no-op while a + // sweep is running, so the isRunning() re-check below would pass on + // the FIRST sweep still running and answer ok:true as though a second + // had begun -- a script would then read the first sweep's progress as + // its own. + if (cal.isRunning()) { + return err(QStringLiteral("droopcal: a sweep is already running -- stop it " + "before starting another")); + } cal.start(); // start() refuses (no active panadapter) by emitting error() and // returning, leaving the phase Idle. Reporting ok:true with @@ -7937,11 +7946,15 @@ QJsonObject AutomationServer::doDroopCal(const QString& action, const QString& v } 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. + // handler has no device round trip), reporting EVERY refusal -- a + // sweep still running, nothing measured yet, no radio connected, a + // write the settings store declined, a stored row with a newer schema + // -- through error(). Capturing it here is what keeps `droopcal + // apply` from answering ok:true for a correction that was never + // saved. That claim only became true when applyResult()'s own + // preconditions stopped returning silently; the guard belongs there + // rather than duplicated here, so the dialog's Apply button is + // covered by the same fix. QString failure; const QMetaObject::Connection conn = QObject::connect(&cal, &AnanDroopCalibrator::error, &cal, diff --git a/src/core/backends/anan/AnanDroopCalibrator.cpp b/src/core/backends/anan/AnanDroopCalibrator.cpp index 01acabf11..90a7eb1b3 100644 --- a/src/core/backends/anan/AnanDroopCalibrator.cpp +++ b/src/core/backends/anan/AnanDroopCalibrator.cpp @@ -94,7 +94,24 @@ QMap AnanDroopCalibrator::loadTables( QMap tables; if (!scope.isValid()) return tables; - const QJsonObject doc = scope.feature(QLatin1String(kFeature)); + + int storedSchema = 0; + const QJsonObject doc = scope.feature(QLatin1String(kFeature), &storedSchema); + if (storedSchema > kSchemaVersion) { + // Symmetric with saveTables()'s own refusal, which was already here -- + // the asymmetry was accidental, not a decision. A v2 row's numbers + // need not mean what v1's mean (a different reference level, cap, or + // length convention), so reading one back as v1 pushes a WRONG + // correction into the DSP, silently: nothing ever reads a table back + // off the radio to notice. Better uncorrected than wrongly corrected, + // and AnanRxDsp's per-rate kDroopCorrectionZero fallback already + // covers a rate with no table at all. + qCWarning(lcAnanDroopCal) + << "stored droop calibration is schema v" << storedSchema + << "-- newer than this build understands (v" << kSchemaVersion + << "); ignoring it rather than applying it as v1"; + return tables; + } for (auto it = doc.constBegin(); it != doc.constEnd(); ++it) { bool okRate = false; const int rateKsps = it.key().toInt(&okRate); @@ -195,8 +212,33 @@ void AnanDroopCalibrator::stop() void AnanDroopCalibrator::applyResult() { - if (isRunning() || m_measuredTables.isEmpty() || !m_radio) + // Every refusal below is LOUD. The single silent `return` these replaced + // covered three quite different states and told nobody about any of them: + // `droopcal apply` answered ok:true for a correction that was never + // applied or saved (AutomationServer's apply branch reports a failure + // only when error() fires), and the dialog's Apply button did nothing + // visible. Same #5263 loud-drop reasoning as start()'s own refusals, and + // as the "radio went away" case immediately below -- which was already + // loud, which is what made the silence here look accidental. + if (isRunning()) { + // error() only, no finished(): the sweep really is still running, and + // the dialog's finished handler unconditionally relabels the button + // "Start Sweep", which would be a lie while it is mid-sweep. + emit error(QStringLiteral("a sweep is still running -- stop it before applying " + "the measured correction")); + return; + } + if (m_measuredTables.isEmpty()) { + emit error(QStringLiteral("no measured correction to apply -- run a sweep first")); + emit finished(false); return; + } + if (!m_radio) { + emit error(QStringLiteral("no radio model -- the measured correction was not " + "applied or saved")); + emit finished(false); + return; + } IRadioBackend* backend = m_radio->backend(); if (!backend) { @@ -365,6 +407,25 @@ void AnanDroopCalibrator::advance() break; case Phase::Sampling: { + // The rate can change out from under an ALREADY-RUNNING Sampling + // phase -- a mouse-wheel zoom on the panadapter, or a `zoom`/`panbw` + // bridge call. WaitingForRateLanded's own target-match test cannot + // catch that; it has already passed. Without this the loop keeps + // capturing and files the new rate's frames under the old rate's key, + // persisting a table measured at the wrong rate -- the same + // cross-rate corruption the per-rate keying and finishRateChange()'s + // m_preRateChangeKsps rollback exist to prevent, reached through a + // different door. The sweep drives the rate itself, so anything else + // moving it mid-measurement is a genuine conflict, not a race to + // absorb: abort loudly and keep the rates already measured. + if (std::abs(pan->bandwidthMhz() - currentTargetRateKsps() / 1000.0) >= 1.0e-6) { + abortSweep(QStringLiteral("the panadapter rate changed to %1 ksps while " + "measuring %2 ksps -- nothing else may drive the " + "rate while a sweep runs") + .arg(std::lround(pan->bandwidthMhz() * 1000.0)) + .arg(currentTargetRateKsps())); + break; + } if (!m_haveLatestFrame) { // The only unbounded wait in the machine used to be right here. if (now - m_phaseStartedAtMs >= kSampleStallTimeoutMs) { diff --git a/src/gui/SpectrumWidget.cpp b/src/gui/SpectrumWidget.cpp index 11ebd735b..2a042cabc 100644 --- a/src/gui/SpectrumWidget.cpp +++ b/src/gui/SpectrumWidget.cpp @@ -13954,7 +13954,12 @@ void SpectrumWidget::renderGpuFrame(QRhiCommandBuffer* cb, // 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, + // with no unit conversion needed. paintEvent()'s software + // path carries the SAME constant and must be changed with + // this one -- they are one fade drawn by two renderers, and + // letting them drift means the same radio hides a different + // fraction of its span depending only on whether RHI came + // up. 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. @@ -15130,10 +15135,13 @@ void SpectrumWidget::paintEvent(QPaintEvent* ev) // flags/TNF/spot markers/SWR overlay below, so those stay fully // visible on top of it, same ordering as the GPU path. if (m_edgeTaperEnabled) { - // 0.05 (5% margin per side) -- see renderGpuFrame()'s own comment - // for why this exact fraction, and why guessing a gentler-looking - // smaller value was wrong. - static constexpr double kEdgeTaperFraction = 0.05; + // 0.09 (9% margin per side) -- see renderGpuFrame()'s own comment + // for the derivation, and why guessing a gentler-looking smaller + // value was wrong. This value is PAIRED with that one on purpose: + // it is the same fade, drawn by whichever path is live, and the two + // drifting apart means the same radio fades a different fraction of + // its span depending only on whether RHI came up. Change both. + 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/tests/anan_droop_calibrator_test.cpp b/tests/anan_droop_calibrator_test.cpp index 545933c88..25e7563bb 100644 --- a/tests/anan_droop_calibrator_test.cpp +++ b/tests/anan_droop_calibrator_test.cpp @@ -248,6 +248,74 @@ int testSaveTablesReportsWhyItDidNotPersist() return 0; } +int testLoadTablesRefusesANewerSchema() +{ + const QString radioId = QStringLiteral("AA:BB:CC:00:00:05"); + const RadioSettingsScope scope(QStringLiteral("anan"), radioId); + + // A well-formed, correctly-sized row -- the ONLY thing wrong with it is + // that a future build wrote it. Shape alone cannot distinguish it, which + // is exactly why the schema version has to be consulted. + 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"); + } + + // saveTables() already refused to WRITE over this; reading it back and + // applying it as v1 was the other half of the same hazard. + if (!AnanDroopCalibrator::loadTables(scope).isEmpty()) { + return fail("loading a NEWER schema version must be refused, not applied as v1 " + "-- a wrong correction reaches the DSP silently"); + } + + // And the refusal is about the VERSION, not the content: the identical + // document at the current version loads fine. + if (!AppSettings::instance().setRadioFeature( + QStringLiteral("anan"), radioId, + QLatin1String(AnanDroopCalibrator::kFeature), + AnanDroopCalibrator::kSchemaVersion, planted)) { + return fail("re-planting at the current schema should succeed"); + } + const auto loaded = AnanDroopCalibrator::loadTables(scope); + if (!loaded.contains(48)) + return fail("the same document at the CURRENT schema must load"); + if (!nearlyEqual(loaded.value(48)[0], 7.5f)) + return fail("a loaded table keeps its values"); + return 0; +} + +int testApplyResultRefusesLoudlyRatherThanSilently() +{ + // No RadioModel: applyResult() cannot do anything here whatever it is + // asked. The point of the test is that it SAYS SO. The silent early + // return this replaced let `droopcal apply` answer ok:true, because + // AutomationServer's apply branch reports a failure only when error() + // fires. + AnanDroopCalibrator cal(nullptr); + QString reason; + int errors = 0; + QObject::connect(&cal, &AnanDroopCalibrator::error, &cal, + [&reason, &errors](const QString& why) { reason = why; ++errors; }); + + cal.applyResult(); + if (errors != 1) + return fail("applying with nothing measured must emit error(), not return silently"); + if (reason.isEmpty()) + return fail("the refusal carries an operator-facing reason"); + + // hasResult() is what the dialog gates its Apply button on, so the two + // must agree about there being nothing to apply. + if (cal.hasResult()) + return fail("a calibrator that never swept has no result"); + return 0; +} + } // namespace int main(int argc, char** argv) @@ -281,6 +349,10 @@ int main(int argc, char** argv) return result; if (const int result = testSaveTablesReportsWhyItDidNotPersist(); result != 0) return result; + if (const int result = testLoadTablesRefusesANewerSchema(); result != 0) + return result; + if (const int result = testApplyResultRefusesLoudlyRatherThanSilently(); result != 0) + return result; std::printf("anan_droop_calibrator_test: all checks passed\n"); return 0; }