Skip to content

Commit 0d9c7aa

Browse files
authored
fix(caps): ANAN regains its noise-floor auto-adjust — split panBinsAreAbsolute out of radioOwnsDbmScale
Split panBinsAreAbsolute out of radioOwnsDbmScale so ANAN's noise-floor auto-adjust can run on host-computed absolute bins. Shared OR predicate in NoiseFloorAutoAdjustGate.h; HL2/ANAN/RTL declare binsAbsolute; Flex and Icom unchanged. Also lands the stacked pan-span/amplitude capability records from #5725. Squashed-from: #5726 Co-authored-by: on8st <258096273+on8st@users.noreply.github.com>
1 parent 682cbd2 commit 0d9c7aa

16 files changed

Lines changed: 1091 additions & 32 deletions

docs/architecture/aetherd-touchpoint-tags.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1354,5 +1354,11 @@
13541354
"note": "Session-local APRS fill-in policy and bounded modem queue; no backend-specific commands or transport ownership.",
13551355
"split": "",
13561356
"confidence": "high"
1357+
},
1358+
"core/backends/NoiseFloorAutoAdjustGate.h": {
1359+
"tag": "universal",
1360+
"note": "The ONE predicate deciding whether the noise-floor auto-adjust may move the display reference level, as an OR over two backend-neutral properties (a real dBm-range echo, or absolute spectrum bins). Deliberately free of Qt and of RadioCapabilities so the widget and its test read the same function rather than two copies; the UI reaching it is the seam working, not coupling. Family backends populate the capability it is called with.",
1361+
"split": "",
1362+
"confidence": "high"
13571363
}
13581364
}

docs/architecture/aetherd-touchpoints.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine-design.md) §2, §10). One row per engine header the UI includes; converting a touchpoint means the UI reaches that surface through the versioned protocol instead of the header.
66

7-
**Totals:** 220 touchpoint headers (187 core, 33 models) — 220/220 tagged, 0/220 converted.
7+
**Totals:** 221 touchpoint headers (188 core, 33 models) — 221/221 tagged, 0/221 converted.
88

99
| Header | Includers | Tag | Status |
1010
|---|---:|---|---|
@@ -172,6 +172,7 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine
172172
| `core/backends/ConnectionSharingPolicy.h` | 2 | universal — Fail-closed discovery-time policy for whether a busy radio family permits another client. Canonical cross-family connection safety pending capability descriptors. | unconverted |
173173
| `core/backends/HealthSnapshotMerge.h` | 1 | universal — The snapshot merge rule, family-neutral by construction: a key the winner declares but omits from `values` means "not reported" and must not erase a base value. Moved out of backends/hl2/Hl2TelemetrySource.h (which keeps hl2MergeHealth as a forwarder) because AutomationServer::doHealth() and RadioHealthDialog both need it, and neither may include a family header to get it. Names no family. | unconverted |
174174
| `core/backends/IRadioBackend.h` | 1 | universal — THE radio seam (RFC §5.5) — the canonical intent verbs, typed deltas and normalized signals every family implements. Universal by definition: the UI reaching this header is the seam working as designed, not coupling. Everything below it in core/backends/<family>/ is family-private. | unconverted |
175+
| `core/backends/NoiseFloorAutoAdjustGate.h` | 2 | universal — The ONE predicate deciding whether the noise-floor auto-adjust may move the display reference level, as an OR over two backend-neutral properties (a real dBm-range echo, or absolute spectrum bins). Deliberately free of Qt and of RadioCapabilities so the widget and its test read the same function rather than two copies; the UI reaching it is the seam working, not coupling. Family backends populate the capability it is called with. | unconverted |
175176
| `core/backends/RadioCapabilities.h` | 7 | universal — Backend-neutral capability descriptor consumed above the radio seam. Universal by definition; family implementations populate it. | unconverted |
176177
| `core/backends/anan/AnanDiscovery.h` | 2 | vendor(anan) — openHPSDR Protocol 2 discovery and ANAN-G2 identity handling. Family-specific discovery belongs below the ANAN backend seam. | unconverted |
177178
| `core/backends/anan/AnanSettings.h` | 1 | ui-support — Client-side connection and ADC configuration for the ANAN backend. Persisted setup plumbing, not live radio state. | unconverted |

