Skip to content

feat(hl2): drive the automatic RF gain on measured wideband headroom (RFC #5535) - #5652

Draft
on8st wants to merge 19 commits into
aethersdr:mainfrom
on8st:hl2/bandscope-headroom-signal
Draft

on8st wants to merge 19 commits into
aethersdr:mainfrom
on8st:hl2/bandscope-headroom-signal

Conversation

@on8st

@on8st on8st commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

This implements the design in #5535 and waits on that ruling

Draft on purpose. GOVERNANCE.md says "Do not open a PR until the RFC issue is approved." The RFC this belongs to is #5535, and it is open — ON8ST posted his design answer there on 2026-09-12 (automatic by default, a manual override, and a visible clipping indicator modelled on the HL2's own LEDs) and it has not been ruled on. A draft shows the code in the review interface without claiming it is ready to merge, which seemed the honest way to respect that rule rather than route around it. It does not ask to be merged before #5535 is decided.

Off by default. No installation's behaviour changes until an operator switches the control on. #5535 asks for automatic-by-default; that default flips if and when the design is ratified. Shipping it opt-in is the first increment of that design, not a contradiction of it — the switch, the bound and the law are all here, and the default is one line.


What the regulator drives on now, and what it used to

It used to drive on the ADC clip counter, and that premise is retracted.

The control law was sized against response address 0's DATA[24] read as "the converter railed". It is not that. The bit is (&clip_cnt) — the reduction AND of a two-bit saturating counter cleared on every resp_rqst — so it is true only when that counter saturated, and it means "at least three clip events in one reporting interval". At idle nothing clears it and it is an uncleared latch of unknown age. (Correction recorded on #5354.)

Two consequences, both now written into Hl2AutoGainPolicy.h and MetisProtocol.h beside the fields they invalidate:

  • Clean does not mean "not clipping." It means "fewer than three clip events per reporting interval", which at 76.8 MSPS is a great deal of clipping. No amount of tuning the thresholds recovers what the bit never carried.
  • The rate is coarser than it looks. It is a rate of threshold crossings of a three-event counter. It stays ordinal — which is why three coarse states survive the correction and a proportional law still would not — but it is not a magnitude and never was.

It now drives on the wideband bandscope (endpoint 0x04).

The bandscope samples rx_data — the AD9866's output register, pre-DDC, pre-decimation, pre-NCO — so it sees the whole 0–38.4 MHz the converter sees. That is the property that matters and neither existing level reading has it: the S-meter and WDSP's RXA_ADC_PK are both post-DDC and describe one slice. A broadcast station 20 MHz away can saturate the converter while the operator's slice sits 40 dB below full scale, and nothing they can currently see says so.

Because it comes off the same register rxclip and rxgoodlvl are derived from, it is commensurable with the clip flag by construction rather than by measurement — unlike the WDSP pairing, which #5626 correctly says is not.

What that changes in the law. Hl2AutoGainPolicy.h's own comment used to read: "NOTHING ON THIS RADIO MEASURES HEADROOM … so the only way to find out whether the gain can come back is TO TRY IT AND SEE." That is why a release had to be a deliberate probe costing a clipped window when it failed. Now a release is licensed by a measurement: the clip flag still establishes only that the loop is allowed to try, and the reading establishes that the room exists.

Where the old input survives, and why. The clip flag is still the attack trigger and still the veto. It inspects every sample; the gated bandscope inspects 2048 out of every 76 800 000 (0.0027 %). One is a veto with total coverage and no magnitude; the other is a magnitude with almost no coverage. Wiring them as veto-and-magnitude is the design, and it is failure-safe in the right direction: a transient the bandscope missed cannot cause a release, because a release also requires the flag to have stayed clear.


The one number that is not decoration

A gated peak systematically understates the true peak — the maximum of a sample is a function of how many samples you took — and the error is in the dangerous direction, because it makes the band look quieter than it is. Under a stated Gaussian model the bound at the shipped 1 Hz gate is 3.77 dB, and a release must clear step + bias + margin.

It is budgeted as margin, never subtracted as a correction: the bound is an upper bound, the true error on a real band lies somewhere between it and zero, and nobody has measured where. The figure is computed from MetisClient::bandscopeSamplePeriodMs() rather than written as a literal, so changing the gate cannot silently invalidate it. A test pins that deleting the bias term would have licensed a step the reading could not support.

gatedPeakBiasDb is checked against the study's own independently-computed table at five duty cycles.


What is deliberately NOT decided here

Three things are left alone on purpose, because deciding them in code is what the RFC exists to prevent.

  1. The probe interval is not shortened. probingReleaseConfig() justifies its 30 s base interval by the cost of a failed probe ("one failed probe per interval is 100 ms of clipping"). A measurement-licensed release is not expected to fail, so that justification genuinely weakens and a shorter interval would reclaim gain faster after a band goes quiet. How fast an automatic control may give gain back is a control decision with an operator-visible consequence; the evidence that would size it is a live-antenna measurement nobody has taken. The loop is strictly more cautious than probing was, and the number is one line to change once there is a ruling.

  2. maxOffsetDb is probably larger than the hardware has, and is not changed. The 24 dB was sized when the LNA axis was believed to span 31 dB. ON8ST retracted that on [RFC] Automatic RF-gain protection for the Hermes-Lite 2 #5535: the gain folds & 0x1F above code 31 (Hermes-Lite2 Profile Manager Save #177, design intent per softerhardware), codes 28–31 sit within 0.07 dB, leaving about 17.8 dB usable — one board, measured once, confirmed by nobody else. Picking a new ceiling from a single unreplicated measurement would be deciding the RFC's question. Meanwhile the behaviour is safe rather than silent: Hl2GainSplit.h clamps to the register floor and reports the offset actually applied, and the AtFloor branch lights the "the front end needs attenuation ahead of the radio" warning rather than attacking into a fold forever.

  3. The visible clipping indicator is not in this PR. [RFC] Automatic RF-gain protection for the Hermes-Lite 2 #5535 asks for one on the HL2's LED model and takes it as a condition rather than a nicety. That is display, it belongs to the bandscope study's phase D, and it wants its own RFC. What this PR does is make sure the source exists: out-of-slice clipping is precisely the case an operator cannot otherwise diagnose, and Hl2BandscopeHeadroom.h already classifies it (AtRail / NearRail / Measured / Absent, plus the ClippedBetweenBlocks pairing the indicator would have to render honestly). It is display-only today and nothing reads it back.

One part of #5535's answer is already satisfied: "the regulator's own action has to be visible too." pushEffectiveLnaGain() echoes the effective gain to every pan, so the RF Gain slider moves when the loop moves it — the operator is shown what the radio is actually doing, and the stored baseline is what is preserved.


Localization

docs/HERMES.md"For coding agents — keep bring-up inside the family backend", written by @jensenpat in f6f56458 — is the rule this branch is measured against, and this is the branch with the most shared surface we have filed. Stating the hits rather than letting a reviewer find them.

The pre-PR grep returns 4 hits across 2 classes, down from 5 files; src/gui/MainWindow_Session.cpp now returns to origin/main exactly.

hit what remains why it is explained
src/models/RadioModel.h one accessor declaration, autoRfGain() The seam verb. It was four forwarders (hasAutoRfGain / setAutoRfGain / setAutoRfGainFloorDb / setAutoRfGainMode); it is now a single borrowed IAutoRfGainControl*, so adding a law or a bound to any future family's loop touches this header not at all. Names no family.
src/models/RadioModel.cpp one 8-line function Returns nullptr when there is no backend or it is disconnected — the non-permissive rule, in one place. Names no family, does no cast, knows nothing about what a law is.
src/gui/MainWindow.cpp two lines inside the existing applyRadioSideDspToPanDisplay() Rides a capability fanout that already exists and already does exactly this for setDaxStreamsAvailable. The family-suffixed settings-key helper that used to live here is gone, and with it the only place above the seam that touched a family string.
src/gui/MainWindow_Wiring.cpp one connect lambda Commands the control and reflects what actually happened. It owns no storage any more. A signal from a widget has to be connected somewhere, and this is where every other overlay-menu signal is connected.

Not grep-block hits, but shared surface, so named anyway: src/gui/SpectrumOverlayMenu.{h,cpp} (the Auto checkbox and the read-only-while-armed slider), src/core/AutomationServer.cpp (the pan autorfgain bench verb — the GUI control lives in a popup and doInvoke() refuses a non-visible widget, so without it the loop cannot be disarmed headlessly), and src/core/RadioCertification.cpp (suspends the loop across the gain-sweep exercise, or the loop moves gain mid-exercise and the stage's one hard finding fires as a permanent false positive).

Two things the rule caught that were genuine defects

A capability bool could not land at all. #5619 froze the RadioCapabilities boolean population at 71, shrink-only, enforced by tools/check_capability_records.py --strict in the Static checks job that #5633 made required. hasAutoRfGain made it 72 — this branch as written would have failed CI on a check that did not exist when it was written. RadioCapabilities now gains nothing (back to 71) and the three IRadioBackend virtuals collapse into one handle, the shape #5642 used for IOfflineHealthSource. The rule's own reasoning applies on the merits too: a bool would have fissioned immediately, because the first GUI to draw this control needed the floor bound and the law list as well — which in practice meant reading an untyped health row by string key, in two places. Both now ask isArmed().

The switch was persisted in a flat AppSettings key, which the same HERMES section names as a prohibition: a value the radio cannot store belongs in that family's OperatingState path, "never in a flat AppSettings key". DisplayAutoRfGain_<family> was exactly that. It now rides Hl2Backend::currentOperatingState()'s existing rfGain object as autoEnabled. The switch only — the offset the loop is holding is still deliberately not persisted, because an automatic transient that outlived its session would be indistinguishable next launch from a gain the operator chose.

Arming is deferred to the connect edge rather than done in restoreOperatingState, because the control refuses to arm from a baseline it does not trust and the restored baseline does not reach m_lnaGainDb until pushInitialState(). So restore records the wish and the connect edge acts on it — which also makes a refusal survivable: the preference stays recorded, the control stays off, and the next connect from a trusted baseline honours it without the operator asking twice.


Tests

Everything decidable is a pure function, for Hl2OverloadPolicy.h's reason and harder: these branches are otherwise reachable only by driving a real converter into overload with a real out-of-slice signal, which a test suite cannot arrange.

  • hl2_bandscope_headroom_test — new. The dB scale against the gateware's own thresholds (rxclip at 2048 → 0.00 dB, rxgoodlvl at 1536 → 2.50 dB), which would catch the kFullScale copy-paste that makes every block ~66 dB quiet; the bias model against the study's published table; and the pairing, including the trap — a clean block while the flag says clipped is the gate's blind interval, not evidence against the flag.
  • hl2_auto_gain_policy_test — 19 new checks. The requirement in both directions, absent-is-not-zero, that the licence is checked against the step actually taken, that a refusal is not a failed probe and earns no backoff, and that a railed block only ever escalates. Plus the equivalence that makes this safe to ship off: with the requirement off, the headroom reading changes nothing at any offset or window verdict, so ramp, probe and binary are exactly the laws that existed before this sensor did.

This stacks on #5650 and its first four commits ARE #5650. Phases 1–4 of the bandscope — the parser, the ingest, the duty-cycle gate and the uncalibrated health rows — are that PR. It targets main from the same fork, so GitHub cannot express the dependency as a base branch and the commits appear here too. Review #5650 first, and read only the six commits after 9feee1ab as this PR's own work. If #5650 merges, this rebases to nothing but those six.


What is not proven

No radio ran. The study's Procedure C — does a bandscope reading mean on a live antenna what the arithmetic says it means — needs an antenna, has not been run, and is not scheduled. Every level this PR surfaces is labelled uncalibrated and pre-DDC for that reason, and the labels say "below the converter's clip point" rather than a bare "dBFS", because the second invites being read as an absolute. What is exact is the relationship to the converter's own clip threshold, and that is exact by construction rather than by measurement.

@aethersdr-agent

Copy link
Copy Markdown
Contributor

Thanks for this — and thanks for filing it as a draft with the #5535 dependency stated up front. Two Static checks steps failed on ef4f5be8 (run 34718815992), and both are real, both are in this PR's own six commits (not inherited from #5650), and both are small. The three compile jobs (build, check-macos, check-windows) were still in progress when I looked, so nothing below says anything about whether the code compiles.

I reproduced both locally against merge-base(pr, main) = 85c9413b, and verified each fix clears its gate.


1. aetherd touchpoint manifest — the new seam header has no semantic tag

::error::aetherd touchpoints require a valid semantic tag: core/backends/AutoRfGainControl.h

tools/gen_touchpoint_manifest.py scans src/gui/ + src/main.cpp for includes that reach into src/core/ or src/models/. Your new header is included by two UI files —

  • src/gui/MainWindow.cpp:12
  • src/gui/MainWindow_Wiring.cpp:21

— so core/backends/AutoRfGainControl.h becomes touchpoint 217, and every live touchpoint must carry a tag matching TAG_RE in docs/architecture/aetherd-touchpoint-tags.json. There is no entry for it, so --check fails before it even gets to the staleness comparison. The regenerated manifest row currently reads | core/backends/AutoRfGainControl.h | 2 | — | unconverted | — the is the missing tag.

Fix — two files:

  1. Add an entry to docs/architecture/aetherd-touchpoint-tags.json. universal is the right tag by the legend's own wording, and by the argument you already make in the Localization table: the header names no family and does no cast. Something like:
  "core/backends/AutoRfGainControl.h": {
    "tag": "universal",
    "note": "Backend-neutral handle for an automatic RF-gain loop consumed above the radio seam; names no family. Family implementations own the law.",
    "split": "",
    "confidence": "high"
  }
  1. Run python tools/gen_touchpoint_manifest.py and commit docs/architecture/aetherd-touchpoints.md. It is generated — do not hand-edit the table; the totals line also moves (216 → 217, 184 → 185 core). Missing this second step is its own failure even once the tag is added, because --check compares the committed manifest byte-for-byte.

With both done, --check prints manifest up to date and exits 0 — confirmed on your head.


2. Hardcoded-colour ratchet — one new setStyleSheet() call site

OK   unique_colours       613  (base 613, +0)
OK   total_references    2724  (base 2724, +0)
OVER setstylesheet       1082  (base 1081, +1)

You added no colours at all — the +1 is the call site, and there is exactly one new one in the whole diff:

src/gui/SpectrumOverlayMenu.cpp:760 (from e9240294)

m_autoRfGainCheck->setStyleSheet(kLabelStyle);

The ratchet (tools/audit_colours.py, #4569 / RFC #3076) counts call sites, not colours, so migrating a colour to a token cannot clear this one — the tool's own hint says as much.

The good news: that line is a no-op. kLabelStyle (line 252) is a single QLabel { … } rule. m_autoRfGainCheck is a QCheckBox, which is not a QLabel and has no QLabel child, so the selector matches nothing and the checkbox renders identically without it. Deleting line 760 is the fix, and it costs no appearance:

OK   setstylesheet       1081  (base 1081, +0)

— verified by deleting exactly that line on your head and re-running the gate.

If you do want the "Auto" text to match the RF Gain: / 0 dB labels beside it (it currently won't — it inherits the popup's default text colour, not #8aa8c0), then adding a QCheckBox { … } rule to the popup's own existing setStyleSheet() site and letting it cascade keeps the count flat, whereas a fresh per-widget call will fail the ratchet again. That is a visual-polish call, not a CI one.


Neither of these touches the substance of the PR — the veto-and-magnitude wiring, the gated-peak bias budgeted as margin rather than subtracted as a correction, and the Clean-does-not-mean-not-clipping retraction written next to the fields it invalidates are the parts worth a reviewer's time, and they're all intact. Nice work on catching the frozen-71 capability-bool problem before CI did, too.

One thing I'd flag rather than fix: the ratchet resolves its base as git merge-base HEAD origin/<base_ref>, so if you rebase onto a newer main the base moves with you and neither of these two counts should shift. No need to rebase to clear them.


🤖 aethersdr-agent · cost: $3.5573 · model: claude-opus-5

@on8st

on8st commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

Re-stacked on the rebased #5650 at 406a4a44; the base is hl2/bandscope-ep4 at 63b67305, which is itself on 87b80c65. Two conflicts, both resolved by keeping both sides: tests/tests.cmake (main's five stream-free telemetry targets alongside our hl2_gain_split_test), and Hl2Backend.cpp's Converter section — where our LNA baseline/effective split now reads t.adcOverload rather than m_telemetry.adcOverload, so it honours main's telemetryLinkState() staleness gate instead of stepping around it. Full ctest 428/429, the one failure vkamp_connection_test, unrelated and reproducible on an untouched tree.

GitHub will keep reporting this CONFLICTING while its baseRefName is main rather than the parent branch — it computes mergeability against main regardless of the stack. Merging #5650 first resolves it.

@on8st
on8st force-pushed the hl2/bandscope-headroom-signal branch from 406a4a4 to c518a49 Compare September 13, 2026 03:07
@on8st

on8st commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

Re-stacked at c518a49c on #5650's e8260c93, which is on d9e8b986.

Six conflicts. Two are worth naming because the resolution is not mechanical:

Also merged rather than chosen: the ADC clip-rate counters now sit inside the non-ACK branch of the EP6 parse. An ACK's raddr is a command address, so 0x00 there is a C&C bank and not the status response — counting it would put our own echoes in the denominator.

Full ctest 433/433, zero failures.

on8st and others added 19 commits September 14, 2026 15:39
The Hermes-Lite 2 emits a second radio->host stream on the SAME socket and
port as EP6 when the run byte's bit 1 is set: 1032-byte datagrams framed
EF FE 01 04, carrying 512 raw 12-bit AD9866 codes each. Today MetisClient
counts one of those into rxBytes and then drops it on the floor.

This adds only the wire primitives — kRunWideSpectrum, the kEp4* geometry,
Ep4Stats, ep4Seq / ep4Samples / ep4Stats, isEp4Header — and nothing that
sends, enables or ingests anything. metisStart() is deliberately untouched:
its byte is asserted by hl2_metis_protocol_test and sniffed as d[3] == 0x01
by three fake-radio fixtures, so enabling the bandscope has to be a separate
later datagram rather than a wider start.

Two things here are measurements, not readings of the RTL:

  * the sequence counter's forward-gap guard. ep4_seq_no is 20 bits, and
    usopenhpsdr1.v forces its low two bits to zero while the capture FIFO is
    still filling — so a fresh stream emits 0, 1, 2 and then RESTARTS AT 0.
    A gap detector that masks to the modulus but keeps EP6's 32-bit threshold
    reads that as 1,048,573 lost packets in the first second of every
    session. kEp4SeqForwardGapMax is EP6's own rule at 20-bit scale, and
    ep4SeqStep() is where the classification lives so it can be proved
    without a client;

  * the clip predicate. A 12-bit two's-complement converter's positive
    extreme is +2047, so a symmetric abs(code) >= 2048 can never fire on a
    positive clip. Ep4Stats::clippedSamples uses ad9866.v's own two
    thresholds instead, which is also what makes a bandscope level and the
    ADC-overload bit commensurable.

hl2_ep4_bandscope_test compiles MetisProtocol.cpp directly with no Qt and no
socket, and its sequence expectations replay 3684 recorded arrivals from a
v74.2 board rather than a hand-built fixture. Against the 32-bit guard six of
its checks fail, including "the recorded leg lost no packets".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
EP4 arrives on MetisClient's existing socket, interleaved with EP6, and until
now onReadyRead counted its bytes into rxBytes and then dropped it. This gives
it a branch, a counter set of its own and a way to turn it on.

Its own SEQUENCE TRIPLE, not a share of EP6's. ep4_seq_no is a separate 20-bit
counter in the gateware with its own reset, so a client tracking one
expectation across both endpoints would report a gap on every packet of
whichever stream it saw second. m_expectedEp4Seq / m_haveEp4Seq / m_ep4Drops
sit beside m_expectedRxSeq / m_haveRxSeq / m_drops and are reset at the same
three places: start(), stop(), and setReceiverCount()'s stop/start — which
also clears wide_spectrum on the wire, so the bandscope reports itself OFF
after a receiver-count change rather than claiming a stream that is not
running. Carrying it ACROSS that restart is the gate's job, not this phase's.

ep4Rewinds is a counter of its OWN, not folded into ep4Drops. One rewind per
stream start is expected and none after it; 15,003 recorded packets saw
exactly that. A second one mid-session is a real anomaly, and inside a counter
that is supposed to read zero it would be invisible.

setBandscopeEnabled() re-sends the run byte with bit 1 set, keeping bit 0 set
so the IQ stream is not interrupted. Reached only through
Hl2Backend::invokeExtension("hl2", "bandscope.enable", ...), where freqcal.set
and nb.get already live: default off, no UI, no setting, and off again at the
next connect. It streams ~3.3 Mbit/s continuously while on — there is no
duty-cycle gate yet, which is why it is not something a default turns on.

NOTHING HERE IS FAMILY-AGNOSTIC AND NOTHING OUTSIDE HL2 PAYS FOR IT. The
counters live on MetisClient::LinkCounters (an HL2 nested type) and are
mirrored into Hl2Backend's own members, NOT onto LinkStats, which is the seam
type every backend fills in. No timer is started, no shared header changes,
and no member is constructed for a radio that has no endpoint 0x04. The four
health rows are in Hl2Backend::healthSnapshot(), which only this family calls.

onReadyRead's per-datagram body is extracted as handleDatagram(), unchanged.
The drain loop keeps what genuinely needs the QNetworkDatagram — the
destination address it latches the local endpoint from — and the rest becomes
reachable without a socket. hl2_ep4_ingest_test uses that seam to replay 3684
recorded arrivals from a v74.2 board: 3684 ep4Packets, zero rxPackets, zero
drops, one rewind. Against a 32-bit forward-gap guard, six of its checks fail,
including "ten seconds of real traffic lost nothing".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
Phase 2 left the bandscope streaming continuously — 380.95 datagrams a second,
~3.3 Mbit/s, about as much again as the IQ stream at 1 RX / 48 kHz. This adds
the duty-cycle gate that makes it a sensor instead of a second stream: sixteen
datagrams a second, 0.14 Mbit/s, one 2048-sample block per sampling period.

WHAT THE GATE IS FOR IS NOW MEASURED. Bench run d95 (Procedure B) found the
bandscope costs EP6 nothing: 0 drops in 2,113,847 EP6 datagrams with it on
against 0 in 3,123,233 with it off, gap distribution unchanged, on two counters
that share no state. So the gate is about BANDWIDTH AND DUTY CYCLE and not about
protecting stream integrity, and this commit does not claim otherwise.

Idle -> Arming -> Flushing -> Capturing -> Idle. Three of its rules are things
bench run d94 measured rather than things the design reasoned to:

  * ARMING EXITS ON seq % 4 == 0, AND THAT IS LOAD-BEARING. A mid-stream
    re-enable does not re-align ep4_seq_no — the counter is zeroed only by
    `~run` — so it resumes on whatever phase the previous disable left behind.
    Two of four measured cycles resumed at seq % 4 == 2, the tail of a block the
    previous disable interrupted two seconds earlier. A gate that accepted the
    first packet it saw would have merged two half-blocks from different
    captures into one Ep4Stats, AND THE SEQUENCE NUMBERS WOULD HAVE LOOKED
    PERFECTLY CONTINUOUS WHILE IT DID: the recorded leg runs 0 to 3047 with no
    gap, straight through four enables and four disables.

  * THE GUARD TIMER IS SIZED IN THE GATEWARE'S OWN UNITS. Ten block intervals
    (105 ms) is right for a mid-stream enable, where the first EP4 arrives in
    2.41-2.61 ms. It is wrong for the other path into Arming — Params::bandscope
    carried through setReceiverCount()'s stop/start, where the run byte goes
    0x00 -> 0x03 and the first EP4 is 129 EP6 PACKETS away. The same 129 at both
    48 kHz and 384 kHz, which is 0.339 s and 0.042 s. A 105 ms guard therefore
    times out always at 48 kHz and never at 384: a spurious failure whose
    presence depends on the operator's sample rate. bandscopeGuardMs takes the
    max of the two terms.

  * THE DISABLE ALWAYS YIELDS EXACTLY ONE TRAILING PACKET, 24-61 us later, in
    four cycles of four — the packet already inside usopenhpsdr1.v's WIDE
    states, which START cannot interrupt. It belongs to the block just emitted.
    Discarded without being counted as a drop (nothing was lost; the sequence is
    continuous) and, more importantly, discarded BEFORE any state test, so that
    it cannot open a false block boundary if a host stall lets the next arming
    happen first. d95 measured stalls of 664 ms on the capture host, so that
    ordering is not hypothetical.

AND ONE THING THE PLAN DID NOT KNOW, found in d95's own tables while building
this: the EP4 packet rate is flat in SAMPLE RATE and is NOT flat in RECEIVER
COUNT. 380.95/s at one receiver (d94, flat to 3 ppm across an eightfold rate
change) against 320.0/s at three (d95, exact to the datagram in three separate
120 s legs) — the bandscope loses START arbitration to EP6, which is tested
first in the same if/else-if chain. The guard's block term therefore uses the
slowest cadence measured (12.5 ms), not the configuration's own, which makes it
125 ms rather than the study's 105 wherever the packet term does not win.

Params::bandscope carries the operator's standing intent across
setReceiverCount()'s stop/start, and is cleared by start() and stop(). MOX and a
300 ms post-unkey hold-off (d83: 178-285 ms, median 229) refuse an arming
silently and leave that intent alone — the HL2 receives while it transmits and
hears its own PA at enormous strength.

NOTHING OUTSIDE HL2 PAYS FOR ANY OF IT. Two QTimers on MetisClient, neither
started until asked; counters on MetisClient::LinkCounters, an HL2 nested type;
no shared header, no LinkStats field, no meter table, no timer for a family that
does not construct a MetisClient — which is Flex, Icom, Sim and Web-888.

hl2_ep4_gate_test replays the four recorded enable/disable cycles from d94's
midstream-toggle leg, including the two that resume mid-block and the four
trailing packets, and fires both timers by hand. Red before green: against the
study's 105 ms guard, eight of its checks fail; with the trailing packet not
discarded, the second cycle's reading is of samples taken before its enable;
with Arming accepting the first packet it sees, the two misaligned cycles both
report the wrong four packets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
The gate produces one 2048-sample block a second and nothing reads it. This
mirrors the latest one onto the GUI thread and puts it in healthSnapshot()'s
existing "Link" section, following m_drops / m_link / m_telemetry exactly: the
block arrives on a queued signal from the hl2-io thread, is copied into a plain
member, and is read from there.

FIVE ROWS, AND THEY SAY WHAT THEY ARE. adcPeakDbfs, adcRmsDbfs, adcCrestDb,
adcClippedPerBlock, adcObservedAgoMs — the first two labelled UNCALIBRATED
PRE-DDC dBFS, because that is what they are and no shorter label is honest.
These codes come off the AD9866 before the DDC, the decimation and the NCO. They
are commensurable with the gateware's clip and good-level flags, which are
derived from the same rx_data register, and with NOTHING ELSE: not the S-meter,
not the WDSP ADC peak, not any antenna-referred level. The comparison that would
change that is the study's Procedure C. It needs a live antenna, it has not been
run, and it is not scheduled. The label goes when the measurement arrives.

ABSENT UNTIL A BLOCK HAS ARRIVED, which is what HealthSnapshot's "absent means
not reported" contract is for. There is no number that honestly stands for "the
converter's level has never been looked at", and 0.00 dBFS in particular would
read as a hard clip.

AND NOTHING MAKES A DECISION FROM THEM. IRadioBackend.h's own comment binds:
"Purely for display — nothing in the app makes a decision from it." There is no
accessor beyond the rows, no consumer, and no policy — phase 5 is HELD behind an
unanswered RFC and this commit does not anticipate it.

adcCrestDb is the one figure here that survives the missing calibration intact:
peak minus RMS, where the unknown offset cancels.

Two checks added to hl2_ep4_bandscope_test, both about the scale rather than the
rows: the gateware's own rxgoodlvl threshold (|code| 1536) must read -2.50 dBFS
beside rxclip's 0.00 at 2048, and normalising by MetisProtocol.h's kFullScale —
the EP6 24-bit DDC scale — must not be what full scale reads, because that
copy-paste would make every bandscope block ~66 dB quiet and would be invisible
on any band that is not at the rail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
Review blocker 1 on aethersdr#5650. sendBandscopeRunByte() composed the byte inline,
which put the single most dangerous line in the bandscope work out of reach of
every socket-free test: it leaves the host twice a second for a whole session
while the operator is listening, and clearing bit 0 stops the IQ stream. The
only assertion possible against an inline expression was one that re-derived it
from the same two constants and agreed with itself — the reviewer's test was to
drop the run bit and watch the suite stay green, and it would have.

Lifted into metisRunCommand(wideSpectrum, watchdogEnabled) beside metisStart
and metisStop, the shape Hl2AdcPairing.h and Hl2OverloadPolicy.h already set in
this tree, and asserted on all four bit combinations in hl2_metis_protocol_test.
The watchdog-disable leg had no coverage in either form. metisStart() stays
0x01, which is why this is a separate function and not a widened one.

Two gate-state corrections from the same review:

  * setMox passed expectTrailing=true for every non-Idle state, Arming
    included, breaking the rule bandscopeDisarm documents and
    onBandscopeGuardTimeout follows. Keying up inside the arming window left a
    stale m_bsTrailingPending that eats the first packet of a later cycle.
  * setBandscopeEnabled(false)'s trailing flag does not survive the
    applyBandscopeGate() on the next line. Behaviour unchanged; the comment now
    says so, so the next reader does not assume the flag is load-bearing there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
… runs

This PR added 41 lines to tests/hl2_backend_test.cpp. That file has no build
target: add_executable(hl2_backend_test ...) sits inside the `#[==[` block in
tests/tests.cmake headed "Retired fake-radio fixtures". ctest -N does not list
it, and a `#error` appended to the file does not stop a full ninja build. So the
41 lines never compiled and never ran, and "428 of 429 passed" said nothing
whatever about them. A test that does not run is worse than no test, because it
reads as coverage — the review's own socket disclosure listed hl2_backend_test
as "existing, registered" on the strength of them.

Not fixed by un-commenting the block. It was retired deliberately and the reason
is stated where it was retired: positive backend and telemetry convergence is
certified against real hardware, and a localhost peer is kept out of the default
compile and CTest graph. This follows what that comment prescribes instead.

What survives, now section 8 of hl2_ep4_ingest_test — a target ctest -N already
lists, extended rather than adding one a reviewer has to weigh. A
default-constructed Hl2Backend answers healthSnapshot() and invokeExtension()
with no socket, so: the EP4 rows are reported and reported OFF before anything
asks; drops and rewinds are separate rows; the headroom rows are ABSENT rather
than zero before any block; bandscope.enable is an implemented verb, echoes its
requestId, and sends no reply for requestId 0.

What does not, and is dropped rather than faked: enabling and watching the
health row follow. Hl2Backend gates the enable on m_connected, which is set only
from MetisClient::linkUp, which needs a real EP6 datagram from a real peer. In
its place section 8 asserts the half that IS socket-free and that the connected
case cannot reach — that an enable with no link is refused and reported as
refused, which is the contract the verb's own comment promises.

tests/hl2_backend_test.cpp keeps a banner saying it is not built and why, so the
next person adding to it learns that in ten seconds rather than an hour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
…nnect

Review nits on aethersdr#5650.

The backend's bandscope mirrors were cleared at the connect edge and nowhere
else. linkDown and connectFailed clear m_connected and leave m_bandscopeEnabled,
the five EP4 counters and the last block alone, so between a drop and the next
connect healthSnapshot() answers "Wideband bandscope (EP4): true" and prints ADC
levels for a session that no longer exists — the exact failure the connect-time
reset's own comment names, reached from the other side. The five lines are now
resetBandscopeMirrors(), called from all three edges.

Two comments corrected because they claimed more than the code does:

  * invokeExtension said "an operator who wants it asks for it here, once".
    There is no here. Nothing in src/ invokes bandscope.enable: no UI, no
    setting, and no AutomationServer route, that server hand-routing every verb
    it exposes. As this PR stands the health rows read off/0/0/0/0/0 on every
    install and the ADC rows never appear. Whether inert-until-a-consumer is the
    right shape to land is a maintainer's call and is not made here; the comment
    simply must not describe a path that does not exist.
  * the bandscopeTimeouts row said a non-zero value "means the radio stopped
    answering the run byte". bandscopeGuardMs's own header names two benign ways
    it fires, and the protocol header is the honest one — an operator who has
    just added a fourth panadapter should not go hunting for a hardware fault.

docs/architecture/radio-capabilities-map.md gains bandscope.enable on the
extensionNamespaces row, labelled as having no caller, because the namespace is
public surface third parties can bind to even while nothing in the app reaches
it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
…te on a watchdog link loss

Review round two on aethersdr#5650 (K5PTB). Nine findings; all nine taken.

**Blocker 1 — the run byte's arguments, not its bits.** Round one lifted the
composition into metisRunCommand() and pinned all four bit combinations in
hl2_metis_protocol_test. That left the other half open: the ARGUMENTS the gate
hands it. The gate tests reach sendBandscopeRunByte with m_socket == nullptr and
return above the only observable, so `metisCommand(kRunWideSpectrum)` (run
dropped — the IQ stops every time the gate toggles) and
`metisRunCommand(true, ...)` (never lowered — the ungated ~3.3 Mbit/s stream the
gate exists to prevent) both passed all four targets. We disclosed that hole on
the PR as one that needed "a send seam the socket-free targets don't have".
K5PTB found the seam: compose and RECORD the byte before the socket test.
m_lastBandscopeRunByte is read only by tests, through MetisClientTestAccess, and
three checks masked with 0x03 pin arming, the lowering byte, and the keyed
lowering. Both mutants above now fail hl2_ep4_gate_test — three checks and two
checks respectively, exactly as the review reported.

**The link edge that is not stop().** onWatchdogTick() surfaces 2 s of EP6
silence as linkDown WITHOUT calling stop(), so m_running and m_params.bandscope
both survive. If EP6 resumes before RadioModel's 5 s reconnect timer fires,
handleDatagram emits linkUp again with no start() behind it — and Hl2Backend's
linkUp handler resets its mirrors on the premise that "MetisClient::start()
comes up with wide_spectrum clear", which is false on exactly that path. The
health row then reads OFF beside a gate still cycling and still publishing fresh
ADC levels, and the gate keeps arming at a quiet radio, manufacturing block
timeouts. The watchdog now ends the gate's intent the way stop() does, which
makes da7f684's "every link edge" reset true on every edge. New gate-test
section 14 drives the real onWatchdogTick; reverting the fix fails three checks.

Same root cause as the watchdog nit raised on aethersdr#5627: a link-down does not reset
the state machine, only stop() does. One asymmetry, two features.

**The rest.**

- setMox's round-one Arming fix was untested: reverting it to
  expectTrailing=true passed all four targets, because section 10 only keys
  mid-capture. New section 10b keys while ARMING; the revert now fails it.
- LinkCounters::bandscopeTimeouts' own declaration still said a non-zero value
  "means the radio stopped answering the run byte". Round one fixed that claim
  on the health row in Hl2Backend.cpp and not in the header that carries it.
- m_bsBlocks and m_bsTimeouts were never reset where m_ep4Drops/m_ep4Rewinds
  are, so after a reconnect the first block of the new session published the
  previous session's total plus one. Reset in start(). Not covered by a test:
  start() binds a socket and these targets are socket-free.
- **TWELVE datagrams a second, not sixteen.** Derived from the gate's own
  arithmetic and confirmed against gate-test section 6: a steady-state cycle
  resumes at phase 1, so 3 are discarded in Arming, 4 flushed, 4 kept, 1
  trailing — 3+4+4+1 = 12. At the wire-byte basis the neighbouring 3.3 Mbit/s
  uses, that is 0.11 Mbit/s, not 0.14. Corrected in the three places the review
  named, plus a FOURTH found while deriving it (kBandscopeSampleMs' own header)
  and the "~11 ms" run-byte up-time, which was one block's worth of the same
  undercount and is ~29 ms.
- The ingest test called requestId 0 "the fire-and-forget form the UI uses".
  No UI reaches this verb; the capabilities map and the verb's own comment both
  say so.

No behaviour change reaches any other radio family, and none reaches an HL2
operator either: bandscope.enable still has no caller in src/.

Tests: 429 of 429 passed, 5 skipped, at -j4. vkamp_connection_test is
nondeterministic and unbisected and failed one -j2 run; it compiles none of the
files this branch touches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
`Hl2Backend::m_lnaGainDb` is three things at once: the AD9866 register value,
the number `rememberCurrentBandState` writes into the per-band map, and the
`rfGain` extension object `RadioStateMemory` persists. Any automatic writer on
that path therefore writes its own transients onto disk, where the next band
change makes them permanent.

Split it. `m_lnaGainDb` becomes the OPERATOR'S BASELINE, written only by
`setPanRfGain`, the band-memory restore and the connect seed. A new
`m_lnaAutoOffsetDb` is a non-negative attenuation below it, and the new
`pushEffectiveLnaGain` sends `baseline - offset` to the register, to
`Hl2DbReference` and to every pan's `panRfGainChanged` echo -- so the operator
still sees what the radio is doing, while what is STORED stays theirs.

The axis is one-directional by construction: it has no representation for a
gain above the operator's own number, so nothing built on it can make the radio
louder than they asked, and the AD9866 register region above +19 dB where this
lab measured +48 reading identically to +18 is unreachable unless the operator
is already in it. `Hl2GainSplit.h` carries the arithmetic and reports the
APPLIED offset as well as the effective gain, because a clamp at the register
floor under-delivers and a controller that assumed otherwise would attack
against a wall believing it had taken gain it never took.

No behaviour changes with no automatic writer present: the offset is zero, and
all three numbers agree. `healthSnapshot` gains `lnaBaselineDb` and
`lnaAutoOffsetDb` beside the existing `lnaGainDb`, which now reports the
effective value it always meant.

RED BEFORE GREEN. `hl2_gain_split_test` was first run against
`setLnaAutoOffsetDb` implemented the way the triage on aethersdr#5354
proposes -- "route every change through the existing applyLnaGain helper" --
and failed 13 of 31 checks, with the backend's own log showing the loss:
`HL2 band memory ( tune ): "40m" -> "20m" lna -4 dB` writing the automatic -4
over the operator's stored +5, and a fresh session then restoring -4. That is
the shape of an open defect in this lab's acceptance table (D-lna-overwrite).
All 31 pass on the split.

The test proves the persisted value across a band round trip and across a
session restart, not merely through a getter, because the loss is delayed: it
happens on the band change, not on the automatic write.

Refs aethersdr#5354

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
Observation only. Nothing here moves the gain.

aethersdr#5354 asks for a loop driven by the HL2's clip counter and
says the counter is "pollable WITHOUT a stream". Reading the gateware at
883a338 -- the build this radio reports -- says otherwise, and the difference
inverts the issue's own argument:

  * control.v composes response address 0 with `(&clip_cnt)` at DATA[24], the
    reduction AND of the counter. That is the SAME BIT MetisProtocol.cpp
    already decodes as adcOverload. Against a clear interval of order a
    millisecond the counter saturates in microseconds, so reading it buys
    nothing over reading the bit.
  * Its only clear is `resp_rqst`, which usopenhpsdr1.v toggles only in
    SYNC_RESP -- reachable only from the EP6 datagram branch, whose guard
    includes `& run`. The increment has no run gate. So at idle the counter is
    monotone and a poll returns a LATCH, not a level.
  * control.v's slow_adc_sample selector gives temperature, forward and
    reverse power and bias an idle conversion path off the LED divider. The
    clip count is the one field of roadmap 14's four that is not on it.

So there is no poll here, and that is a correctness rule rather than an
optimisation: polling the discovery reply would rail a servo to minimum on a
radio with no antenna connected.

What there is instead: MetisClient's receive loop counts response-address-0
responses and how many carried the bit, per publish window, and stamps both
onto Hl2Telemetry. It has to happen THERE. telemetryUpdated is coalesced at
kTelemetryMinIntervalMs, so publishTelemetry sees ~10 samples/s of a flag that
cycles up to ~190 times/s -- Hl2Backend.h's own comment already says the
existing warning path counts assertions "sampled far below their true rate and
always were".

healthSnapshot's Converter section gains the window pair, a session total, and
the last-observed age. Not a meter: MeterSurfaces.h and RadioCertification's
meter table are hand-maintained parallel tables whose drift has already
produced a permanent false positive, and a diagnostic does not need to inherit
that.

adcClipRatePercent returns std::optional and gives NOTHING below four
observations, which the snapshot renders as "not reported". Three responses
that all said "railed" is three responses, not 100 % clipping. And the whole
section goes quiet when the stream stops, with the age row saying how long ago
-- because a frozen 0 % is not a quiet band.

RED BEFORE GREEN. adcClipRatePercent was first written the obvious way,
`overload * 100 / samples` with no denominator gate, and failed 8 of the 13 new
cases in hl2_metis_protocol_test:

  FAIL: an empty window has no clip rate
  FAIL: three of three is NOT 100 % -- it is three observations
  FAIL: none of two is NOT 0 % -- it is two observations

Worth having on its own even if no control loop is ever built: nobody has
watched this observable on a live antenna from inside the application, because
until now nothing put it in a form that could be watched.

Refs aethersdr#5354

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
No wiring. Nothing evaluates this yet.

Hl2AutoGainPolicy.h is (state, observation, config) -> action, with no Qt, no
socket and no clock: elapsed time is an INPUT and the function returns an
instruction about state, exactly as Hl2OverloadPolicy.h's adcOverloadWarn
returns restartClock rather than touching a QElapsedTimer.

IT IS PARAMETERISED, AND THE HEADER SAYS WHY. ON8ST's sweep in
aethersdr#5354 puts the entire 0 % -> 86 % clipping transition inside
SIX DECIBELS, against a smallest useful attack step of three. A controller
whose step is half its plant's linear region is not a servo; it is a two-state
switch with extra machinery, and the honest answer may be a per-band binary
high/low decision with a long hold. The measurement that would settle it has
not been made. So step size, thresholds, dwell, cooldown and the trip memory
are all configuration, and binaryHighLowConfig() IS the binary controller
expressed as one AutoGainConfig -- same function, same state machine, same
tests. A test asserts it really produces a two-state controller. The bench
decides by measurement and neither outcome needs a rewrite.

Two behaviours are code rather than prose because they fall out of the gateware
finding in the previous commit:

  * THE OBSERVATION ONLY EXISTS WHILE STREAMING, SO THE LOOP HOLDS. A window
    with too few observations is Void, and Void is not Clean: it neither
    attacks nor releases and does not advance the release dwell. Forty seconds
    of silence produce zero release steps and leave the offset where it was.
  * A 300 ms POST-UNKEY HOLD-OFF, from a measurement on this station. Run
    d83-unkey-transient (FINDINGS.md FIND-16) measured the RX path still
    describing the operator's own transmission 178-285 ms after unkey, median
    229, over ten windows, and its own arithmetic says a hold covering it
    "would have to run past ~300 ms". That is the bound; 300 is the value.

Beyond the prior art in one place: a per-band memory of the DEEPEST attenuation
at which the band was seen to clip. A loop without it re-probes into a known
wall forever. The suite carries that as a NEGATIVE CONTROL -- the same law with
the memory disabled is still moving at the end of a 250 s run against a plant
it converges on with the memory on.

Correction to this lab's own design document, made deliberately: its section
6.4 says to remember the LOWEST offset at which a clip was observed. That is
the wrong extremum and does not converge; the binding constraint is the deepest
attenuation that still clipped, because that is where the loop must not return.

RED BEFORE GREEN, and the dither property found a real question rather than a
bug. Written first as "attack count <= elapsed / cooldown", it returned 101
attacks in 20001 ms across 500 phase and window-length combinations. The extra
step is the deliberate immediate response to the first threat after a quiet
period, which is correct -- so the assertion moved to "one immediate step plus
one per cooldown" and the reasoning is recorded at the assertion. What the
shared cooldown must prevent is a SECOND free step, and it does.

Every property is exhaustive over something: every (samples, overload) pair for
the denominator gate, 500 phase and window-length combinations for the dither
case, every plant threshold 0..26 for safety and convergence.

Refs aethersdr#5354

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
The three layers below this one are each independently correct and each stands
without a control loop. This one arms them.

Hl2Backend evaluates Hl2AutoGainPolicy.h on the existing telemetry publish --
NO NEW TIMER -- and applies the returned instruction through
setLnaAutoOffsetDb, so every automatic step goes through the split rather than
round it. The window length is an input rather than an assumption: the publish
interval is a floor, not a period, and a cooldown counted in windows would
drift with a late I/O thread.

A consequence worth stating because it is behaviour: when the radio stops
streaming, publishTelemetry stops being called and the offset simply HOLDS. The
clip evidence has no idle path, and silence is not a clean converter.

ONE Auto checkbox on the ANT panel's RF Gain row, beside the control it drives
rather than in a menu, because the two are one control: the slider becomes the
CEILING when this is ticked. Gated on a new RadioCapabilities::hasAutoRfGain,
non-permissive when disconnected on hasHostNoiseBlanker's precedent -- it can
only ever ADD a control. Persisted family-scoped as DisplayAutoRfGain_hl2,
restored only when the operator actually stored a preference: writing a default
here is how the RF-gain restore beside it once pushed a deaf receiver.

AND THE SLIDER IS READ-ONLY WHILE THE LOOP OWNS THE GAIN, which was found by
tracing the round trip rather than by a test. The slider is an INPUT as well as
a display. Showing the effective value is right -- a gain change hidden from
the operator is its own defect -- but dragging it then means something
different from what it shows: with 11 dB held, an operator dragging to 12 dB
has the backend take 12 as the new BASELINE, compute an effective 1, echo that
back, and the slider lands on 1. They asked for 12, watched it jump to 1, and
the stored value is 12. That is the aethersdr#5395 shape arrived at from the other
direction. So while armed the slider is a readout and its tooltip says how to
change the ceiling. It is the maintainer triage's own suggestion on aethersdr#5354
("when on, the slider goes read-only"), reached from the failure rather than
from the suggestion. setAutoRfGainAvailable(false) re-enables it, so a radio
swap cannot leave the operator's only gain control dead.

`pan autorfgain on|off` is added to the automation bridge for the same reason
`pan rfgain` exists, only more so: the checkbox lives in a popup, doInvoke()
refuses a widget that is not visible, and the one thing worth being able to
assert about a loop that moves the operator's gain is that it can be switched
off without a mouse. It reports "requested", not the armed state.

ARMING IS REFUSABLE, NOT SILENTLY CLAMPED. Above a +19 dB baseline this radio's
gain axis is not trustworthy -- aethersdr#5354 measured +48 dB reading identically to
+18 dB, and neither the gateware decode nor the AD9866's stated geometry
accounts for it. The control declines and says why rather than moving the
operator's number to somewhere it would work. MainWindow READS BACK the armed
state from the backend's health snapshot instead of assuming the request took,
so a refusal visibly unticks the box; the stored PREFERENCE still records what
the operator asked for, because the refusal is about the current baseline
rather than about what they want.

AND RadioCertification's control-effect stage now suspends it. That stage moves
RF gain 8 dB, spins 1.2 s twice for the S-meter EMA, and restores -- about
2.8 s of live event loop. A loop acting inside that window makes `echoed !=
target` and fires the one hard finding the stage has, "the RF Gain control did
not reach the backend", which is exactly the permanent false positive that
stage was rewritten to eliminate. It would also invalidate startGain and
therefore the restore. Suspended from the backend's own armed state, not from
the capability, and re-armed after the gain is back where it was.

RED BEFORE GREEN: with the fold-region refusal removed, hl2_gain_split_test
fails "arming is REFUSED from a baseline of +20 dB, in the fold region" and
passes with it.

NOT VERIFIED, and said here rather than left to be assumed: the GUI wiring is
source-reviewed and compiles and has NOT been driven -- not by hand and not
through the bridge. Nor has the bridge verb been invoked, and the refusal path
has been reasoned rather than seen.

Refs aethersdr#5354

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
The clip flag is an honest "you are too high" sensor and nothing else. It
says the converter railed; nothing on this radio says how much room is left
below the rails, and `RXA_ADC_PK` cannot stand in because it measures the
post-DDC slice while the flag measures the pre-DDC full spectrum. So the
attack was solved and the release was not.

This makes a release a PROBE. Clip -> 6 dB of back-off, immediate. Hold for
a probe interval. Remove the 6 dB and find out. A clip inside the
confirmation period is a FAILED probe: the step goes straight back and the
interval DOUBLES, to a cap. A probe that survives it is believed: the
interval collapses to base and the loop reclaims a step per confirmation
period. Slow and quiet while the band is genuinely hot; fast to take the
gain back once it is not.

THE COST IS ONE DETECTION WINDOW AND THAT NUMBER IS READ OUT OF THE CODE,
not assumed. The bit rides the EP6 C&C bytes, `MetisClient` accumulates it
per datagram into `Hl2Telemetry::adcSamples`, publishes coalesced at
`kTelemetryMinIntervalMs`, and `Hl2Backend::publishTelemetry` evaluates the
law on that publish and no other clock: 100 ms, carrying ~19 response-
address-0 observations at 48 kHz with one receiver and ~9 in the worst case
where command ACKs displace every other classic slot. A failed probe rails
the converter for one such window.

It is a configuration of the same pure function, beside
`binaryHighLowConfig()` -- but honestly, it needed two new fields rather
than only numbers:

  - `probeConfirmMs`, because nothing in the old law ever reset the
    interval, so last night's backoff would be carried into this afternoon;
  - `tripFloorBindsRelease`, because the old memory is a remembered DECIBEL
    and ON8ST's aethersdr#5535 objection is that the knee MOVES 10-20 dB across the
    day. Property 21 shows the floor-bound loop stranded 12 dB down long
    after the band went quiet while the probing loop walks the whole way
    back. Probing's memory is the widening interval instead: a memory in
    time, which is the only kind that survives a knee that moves.

Both default to the shipped behaviour, so the law with no configuration is
the law that shipped.

Constants, each answerable to a measurement: 6 dB both ways because the
measured knee is 3-5 dB wide, median 4, so one step clears it and cannot
stall inside it; 24 dB ceiling so no move is ever truncated below the knee
width; 30 s base interval, which is 0.33 % duty cycle against a 100 ms cost
and two orders of magnitude faster than the drift it tracks; 8 min cap,
four doublings away and far inside a dawn transition; 3 s confirmation,
because d92's same-gain control saw the rate swing 0 % -> 90 % between
successive 3 s blocks AT A FIXED GAIN, so anything shorter can sit inside a
lull and call it headroom.

`pan autorfgain mode <ramp|probe|binary>` selects the law, because the
release condition is an open question and a bench that cannot switch
without a rebuild cannot answer it. Two new health rows say which law is
running and what the probe interval currently is.

Thirteen new properties, red first: the two clauses above accounted for all
five initial failures. Observed on the closed loop -- 3 probes cost exactly
3 clipped windows; intervals 60/120/240/480/480 s; 4 deliberate clips in
the first 600 s against 1 in a 600 s slice an hour later; 24 dB reclaimed
in 9.0 s from the first successful probe, with the first probe still
waiting a whole base interval after arming.

NOT RUN AGAINST A RADIO. Every number here is from source or from the
bench; none is from the antenna.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
…decision

The bandscope gate produces one 2048-sample block a second and phase 4 put its
peak on a health dialog. This turns that block into an OBSERVATION a consumer
can reason about, and states what the observation is worth.

WHY THIS SENSOR AND NOT THE TWO WE HAD. The bandscope samples rx_data — the
AD9866's output register, pre-DDC, pre-decimation, pre-NCO — so it sees the
whole 0-38.4 MHz the converter sees. The S-meter and WDSP's RXA_ADC_PK are both
post-DDC and describe ONE SLICE. A broadcast station 20 MHz away can saturate
the converter while the operator's slice sits 40 dB below full scale, and
nothing they can see says so. docs/HERMES.md §12.5 calls the pre-DDC/post-DDC
pairing the single most useful diagnostic pairing on this radio; this is the
pre-DDC half with a MAGNITUDE rather than a boolean.

EXACTLY ONE THING HERE IS CALIBRATED, AND IT IS NOT AN ABSOLUTE. Nothing is
antenna-referred: no dBm, no LNA correction, no Hl2DbReference. What is exact,
by construction rather than by measurement, is the relation to the converter's
own clip threshold — ad9866.v derives rxclip, rxgoodlvl and the bandscope's
samples from one register, so "6 dB below the code at which rxclip fires" is
true with no reference in it. Every label reads "below the clip point" and
never bare "dBFS", because the second invites being read as an absolute. The
comparison that would change that is the study's Procedure C; it needs a live
antenna and has not been run.

AND THE DUTY CYCLE BIASES THE PEAK, COMPUTABLY. 2048 samples out of every
76 800 000 is 0.0027 % coverage, which both misses transients entirely and
SYSTEMATICALLY UNDERSTATES the peak — the maximum of a sample is a function of
how many samples you took. gatedPeakBiasDb bounds it under a stated Gaussian
model and the test checks it against the study's own published table at five
duty cycles. It is budgeted as MARGIN and never subtracted as a correction: the
bias is an upper bound, the true error on a real band is somewhere between it
and zero, and nobody has measured where.

A seam rather than an inline condition, for Hl2OverloadPolicy.h's reason and
harder: every branch here otherwise needs a real converter driven into overload
by a real out-of-slice signal. As pure functions they are reachable by
arithmetic — including the one that matters most, a clean block while the clip
flag says the converter railed, which is the gate's blind interval and NOT
evidence against the flag.

Nothing consumes this yet. 46 check sites, 50 assertions, all passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
…clip bit

THE INPUT THIS LOOP WAS BUILT ON IS RETRACTED, and that is why this change
exists rather than waiting. The control law was sized against the ADC overload
bit read as "the converter railed". It is not that. Response address 0's
DATA[24] is `(&clip_cnt)` — the reduction AND of a TWO-BIT SATURATING counter
cleared on every resp_rqst — so it is true only when that counter saturated,
and it means "at least THREE clip events in one reporting interval". At idle
nothing clears it and it is an uncleared latch of unknown age. The correction
is on aethersdr#5354 and both consequences are now written into
Hl2AutoGainPolicy.h and MetisProtocol.h beside the fields they invalidate:

  * Clean DOES NOT MEAN "not clipping". It means "fewer than three clip events
    per reporting interval", which at 76.8 MSPS is a great deal of clipping.
  * The rate is a rate of threshold crossings of a three-event counter, not a
    rate of clipping. It stays ORDINAL — which is why three coarse states
    survive the correction and a proportional law still would not — but it is
    not a magnitude and never was.

WHAT THE BANDSCOPE SUPPLIES IS THE THING THE HEADER SAID DID NOT EXIST. Its own
probing-release comment rests on "NOTHING ON THIS RADIO MEASURES HEADROOM ... so
the only way to find out whether the gain can come back is TO TRY IT AND SEE" —
which is why a release had to be a deliberate probe costing a clipped window
when it failed. The wideband bandscope measures it, off the same rx_data
register the clip counter is derived from, across the whole 0-38.4 MHz. So it
is commensurable with the flag by construction, and it can attribute pressure
to a signal OUTSIDE the operator's slice, which is the usual cause and the one
neither the S-meter nor the post-DDC slice peak can see.

A RELEASE IS NOW LICENSED BY A MEASUREMENT. The clip flag still establishes
only that the loop is ALLOWED to try; the reading establishes that the room
exists — step + the gate's sampling bias + a margin. The bias term is not
decoration: a gated peak UNDERSTATES the true peak, in the dangerous direction,
by ~3.77 dB at the shipped 1 Hz gate, and it is computed from
MetisClient::bandscopeSamplePeriodMs() rather than written as a literal so the
two cannot drift. A test pins that deleting the term would have licensed a step
the reading could not support.

ABSENT IS NOT ZERO AND IS NOT INFINITY. No current reading holds the loop and
reports HeadroomAbsent — distinct from HeadroomHold, because an operator can
act on "is the bandscope running?" and cannot act on "not enough room". It
deliberately does not degrade into blind probing: someone who armed a measured
mode did not ask for a deliberate clip. Same rule the Void branch already
applies to the clip bit — hearing nothing is not hearing clean.

The probe machinery is KEPT, unchanged. A release can still fail, because the
duty cycle can miss a transient between blocks, and a failed one still backs
the interval off exactly as before. A REFUSAL is not a failed probe and earns
no backoff: the loop never moved, so there is nothing for the band to have
punished.

STILL OFF BY DEFAULT. Setting a law is not arming one — no installation's
behaviour changes until an operator switches the control on. ON8ST's design
answer on RFC aethersdr#5535 asks for automatic-by-default with a manual override; that
default flips if and when the design is ratified, and shipping opt-in is the
first increment of it rather than a contradiction of it.

WHAT IS NOT DECIDED HERE, on purpose. probingReleaseConfig() justifies its 30 s
base interval by the cost of a failed probe. A measurement-licensed release is
not expected to fail, so that justification genuinely weakens and a shorter
interval would reclaim gain faster. It is NOT shortened: how fast an automatic
control may give gain back is a control decision with an operator-visible
consequence, the evidence that would size it is a live-antenna measurement
nobody has taken, and aethersdr#5535 is unanswered. The loop is strictly more cautious
than probing was and the number is one line to change once there is a ruling.

Arming a law that needs the reading arms the bandscope gate (0.14 Mbit/s,
gated) and disarming releases it — but only ever a gate this object started. An
operator's own bandscope.enable is theirs, in both directions.

19 new checks in hl2_auto_gain_policy_test, 83 total, all passing — including
the equivalence that makes this safe: with the requirement off, the headroom
reading changes nothing at any offset or window verdict, so ramp, probe and
binary are exactly the laws that existed before this sensor did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
…a bool and three verbs

Localization, and two of the three changes here are forced rather than chosen.

FORCED aethersdr#1 — THE CAPABILITY BOOL CANNOT LAND. aethersdr#5619 froze the
RadioCapabilities boolean population at 71, shrink-only, enforced by
tools/check_capability_records.py --strict in the Static checks job that aethersdr#5633
made REQUIRED. `hasAutoRfGain` made it 72; this branch would have failed CI on
a check that did not exist when the branch was written.

The rule behind the freeze applies on its merits, too. A bool would have
fissioned immediately: the first GUI to draw this control needed the floor
bound and the set of laws as well, and with a bool both had to come from
somewhere else — which in practice meant reading an untyped health row by
string key, in two places.

So: RadioCapabilities gains NOTHING (back to 71), IRadioBackend's three virtuals
(setAutoRfGain / setAutoRfGainFloorDb / setAutoRfGainMode) collapse into ONE —
`autoRfGainControl()` returning a borrowed IAutoRfGainControl* or nullptr — and
the vocabulary of the control lives in src/core/backends/AutoRfGainControl.h
beside the backend that implements it. Same shape aethersdr#5642 used for
IOfflineHealthSource: the family declares itself, shared code names no family.

FORCED aethersdr#2 — THE SWITCH WAS PERSISTED IN A FLAT AppSettings KEY, which
docs/HERMES.md names as a prohibition in the same section this branch is being
measured against: a value the radio cannot store belongs in that family's
OperatingState path, "never in a flat AppSettings key". `DisplayAutoRfGain_<family>`
was exactly that, family-suffixed in shared GUI code.

It now rides Hl2Backend::currentOperatingState()'s existing rfGain object, as
`autoEnabled`, and restores through the same path as the per-band gain map. THE
SWITCH ONLY — the offset the loop is holding is still deliberately not
persisted, because an automatic transient that outlived its session would be
indistinguishable next launch from a gain the operator chose.

Restoring it needed one ordering decision worth stating: arming is deferred to
the connect edge rather than done in restoreOperatingState, because the control
refuses to arm from a baseline it does not trust and the restored baseline does
not reach m_lnaGainDb until pushInitialState(). So restore records the WISH and
the connect edge acts on it — which also makes a refusal survivable: the
preference stays recorded, the control stays off, and the next connect from a
trusted baseline honours it without the operator asking twice.

CHOSEN — the typed reads. Two callers were fetching the armed state as
`backendHealthSnapshot().values["autoRfGain"].toBool()`, a string key into an
untyped map, to decide what a checkbox should show and whether a certification
run had to suspend the loop. Both now ask `isArmed()`.

WHAT THIS DOES TO THE SHARED SURFACE:

  * RadioModel: four methods become ONE accessor, `autoRfGain()`.
  * MainWindow.cpp: autoRfGainSettingsKey() is GONE, and with it the only place
    above the seam that touched a family string. What remains is two lines on
    the existing applyRadioSideDspToPanDisplay() capability fanout.
  * MainWindow_Session.cpp: returns to origin/main EXACTLY. The backend restores
    its own switch, so the GUI has no restore to do.
  * MainWindow_Wiring.cpp: commands and reflects; owns no storage.
  * AutomationServer's `pan autorfgain` reports the backend's own law list
    instead of a hard-coded ramp|probe|binary, so a new law needs no edit here.

Behaviour is unchanged in every case except the one named above: the switch now
persists per radio in that radio's own state rather than per family in the
application's, which is both the rule and the better answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
Neither number changes. Both justifications did, on aethersdr#5535 on 2026-09-12, and a
constant defended by a retracted measurement is worse than one defended by
nothing — the next person to touch it would be arguing with a ghost.

tripFloorBindsRelease's comment said the knee moves 10-20 dB between a quiet
afternoon and a loud evening. ON8ST WITHDREW THE DIURNAL ATTRIBUTION: the
return-to-baseline control fired, two of three bands did not return, and the
+/-2 dB floor quoted was two sweeps 90 minutes apart on one night. Overnight
repeatability was never measured.

What survives is the part the field actually needs, in his words — "the range
and the variability stand, the hour as cause does not". The knee is not a
constant, so a remembered offset that permanently floors the release is a
lookup table keyed on a number that moves for reasons nobody has established.
The mechanism is unchanged; the sentence defending it now claims only what is
still standing.

maxOffsetDb = 24 was sized when the LNA axis was believed to span 31 dB. That
is retracted too: the gain folds `& 0x1F` above code 31 (Hermes-Lite2 aethersdr#177,
design intent per softerhardware) and codes 28-31 sit within 0.07 dB, leaving
about 17.8 dB USABLE — one board, measured once, confirmed by nobody else.

SO THE CEILING IS PROBABLY DEEPER THAN THE HARDWARE HAS, AND IT IS NOT CHANGED
HERE. How deep an automatic control may dig is one of exactly two numbers the
operator owns, the replacement figure rests on a single unreplicated board, and
aethersdr#5535 is unanswered — picking a new ceiling from that would be deciding in code
the question the RFC exists to decide. The comment now says so, and says what
the loop does meanwhile instead of pretending the number is safe: Hl2GainSplit.h
clamps to the register floor and reports the offset ACTUALLY applied, and the
AtFloor branch lights the "the front end needs attenuation ahead of the radio"
warning rather than attacking into a fold forever.

Comments only. 83 checks still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
`bridge_docs_check` is generated from the verb registry and failed once
`pan autorfgain mode` learned the `bandscope` law: the table still advertised
`<ramp|probe|binary>`, so the documented set and the accepted set had drifted
apart in the one direction that matters — a caller reading the docs would not
have known the default law could be named.

tools/gen_bridge_docs.py, 74 verbs. One line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
…hed nothing

Two Static checks failures, neither about the control law.

**The touchpoint tag.** core/backends/AutoRfGainControl.h is an aetherd
touchpoint and carried no semantic tag, so gen_touchpoint_manifest.py --check
refused it. Tagged `universal` on the same grounds as IRadioBackend.h: no
family name appears above this seam. A family that has such a control returns
one from autoRfGainControl(); a family that does not returns nullptr and never
learns the concept exists. Manifest regenerated.

**The stylesheet.** The colour ratchet counts setStyleSheet CALL SITES, not
colours, and this branch added one: kLabelStyle on m_autoRfGainCheck. That rule
selects `QLabel`, and a QCheckBox is not one — it matched nothing and styled
nothing while still costing a ratchet slot. Removed, with the reason written at
the site so it is not re-added.

What this leaves: the box is unstyled and does not match the labels beside it.
Fixing that needs a new call site, which the ratchet refuses, or a rule on an
ancestor, and the ancestors here are scoped QWidget#name deliberately. Named in
the PR body rather than papered over.

NOT VERIFIED VISUALLY. The GUI was not run; the claim that the removed rule was
inert rests on the selector, not on a screenshot. Removing a stylesheet also
removes Qt's QStyleSheetStyle wrapper, which is the one way the appearance could
shift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TtnKQu6QGrSeBirGeAsAs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant