From a2c306ad7abcd9c2e2fb7195afc922daf193bd25 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Thu, 19 Mar 2026 19:38:50 +0100 Subject: [PATCH] feat: expand detected signal edges in second pass --- internal/config/config.go | 10 ++ internal/detector/detector.go | 210 +++++++++++++++++++++++++---- internal/detector/detector_test.go | 51 ++++++- 3 files changed, 242 insertions(+), 29 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index e78de9e..4845976 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,6 +28,8 @@ type DetectorConfig struct { CFARRank int `yaml:"cfar_rank" json:"cfar_rank"` CFARScaleDb float64 `yaml:"cfar_scale_db" json:"cfar_scale_db"` CFARWrapAround bool `yaml:"cfar_wrap_around" json:"cfar_wrap_around"` + EdgeMarginDb float64 `yaml:"edge_margin_db" json:"edge_margin_db"` + MaxSignalBwHz float64 `yaml:"max_signal_bw_hz" json:"max_signal_bw_hz"` // Deprecated (backward compatibility) CFAREnabled *bool `yaml:"cfar_enabled,omitempty" json:"cfar_enabled,omitempty"` @@ -107,6 +109,8 @@ func Default() Config { CFARRank: 36, CFARScaleDb: 6, CFARWrapAround: true, + EdgeMarginDb: 3.0, + MaxSignalBwHz: 150000, }, Recorder: RecorderConfig{ Enabled: false, @@ -187,6 +191,12 @@ func applyDefaults(cfg Config) Config { if cfg.Detector.CFARScaleDb <= 0 { cfg.Detector.CFARScaleDb = 6 } + if cfg.Detector.EdgeMarginDb <= 0 { + cfg.Detector.EdgeMarginDb = 3.0 + } + if cfg.Detector.MaxSignalBwHz <= 0 { + cfg.Detector.MaxSignalBwHz = 150000 + } if cfg.FrameRate <= 0 { cfg.FrameRate = 15 } diff --git a/internal/detector/detector.go b/internal/detector/detector.go index d5b3a37..b275ead 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -32,14 +32,16 @@ type Detector struct { MinStableFrames int GapTolerance time.Duration CFARScaleDb float64 + EdgeMarginDb float64 + MaxSignalBwHz float64 binWidth float64 nbins int sampleRate int - ema []float64 - active map[int64]*activeEvent - nextID int64 - cfarEngine cfar.CFAR + ema []float64 + active map[int64]*activeEvent + nextID int64 + cfarEngine cfar.CFAR lastThresholds []float64 lastNoiseFloor float64 } @@ -83,6 +85,8 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { cfarScaleDb := detCfg.CFARScaleDb cfarWrap := detCfg.CFARWrapAround thresholdDb := detCfg.ThresholdDb + edgeMarginDb := detCfg.EdgeMarginDb + maxSignalBwHz := detCfg.MaxSignalBwHz if minDur <= 0 { minDur = 250 * time.Millisecond @@ -111,6 +115,12 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { if cfarScaleDb <= 0 { cfarScaleDb = 6 } + if edgeMarginDb <= 0 { + edgeMarginDb = 3.0 + } + if maxSignalBwHz <= 0 { + maxSignalBwHz = 150000 + } if cfarRank <= 0 || cfarRank > 2*cfarTrain { cfarRank = int(math.Round(0.75 * float64(2*cfarTrain))) if cfarRank <= 0 { @@ -137,6 +147,8 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { MinStableFrames: minStable, GapTolerance: gapTolerance, CFARScaleDb: cfarScaleDb, + EdgeMarginDb: edgeMarginDb, + MaxSignalBwHz: maxSignalBwHz, binWidth: float64(sampleRate) / float64(fftSize), nbins: fftSize, sampleRate: sampleRate, @@ -164,7 +176,6 @@ func (d *Detector) LastNoiseFloor() float64 { return d.lastNoiseFloor } -// UpdateClasses refreshes active event classes from current signals. func (d *Detector) UpdateClasses(signals []Signal) { for _, s := range signals { for _, ev := range d.active { @@ -230,9 +241,176 @@ func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal } signals = append(signals, d.makeSignal(start, n-1, peak, peakBin, noise, centerHz, smooth)) } + signals = d.expandSignalEdges(signals, smooth, noiseGlobal, centerHz) + for i := range signals { + centerBin := float64(signals[i].FirstBin+signals[i].LastBin) / 2.0 + signals[i].CenterHz = d.centerFreqForBin(centerBin, centerHz) + signals[i].BWHz = float64(signals[i].LastBin-signals[i].FirstBin+1) * d.binWidth + } return signals } +func (d *Detector) expandSignalEdges(signals []Signal, smooth []float64, noiseFloor float64, centerHz float64) []Signal { + n := len(smooth) + if n == 0 || len(signals) == 0 { + return signals + } + margin := d.EdgeMarginDb + if margin <= 0 { + margin = 3.0 + } + maxExpansionBins := int(d.MaxSignalBwHz / d.binWidth) + if maxExpansionBins < 10 { + maxExpansionBins = 10 + } + for i := range signals { + seed := signals[i] + peakDb := seed.PeakDb + localNoise := noiseFloor + leftProbe := seed.FirstBin - 50 + rightProbe := seed.LastBin + 50 + if leftProbe >= 0 && rightProbe < n { + leftNoise := minInRange(smooth, maxInt(0, leftProbe), maxInt(0, seed.FirstBin-5)) + rightNoise := minInRange(smooth, minInt(n-1, seed.LastBin+5), minInt(n-1, rightProbe)) + localNoise = math.Min(leftNoise, rightNoise) + } + edgeThreshold := localNoise + margin + newFirst := seed.FirstBin + prevVal := smooth[newFirst] + for j := 0; j < maxExpansionBins; j++ { + next := newFirst - 1 + if next < 0 { + break + } + val := smooth[next] + if val <= edgeThreshold { + break + } + if val > prevVal+1.0 && val < peakDb-6.0 { + break + } + prevVal = val + newFirst = next + } + newLast := seed.LastBin + prevVal = smooth[newLast] + for j := 0; j < maxExpansionBins; j++ { + next := newLast + 1 + if next >= n { + break + } + val := smooth[next] + if val <= edgeThreshold { + break + } + if val > prevVal+1.0 && val < peakDb-6.0 { + break + } + prevVal = val + newLast = next + } + signals[i].FirstBin = newFirst + signals[i].LastBin = newLast + centerBin := float64(newFirst+newLast) / 2.0 + signals[i].CenterHz = d.centerFreqForBin(centerBin, centerHz) + signals[i].BWHz = float64(newLast-newFirst+1) * d.binWidth + } + signals = d.mergeOverlapping(signals, centerHz) + return signals +} + +func (d *Detector) mergeOverlapping(signals []Signal, centerHz float64) []Signal { + if len(signals) <= 1 { + return signals + } + sort.Slice(signals, func(i, j int) bool { + return signals[i].FirstBin < signals[j].FirstBin + }) + merged := []Signal{signals[0]} + for i := 1; i < len(signals); i++ { + last := &merged[len(merged)-1] + cur := signals[i] + if cur.FirstBin <= last.LastBin+1 { + if cur.LastBin > last.LastBin { + last.LastBin = cur.LastBin + } + if cur.PeakDb > last.PeakDb { + last.PeakDb = cur.PeakDb + } + if cur.SNRDb > last.SNRDb { + last.SNRDb = cur.SNRDb + } + centerBin := float64(last.FirstBin+last.LastBin) / 2.0 + last.BWHz = float64(last.LastBin-last.FirstBin+1) * d.binWidth + last.CenterHz = d.centerFreqForBin(centerBin, centerHz) + } else { + merged = append(merged, cur) + } + } + return merged +} + +func (d *Detector) centerFreqForBin(bin float64, centerHz float64) float64 { + return centerHz + (bin-float64(d.nbins)/2.0)*d.binWidth +} + +func minInRange(s []float64, from, to int) float64 { + if len(s) == 0 { + return 0 + } + if from < 0 { + from = 0 + } + if to >= len(s) { + to = len(s) - 1 + } + if from > to { + return s[minInt(maxInt(from, 0), len(s)-1)] + } + m := s[from] + for i := from + 1; i <= to; i++ { + if s[i] < m { + m = s[i] + } + } + return m +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func overlapHz(center1, bw1, center2, bw2 float64) bool { + left1 := center1 - bw1/2 + right1 := center1 + bw1/2 + left2 := center2 - bw2/2 + right2 := center2 + bw2/2 + return left1 <= right2 && left2 <= right1 +} + +func median(vals []float64) float64 { + if len(vals) == 0 { + return 0 + } + cp := append([]float64(nil), vals...) + sort.Float64s(cp) + mid := len(cp) / 2 + if len(cp)%2 == 0 { + return (cp[mid-1] + cp[mid]) / 2 + } + return cp[mid] +} + func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise float64, centerHz float64, spectrum []float64) Signal { centerBin := float64(first+last) / 2.0 centerFreq := centerHz + (centerBin-float64(d.nbins)/2.0)*d.binWidth @@ -260,7 +438,6 @@ func (d *Detector) smoothSpectrum(spectrum []float64) []float64 { v := spectrum[i] d.ema[i] = alpha*v + (1-alpha)*d.ema[i] } - // IMPORTANT: caller must not modify returned slice return d.ema } @@ -357,24 +534,3 @@ func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event { } return finished } - -func overlapHz(c1, b1, c2, b2 float64) bool { - l1 := c1 - b1/2.0 - r1 := c1 + b1/2.0 - l2 := c2 - b2/2.0 - r2 := c2 + b2/2.0 - return l1 <= r2 && l2 <= r1 -} - -func median(vals []float64) float64 { - if len(vals) == 0 { - return 0 - } - cpy := append([]float64(nil), vals...) - sort.Float64s(cpy) - mid := len(cpy) / 2 - if len(cpy)%2 == 0 { - return (cpy[mid-1] + cpy[mid]) / 2.0 - } - return cpy[mid] -} diff --git a/internal/detector/detector_test.go b/internal/detector/detector_test.go index d202d6f..8ebdc77 100644 --- a/internal/detector/detector_test.go +++ b/internal/detector/detector_test.go @@ -37,10 +37,8 @@ func TestDetectorCreatesEvent(t *testing.T) { t.Fatalf("expected bandwidth > 0") } - // Extend signal duration. _, _ = d.Process(now.Add(5*time.Millisecond), spectrum, center) - // Advance beyond hold with no signal to finalize. now2 := now.Add(30 * time.Millisecond) noSignal := make([]float64, len(spectrum)) for i := range noSignal { @@ -54,3 +52,52 @@ func TestDetectorCreatesEvent(t *testing.T) { t.Fatalf("event bandwidth not set") } } + +func TestSignalBandwidthExpansion(t *testing.T) { + sampleRate := 2048000 + fftSize := 2048 + cfg := config.DetectorConfig{ + ThresholdDb: -20, + MinDurationMs: 1, + HoldMs: 10, + EmaAlpha: 1.0, + HysteresisDb: 3, + MinStableFrames: 1, + GapToleranceMs: 10, + CFARMode: "OFF", + CFARGuardCells: 2, + CFARTrainCells: 8, + CFARRank: 12, + CFARScaleDb: 6, + CFARWrapAround: true, + EdgeMarginDb: 3.0, + MaxSignalBwHz: 150000, + } + d := New(cfg, sampleRate, fftSize) + spectrum := make([]float64, fftSize) + for i := range spectrum { + spectrum[i] = -100 + } + for i := 1000; i <= 1012; i++ { + spectrum[i] = -20 + } + for j := 1; j <= 5; j++ { + level := -20 - float64(j)*3 + if 1000-j >= 0 { + spectrum[1000-j] = level + } + if 1012+j < fftSize { + spectrum[1012+j] = level + } + } + now := time.Now() + _, signals := d.Process(now, spectrum, 434e6) + if len(signals) == 0 { + t.Fatal("no signals detected") + } + sig := signals[0] + expectedMinBW := 18.0 * 1000 + if sig.BWHz < expectedMinBW { + t.Errorf("BW too narrow: got %.0f Hz, want >= %.0f Hz (FirstBin=%d LastBin=%d)", sig.BWHz, expectedMinBW, sig.FirstBin, sig.LastBin) + } +}