docs/architecture/radio-capabilities-map.md

Lines changed: 160 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,149 @@ read at use time, not baked into a key at construction, so the ordering problem
454454
does not arise. It is still its own change, and it applies to more than this one
455455
control.
456456

457+
### The display scale: who owns it, and whether its numbers mean anything
458+
459+
Two separate questions that both land on the vertical axis of the panadapter,
460+
and conflating them would hide one of them.
461+
462+
| Field | Flex | HL2 | Icom | ANAN | Question it answers |
463+
|---|:--:|:--:|:--:|:--:|---|
464+
| `radioOwnsDbmScale` | ✅ (default) | ⚠️ **✅ (default)** ||| Will the radio adopt a dBm range sent to it and report it back? |
465+
| `dbmAxisIsCalibrated()` (`panAmplitude->calibratedDbm`) | ✅ (absent) || ✅ (absent) || Do the numbers on that axis mean absolute dBm at the antenna? |
466+
| `panBinsAbsolute()` (`panAmplitude->binsAbsolute`) | ❌ (absent) || ❌ (absent) || Do the spectrum bins hold still while the reference level moves? |
467+
468+
**`panBinsAbsolute()` is consumed too, and it is the second term of ONE gate.**
469+
`noiseFloorAutoAdjustAllowed(radioOwnsDbmScale, panBinsAbsolute)` in
470+
`core/backends/NoiseFloorAutoAdjustGate.h` is an OR: a real echo from the radio
471+
ends the auto-floor loop by confirmation, absolute bins end it by giving it a
472+
fixed target, and either alone is enough. `SpectrumWidget::applyNoiseFloorAutoAdjust`
473+
and the auto-floor branch of `dbmRangeChangeRequested` both call it, so the
474+
widget and its backstop cannot drift apart. The other three
475+
`radioOwnsDbmScale` gates below are about whether a range can be **sent** and
476+
stay on the echo alone. HL2, ANAN and RTL-SDR declare `binsAbsolute = true`,
477+
each quoting the expression that produces its bins; ANAN is the one whose loop
478+
this turned back on.
479+
480+
**THE HL2 KEEPS THE PERMISSIVE DEFAULT, and that is deliberate rather than an
481+
omission.** `Hl2Backend::capabilities()` never assigns the field and says why at
482+
the point where it would: the flag answers two questions, and on this radio the
483+
answers differ — it cannot be commanded a dBm range (so `false` is right), and
484+
its auto-floor loop's measurement does not depend on one either (so `false`
485+
would switch off a loop that bench run d101 measured working). The only two
486+
`c.radioOwnsDbmScale = false` sites in the tree are `IcomCivBackend` and
487+
`AnanBackend`. This PR's own test refuses to assert the HL2's value for the same
488+
reason.
489+
490+
**So every gate in the table below is LIVE on an HL2**, not disarmed. An earlier
491+
version of this row read ❌ and the table then said the opposite of the code —
492+
caught by `aethersdr-agent` on #5725.
493+
494+
**`radioOwnsDbmScale` is consumed**, and a backend declaring `false` changes
495+
behaviour at four gates plus two fan-out sites:
496+
497+
| Site | What `false` does |
498+
|---|---|
499+
| `SpectrumWidget::applyNoiseFloorAutoAdjust` | Early return — the gate that actually stops the runaway, because `m_refLevel` is local and would keep marching without it |
500+
| `sendDbmRangeCommand` (`MainWindow_Wiring.cpp`) | Backstop: no dBm range leaves for the radio, whichever caller asked |
501+
| `radioDbmHeadroomRecoveryRequested` handler | Return — the second ratchet source; gating `dbmRangeChangeRequested` alone did not stop it |
502+
| `dbmRangeChangeRequested`, auto-floor branch | Re-seeds the widget from the pan's real range instead of commanding one |
503+
| `dbmRangeDragFinished` | A hand drag still moves the LOCAL scale, but skips the handshake and the command |
504+
| `MainWindow::applyCapabilitiesToUi`, and the per-pane wiring for a pane added after connect | Pushes the value into every `SpectrumWidget`, restoring the permissive default on disconnect (`!connected \|\| caps.radioOwnsDbmScale`) per rule 2 above |
505+
506+
The flag is about a **command plane**, not about a measurement. The auto-floor
507+
loop is built on an echo: the widget moves its reference level, sends
508+
`display pan set <id> min_dbm=… max_dbm=…`, and waits for the radio to confirm
509+
before moving again. `FlexBackend` is the only reader of `min_dbm` in the tree,
510+
and `RadioModel::sendCmd` drops the text at `hasCommandPlane()` for every
511+
backend but Flex and Sim. So on the HL2 the request cannot arrive and the
512+
confirmation cannot come back — which is what `false` states.
513+
514+
What that declaration does **not** state is that the 24 dB/s ratchet
515+
`RadioCapabilities.h` describes was observed on an HL2. It was not, and the two
516+
radios differ in the way that decides it: an Icom's scope bins are decoded
517+
against the **commanded** range, so a dropped command leaves the measurement
518+
stale and the loop cannot converge, while `RadioModel::onBackendSpectrumFrame`
519+
passes the HL2's absolute float dBFS bins through untouched and
520+
`SpectrumWidget::estimateNoiseFloorDbm` reads them directly — an input that does
521+
not move with `m_refLevel`. The declaration rests on the source fact (there is no
522+
echo to wait for); the runtime question is open.
523+
524+
**One read of this flag is doing a second job**, and it is worth knowing before
525+
the next backend sets it. Four of the gates above stop outbound traffic that
526+
cannot arrive. The first — `SpectrumWidget::applyNoiseFloorAutoAdjust` — turns
527+
the noise-floor auto-adjust off, justified in its own comment by a radio whose
528+
floor "is already where the calibration puts it". That is true of an Icom and
529+
false of a raw-IQ backend, whose scale is arbitrary and local and where a local
530+
auto-floor is the only one available. The two questions coincide on a Flex and an
531+
Icom and come apart here; the gate should eventually ask whether the floor
532+
MEASUREMENT depends on a commanded range.
533+
534+
**The calibration claim is declared and nothing reads it yet.** It is the
535+
second question: an HL2's axis is dBFS wearing a dBm label, because nothing on
536+
the board reports what 0 dBFS is worth at the antenna and no HL2 oracle states a
537+
figure for it. `Hl2Backend` reads the declaration off
538+
`Hl2DbReference::isCalibrated()` rather than hardcoding it, so a per-unit
539+
`fullScaleDbm` measured later moves the capability with it. The values stay
540+
internally consistent — a 3 dB stronger signal reads 3 dB higher — so what the
541+
`false` denies is comparison: no spot level, no cross-station claim, no absolute
542+
threshold.
543+
544+
It is **not a bool**. It is a field of `PanAmplitudeModel`, held as
545+
`std::optional<PanAmplitudeModel> panAmplitude`, and read through
546+
`RadioCapabilities::dbmAxisIsCalibrated()`. Absent means *no backend has been
547+
read on the question*, which is not the same as "no" — the distinction a bool
548+
cannot carry, and the reason #5262 M2 asks for a record. The accessor exists
549+
because absence has to fall to the **legacy** answer (`true`): every backend
550+
predating the field labelled its axis dBm and was consumed as though it meant
551+
it, so answering `false` for an unread backend would silently restate a claim
552+
about radios nobody has looked at.
553+
554+
`panBinsAbsolute()` reads the *other* field of the same record and falls the
555+
**opposite** way on the same absent value — undeclared is not "absolute". Two
556+
opposite defaults on one record is precisely why neither is unwrapped at a call
557+
site.
558+
559+
Flex and Icom leave the record absent and keep the legacy `` above; that is an
560+
unexamined default, not a finding, and it is exactly the trap rule 1 names,
561+
sitting in the tree. ANAN and RTL-SDR no longer inherit it: both are raw-IQ
562+
paths, both have now been read, and both declare `calibratedDbm = false` with
563+
the bin expression quoted in their own `capabilities()`.
564+
565+
### The panadapter span is a hardware property on a raw-IQ radio
566+
567+
| Field | Flex | HL2 | RTL | Question it answers |
568+
|---|:--:|:--:|:--:|---|
569+
| `panSpanModel->followsSampleRate` | — (absent) || — (absent) | Is `sampleRatesHz` the complete set of spans, floor included? |
570+
| `panSpanModel->radioWide` | — (absent) || — (absent) | Does changing one pan's span change every receiver's? |
571+
572+
Both live in one `std::optional<PanSpanModel> panSpanModel`. Absent is *not* a
573+
pair of `false`s: it means no backend has been read, and a client that needs the
574+
distinction checks `has_value()` before the fields.
575+
576+
Both are declared and nothing reads them yet — the behaviour they describe is
577+
already implemented, by `Hl2Backend::applyPanBandwidth` snapping through
578+
`nearestIqSampleRateHz` and by `panBandwidthLimitsChanged` clamping the zoom
579+
control. What was missing was the **claim**, so a client had no way to ask.
580+
581+
On the HL2 the pan span *is* the DDC sample rate, so `sampleRatesHz` is not a
582+
list of stream rates that happens to exist alongside a span control: it is every
583+
span the radio can deliver, and its first entry (48 kHz) is a floor rather than a
584+
default. A narrower window would need samples the DDC never sent. One array in
585+
`Hl2Backend.h`, `kIqSampleRatesHz`, is simultaneously the capability, the zoom
586+
clamp and the snap target, and `tests/hl2_pan_limits_declaration_test.cpp`
587+
asserts the capability against that array rather than a copy of its values.
588+
589+
`panSpanModel->radioWide` is why `receivePanBandwidthControl` is `nullopt` on a radio
590+
that plainly does change its span: `MetisProtocol::ccConfig` packs one
591+
`SampleRate` into C1[1:0] for the whole board, so the control is real but
592+
radio-wide. Publishing it as a per-pan authority would let an operator narrow one
593+
window and silently retune the other three. The same shared budget is why
594+
`Hl2Backend::receiverCeiling()` falls as the span widens.
595+
596+
Upstream RFC #5223 (display-side crop: show a sub-window of a delivered span at
597+
full bin resolution) would change what the **display** shows. It would not change
598+
what the radio can deliver, which is what these two fields declare.
599+
457600
## Previously bypassed, now reconciled
458601

