Skip to content

Commit 0710d78

Browse files
secupclaude
andcommitted
feat(ui): handoff §5 — channel SNR as distribution (linear-mean ± dB-spread over last 8 readings; MC-DPSK physical + recalibrated OFDM broadband feed one ring; per-session reset)
good@10 shows 'channel 8.5±4.0 dB', good@20 '20.6±2.5' — mean tracks dial, spread IS the fading. ctest 85/85; both gates PASS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019btwFxDt7D19SZSqPYvZnk
1 parent 26a6d42 commit 0710d78

7 files changed

Lines changed: 88 additions & 5 deletions

File tree

docs/CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,21 @@ This log tracks all bug fixes and behavioral changes to prevent re-doing work du
1010

1111
---
1212

13+
## 2026-07-08 — feat(ui): handoff §5 — fading channel SNR displayed as a DISTRIBUTION (mean±spread), not snapshot flicker
14+
15+
On fading there is no single SNR — each reading samples one fade state (the
16+
F230 confusion: 2/6.6/12.6 flashed at the operator for the same dial-10
17+
channel). The decoder now keeps a ring of the last 8 channel-SNR readings
18+
(MC-DPSK physical measurements at the handshake; the truth-calibrated OFDM
19+
broadband per frame during transfers), reports the LINEAR-domain mean (the
20+
honest fade average — dB-averaging under-reads by the Jensen penalty) and the
21+
dB spread. MODE lines now read `channel 8.5±4.0 dB` (good@10) /
22+
`channel 20.6±2.5 dB` (good@20) — the mean tracks the dial, the ± IS the
23+
fading. Ring resets per session (a new QSO never averages the old channel).
24+
Verified: ctest 85/85; good@10 PASS + good@20 PASS 1440 bps.
25+
26+
---
27+
1328
## 2026-07-08 — feat(snr): handoff §4 — the usable≤channel invariant enforced in code (SNR-SANITY)
1429

1530
Physics forbids demod-usable SNR exceeding the channel's physical S:N. When a

src/gui/app.cpp

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1143,10 +1143,20 @@ App::App(const Options& opts) : options_(opts), simulation_enabled_(opts.enable_
11431143
// the locally measured physical channel SNR beside it when available.
11441144
// BETA readout (BUG-PHYSICAL-SNR-RIG-REF): power ratio of the connect
11451145
// sequence's training span vs the frame's own inter-chirp noise.
1146-
char channel_snr_text[40] = "";
1147-
if (modem_.hasLastPhysicalSnr()) {
1148-
snprintf(channel_snr_text, sizeof(channel_snr_text),
1149-
", local channel~%.1f dB (beta)", modem_.lastPhysicalSnrDb());
1146+
char channel_snr_text[48] = "";
1147+
{
1148+
float mean_db = 0.0f, spread_db = 0.0f;
1149+
const size_t n = modem_.physicalSnrStats(mean_db, spread_db);
1150+
if (n >= 3) {
1151+
// §5: on fading, the channel is a distribution — report the
1152+
// linear-domain (fade-averaged) mean and the dB spread across
1153+
// the recent frames instead of a single-snapshot flicker.
1154+
snprintf(channel_snr_text, sizeof(channel_snr_text),
1155+
", channel %.1f±%.1f dB", mean_db, spread_db);
1156+
} else if (modem_.hasLastPhysicalSnr()) {
1157+
snprintf(channel_snr_text, sizeof(channel_snr_text),
1158+
", channel~%.1f dB (1 frame)", modem_.lastPhysicalSnrDb());
1159+
}
11501160
}
11511161
char buf[300];
11521162
if (waveform == protocol::WaveformMode::MC_DPSK) {

src/gui/modem/modem_engine.cpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -995,6 +995,11 @@ float ModemEngine::lastPhysicalSnrDb() const {
995995
return streaming_decoder_ ? streaming_decoder_->lastPhysicalSnrDb() : 0.0f;
996996
}
997997

998+
size_t ModemEngine::physicalSnrStats(float& mean_db, float& spread_db) const {
999+
if (!streaming_decoder_) { mean_db = 0.0f; spread_db = 0.0f; return 0; }
1000+
return streaming_decoder_->physicalSnrStats(mean_db, spread_db);
1001+
}
1002+
9981003
int64_t ModemEngine::lastRxSubstantiveMs() const {
9991004
return streaming_decoder_ ? streaming_decoder_->lastRxSubstantiveMs() : -1000000;
10001005
}

src/gui/modem/modem_engine.hpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ class ModemEngine {
157157
// BUG-PHYSICAL-SNR-RIG-REF. Valid=false until first data-bearing frame.
158158
bool hasLastPhysicalSnr() const;
159159
float lastPhysicalSnrDb() const;
160+
size_t physicalSnrStats(float& mean_db, float& spread_db) const;
160161
ChannelQuality getChannelQuality() const;
161162
// Live OFDM in-band (broadband) SNR from the decoder's lock-free last-estimate
162163
// atomics — updated per logical frame on BOTH delivery paths (incl. burst-as-unit,

src/gui/modem/streaming_decoder.cpp

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1346,6 +1346,14 @@ void StreamingDecoder::reset(bool reset_doppler_coherence) {
13461346
std::fill(sync_controller_.ring_.buffer_.begin(), sync_controller_.ring_.buffer_.end(), 0.0f);
13471347
if (waveform_) waveform_->reset();
13481348
idle_noise_snr_estimator_.reset();
1349+
// §5: the physical-SNR distribution is per-session — a new QSO (or channel
1350+
// change) must not average against the previous channel's samples.
1351+
last_physical_snr_valid_.store(false);
1352+
last_physical_snr_db_.store(0.0f);
1353+
{
1354+
std::lock_guard<std::mutex> lk(physical_ring_mutex_);
1355+
physical_ring_count_ = 0;
1356+
}
13491357

13501358
{
13511359
std::lock_guard<std::mutex> qlock(queue_mutex_);

src/gui/modem/streaming_decoder.hpp

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,33 @@ class StreamingDecoder {
455455
int64_t lastRxSubstantiveMs() const { return last_rx_substantive_ms_.load(); }
456456
bool hasLastPhysicalSnr() const { return last_physical_snr_valid_.load(); }
457457
float lastPhysicalSnrDb() const { return last_physical_snr_db_.load(); }
458+
// Handoff §5: distribution over the recent physical readings.
459+
// Returns readings-count; mean is linear-domain (fade-averaged), spread
460+
// is the dB standard deviation of the samples.
461+
size_t physicalSnrStats(float& mean_db, float& spread_db) const {
462+
std::lock_guard<std::mutex> lk(physical_ring_mutex_);
463+
const size_t n = std::min(physical_ring_count_, kPhysicalSnrRing);
464+
if (n == 0) { mean_db = 0.0f; spread_db = 0.0f; return 0; }
465+
double lin_sum = 0.0, db_sum = 0.0;
466+
for (size_t i = 0; i < n; ++i) {
467+
lin_sum += std::pow(10.0, physical_ring_db_[i] / 10.0);
468+
db_sum += physical_ring_db_[i];
469+
}
470+
mean_db = static_cast<float>(10.0 * std::log10(lin_sum / n));
471+
const double db_mean = db_sum / n;
472+
double var = 0.0;
473+
for (size_t i = 0; i < n; ++i) {
474+
const double d = physical_ring_db_[i] - db_mean;
475+
var += d * d;
476+
}
477+
spread_db = static_cast<float>(std::sqrt(var / n));
478+
return n;
479+
}
480+
void notePhysicalSnrSample(float db) const {
481+
std::lock_guard<std::mutex> lk(physical_ring_mutex_);
482+
physical_ring_db_[physical_ring_count_ % kPhysicalSnrRing] = db;
483+
++physical_ring_count_;
484+
}
458485
uint64_t burstAirSamplesRemaining() {
459486
const uint64_t end = burst_air_end_abs_.load(std::memory_order_relaxed);
460487
if (end == 0) return 0;
@@ -775,6 +802,16 @@ class StreamingDecoder {
775802
// rate selection stays on effective (#74 — the demod lives there).
776803
mutable std::atomic<float> last_physical_snr_db_{0.0f};
777804
mutable std::atomic<bool> last_physical_snr_valid_{false};
805+
// Handoff §5: fading channel SNR is a DISTRIBUTION, not a number — each
806+
// per-frame physical reading samples one fade state. Keep a short ring
807+
// and report mean ± spread: mean in the LINEAR power domain (the honest
808+
// fade average — dB-averaging under-reads by the Jensen penalty), spread
809+
// as the dB standard deviation. ~8 readings ≈ 15-40 s of frames ≈ several
810+
// coherence times on Good.
811+
static constexpr size_t kPhysicalSnrRing = 8;
812+
mutable std::mutex physical_ring_mutex_;
813+
mutable std::array<float, kPhysicalSnrRing> physical_ring_db_{};
814+
mutable size_t physical_ring_count_ = 0;
778815
float sync_gap_error_samples_ = 0.0f; // Dual-chirp timing error for current frame
779816
size_t last_decoded_sync_pos_ = SIZE_MAX; // Last successfully decoded sync position (to prevent duplicates)
780817
bool sync_from_warm_timed_window_ = false;

src/gui/modem/streaming_sync_acquisition.cpp

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,10 @@ void StreamingDecoder::populateDecodeMetrics(DecodeResult& result, bool is_ofdm,
117117
// ratio over the exact training span vs THIS frame's burst-time noise
118118
// ref) — replaces the old handshake-latched decoder-side computation.
119119
if (!is_ofdm && waveform_ && waveform_->hasPhysicalSNR()) {
120-
last_physical_snr_db_.store(waveform_->getPhysicalSNRdB());
120+
const float phys = waveform_->getPhysicalSNRdB();
121+
last_physical_snr_db_.store(phys);
121122
last_physical_snr_valid_.store(true);
123+
notePhysicalSnrSample(phys); // §5: distribution ring
122124
}
123125
result.sync_correlation = sync_correlation_;
124126
result.sync_quality_db = result.snr_db;
@@ -205,6 +207,11 @@ void StreamingDecoder::populateDecodeMetrics(DecodeResult& result, bool is_ofdm,
205207
}
206208
result.snr_source = SNRSource::SYNC_QUALITY;
207209
if (result.has_ofdm_broadband_snr_db) {
210+
// §5: the recalibrated OFDM broadband reading is truth-calibrated
211+
// (OFDMSnrCalibration ±1.5 dB) — feed the channel-SNR distribution
212+
// ring so the mean±spread display keeps updating through OFDM
213+
// transfers (MC-DPSK frames stop at the handshake).
214+
notePhysicalSnrSample(result.ofdm_broadband_snr_db);
208215
result.snr_db = result.ofdm_broadband_snr_db;
209216
result.snr_source = SNRSource::OFDM_BROADBAND;
210217
last_ofdm_broadband_snr_db_valid_.store(true);

0 commit comments

Comments
 (0)