@@ -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 ;
0 commit comments