459602
`maxSlices` / `maxPanadapters` sat here for weeks: declared by every backend
@@ -478,7 +621,10 @@ backend side the whole time.
478621

479622
| Field | Flex | HL2 | Sim | Note |
480623
|---|:--:|:--:|:--:|---|
481-
| `sampleRatesHz` || 4 rates | `{}` | HL2 populates it honestly; no consumer exists |
624+
| `sampleRatesHz` || 4 rates | `{}` | HL2 populates it honestly; no consumer exists. On the HL2 it is also the complete span set — see `panSpanModel` below |
625+
| `panSpanModel->followsSampleRate` | — (absent) || — (absent) | The span IS the rate, so `sampleRatesHz` is every deliverable span and its first entry is a floor |
626+
| `panSpanModel->radioWide` | — (absent) || — (absent) | One DDC rate for the board; a span change moves every receiver |
627+
| `dbmAxisIsCalibrated()` | — (absent ⇒ ✅) || — (absent ⇒ ✅) | Whether the dBm axis is absolute. HL2 reads it off `Hl2DbReference::isCalibrated()` |
482628
| `txPowerMaxWatts` | — (0.0) | 0.0 | 0.0 | Global fallback ceiling; Flex still omits it despite transmitting, which remains wrong but inert while `txPowerBands` is empty |
483629
| `hasAmplifier` | — (❌) ||| The AMP applet is driven by `TunerModel::presenceChanged`, not by this |
484630
| `extensions` |||| The namespaced vendor bag; never populated |
@@ -557,7 +703,19 @@ predicates the readouts ask before printing. See [`HERMES.md`](../HERMES.md)
557703
asserts the model-profile capabilities and the refusals they gate, and that the
558704
other backends keep the controls those refusals take away.
559705
[`tests/icom_ptt_authority_test.cpp`](../../tests/icom_ptt_authority_test.cpp)
560-
covers the keying-authority side. Every assertion reads a **capability**
706+
covers the keying-authority side.
707+
[`tests/hl2_pan_limits_declaration_test.cpp`](../../tests/hl2_pan_limits_declaration_test.cpp)
708+
pins the HL2's panadapter-limit declarations — `panSpanModel` and
709+
`panAmplitude`, and **not** `radioOwnsDbmScale`, which the HL2 deliberately
710+
leaves undeclared: that target asserts only its *default*, which is a different
711+
fact — and asserts the rate list against
712+
`hl2::kIqSampleRatesHz` and the axis against
713+
`Hl2DbReference::isCalibrated()` rather than against re-typed values, so the
714+
declaration cannot drift away from the code it describes without a failure. It is
715+
its own socket-free target on purpose: the fake-EP6 fixture that once carried the
716+
HL2 seam contract is retired inside a bracket comment in `tests/tests.cmake`, and
717+
a declaration pinned only there would be pinned in something that never builds.
718+
Every assertion reads a **capability**
561719
never `caps.family`, never a backend type. A test that asserted the family
562720
would pass just as happily against the anti-pattern the struct exists to
563721
prevent.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#pragma once
2+
3+
// The ONE gate that decides whether the noise-floor auto-adjust may move the
4+
// display reference level, kept in a header of its own so the widget and its
5+
// test read the same predicate rather than two copies that can drift.
6+
//
7+
// It takes two INDEPENDENT properties, because the single flag it replaces was
8+
// being asked two different questions and on a raw-IQ radio the answers differ:
9+
//
10+
// radioOwnsDbmScale — the radio accepts a display dBm range and ECHOES it
11+
// back. Flex: yes. HL2, ANAN, RTL-SDR, Icom: no.
12+
// panBinsAbsolute — the spectrum bins do not move when the reference level
13+
// moves, so the loop's own measurement is stable under
14+
// its own correction and it converges in one step.
15+
//
16+
// Either one alone terminates the loop, so the gate is an OR. An echo ends it
17+
// by confirmation; absolute bins end it by giving it a fixed target. With
18+
// NEITHER, the loop measures a floor that retreats as fast as it corrects and
19+
// ratchets — measured at a linear 24 dB/s on an IC-9700, walking the scale to
20+
// -1882 dBm. See RadioCapabilities::radioOwnsDbmScale and
21+
// PanAmplitudeModel::binsAbsolute for both derivations.
22+
//
23+
// Deliberately free of Qt and of RadioCapabilities itself: SpectrumWidget is
24+
// handed the two values through setters (setRadioOwnsDbmScale /
25+
// setPanBinsAbsolute) rather than reaching for capabilities from inside the
26+
// widget, and this header has to be usable from both sides of that seam.
27+
//
28+
// The second argument comes from RadioCapabilities::panBinsAbsolute(), the
29+
// accessor over std::optional<PanAmplitudeModel> — an UNDECLARED backend is
30+
// not assumed to have absolute bins, and the OR's first term keeps the gate
31+
// permissive for it anyway.
32+
33+
namespace AetherSDR {
34+
35+
// True when the auto-floor may run. The early return in
36+
// SpectrumWidget::applyNoiseFloorAutoAdjust fires when this is false — that is,
37+
// only when NEITHER property holds.
38+
constexpr bool noiseFloorAutoAdjustAllowed(bool radioOwnsDbmScale,
39+
bool panBinsAbsolute) noexcept
40+
{
41+
return radioOwnsDbmScale || panBinsAbsolute;
42+
}
43+
44+
} // namespace AetherSDR

0 commit comments

Comments
 (0)