diff --git a/internal/config/config.go b/internal/config/config.go index 4845976..db95927 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,6 +30,7 @@ type DetectorConfig struct { 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"` + MergeGapHz float64 `yaml:"merge_gap_hz" json:"merge_gap_hz"` // Deprecated (backward compatibility) CFAREnabled *bool `yaml:"cfar_enabled,omitempty" json:"cfar_enabled,omitempty"` @@ -111,6 +112,7 @@ func Default() Config { CFARWrapAround: true, EdgeMarginDb: 3.0, MaxSignalBwHz: 150000, + MergeGapHz: 5000, }, Recorder: RecorderConfig{ Enabled: false, @@ -197,6 +199,9 @@ func applyDefaults(cfg Config) Config { if cfg.Detector.MaxSignalBwHz <= 0 { cfg.Detector.MaxSignalBwHz = 150000 } + if cfg.Detector.MergeGapHz <= 0 { + cfg.Detector.MergeGapHz = 5000 + } if cfg.FrameRate <= 0 { cfg.FrameRate = 15 } diff --git a/internal/detector/detector.go b/internal/detector/detector.go index b275ead..a5fed3f 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -34,6 +34,7 @@ type Detector struct { CFARScaleDb float64 EdgeMarginDb float64 MaxSignalBwHz float64 + MergeGapHz float64 binWidth float64 nbins int sampleRate int @@ -87,6 +88,7 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { thresholdDb := detCfg.ThresholdDb edgeMarginDb := detCfg.EdgeMarginDb maxSignalBwHz := detCfg.MaxSignalBwHz + mergeGapHz := detCfg.MergeGapHz if minDur <= 0 { minDur = 250 * time.Millisecond @@ -121,6 +123,9 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { if maxSignalBwHz <= 0 { maxSignalBwHz = 150000 } + if mergeGapHz <= 0 { + mergeGapHz = 5000 + } if cfarRank <= 0 || cfarRank > 2*cfarTrain { cfarRank = int(math.Round(0.75 * float64(2*cfarTrain))) if cfarRank <= 0 { @@ -149,6 +154,7 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { CFARScaleDb: cfarScaleDb, EdgeMarginDb: edgeMarginDb, MaxSignalBwHz: maxSignalBwHz, + MergeGapHz: mergeGapHz, binWidth: float64(sampleRate) / float64(fftSize), nbins: fftSize, sampleRate: sampleRate, @@ -323,6 +329,10 @@ func (d *Detector) mergeOverlapping(signals []Signal, centerHz float64) []Signal if len(signals) <= 1 { return signals } + gapBins := 0 + if d.MergeGapHz > 0 && d.binWidth > 0 { + gapBins = int(math.Ceil(d.MergeGapHz / d.binWidth)) + } sort.Slice(signals, func(i, j int) bool { return signals[i].FirstBin < signals[j].FirstBin }) @@ -330,7 +340,8 @@ func (d *Detector) mergeOverlapping(signals []Signal, centerHz float64) []Signal for i := 1; i < len(signals); i++ { last := &merged[len(merged)-1] cur := signals[i] - if cur.FirstBin <= last.LastBin+1 { + gap := cur.FirstBin - last.LastBin - 1 + if gap <= gapBins { if cur.LastBin > last.LastBin { last.LastBin = cur.LastBin } @@ -343,6 +354,9 @@ func (d *Detector) mergeOverlapping(signals []Signal, centerHz float64) []Signal centerBin := float64(last.FirstBin+last.LastBin) / 2.0 last.BWHz = float64(last.LastBin-last.FirstBin+1) * d.binWidth last.CenterHz = d.centerFreqForBin(centerBin, centerHz) + if cur.NoiseDb < last.NoiseDb || last.NoiseDb == 0 { + last.NoiseDb = cur.NoiseDb + } } else { merged = append(merged, cur) } diff --git a/internal/detector/detector_test.go b/internal/detector/detector_test.go index 8ebdc77..fd04acf 100644 --- a/internal/detector/detector_test.go +++ b/internal/detector/detector_test.go @@ -72,6 +72,7 @@ func TestSignalBandwidthExpansion(t *testing.T) { CFARWrapAround: true, EdgeMarginDb: 3.0, MaxSignalBwHz: 150000, + MergeGapHz: 5000, } d := New(cfg, sampleRate, fftSize) spectrum := make([]float64, fftSize) @@ -101,3 +102,38 @@ func TestSignalBandwidthExpansion(t *testing.T) { t.Errorf("BW too narrow: got %.0f Hz, want >= %.0f Hz (FirstBin=%d LastBin=%d)", sig.BWHz, expectedMinBW, sig.FirstBin, sig.LastBin) } } + +func TestMergeGap(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", + MergeGapHz: 5000, + } + d := New(cfg, sampleRate, fftSize) + spectrum := make([]float64, fftSize) + for i := range spectrum { + spectrum[i] = -100 + } + for i := 500; i <= 505; i++ { + spectrum[i] = -20 + } + for i := 509; i <= 514; i++ { + spectrum[i] = -20 + } + now := time.Now() + _, signals := d.Process(now, spectrum, 434e6) + if len(signals) != 1 { + t.Errorf("expected 1 merged signal, got %d", len(signals)) + } + if len(signals) == 1 && signals[0].BWHz < 14000 { + t.Errorf("merged BW too narrow: %.0f Hz", signals[0].BWHz) + } +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 30f31f1..447d94d 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -35,6 +35,7 @@ type DetectorUpdate struct { CFARScaleDb *float64 `json:"cfar_scale_db"` CFARWrapAround *bool `json:"cfar_wrap_around"` EdgeMarginDb *float64 `json:"edge_margin_db"` + MergeGapHz *float64 `json:"merge_gap_hz"` } type SettingsUpdate struct { @@ -203,6 +204,13 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { } next.Detector.EdgeMarginDb = v } + if update.Detector.MergeGapHz != nil { + v := *update.Detector.MergeGapHz + if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 { + return m.cfg, errors.New("merge_gap_hz must be >= 0") + } + next.Detector.MergeGapHz = v + } } if update.Recorder != nil { if update.Recorder.Enabled != nil { diff --git a/web/app.js b/web/app.js index 2aebd36..0a92394 100644 --- a/web/app.js +++ b/web/app.js @@ -43,6 +43,7 @@ const hysteresisInput = qs('hysteresisInput'); const stableFramesInput = qs('stableFramesInput'); const gapToleranceInput = qs('gapToleranceInput'); const edgeMarginInput = qs('edgeMarginInput'); +const mergeGapInput = qs('mergeGapInput'); const agcToggle = qs('agcToggle'); const dcToggle = qs('dcToggle'); const iqToggle = qs('iqToggle'); @@ -420,6 +421,7 @@ function applyConfigToUI(cfg) { if (stableFramesInput) stableFramesInput.value = cfg.detector.min_stable_frames ?? 3; if (gapToleranceInput) gapToleranceInput.value = cfg.detector.gap_tolerance_ms ?? cfg.detector.hold_ms; if (edgeMarginInput) edgeMarginInput.value = cfg.detector.edge_margin_db ?? 3.0; + if (mergeGapInput) mergeGapInput.value = cfg.detector.merge_gap_hz ?? 5000; agcToggle.checked = !!cfg.agc; dcToggle.checked = !!cfg.dc_block; iqToggle.checked = !!cfg.iq_balance; @@ -1422,6 +1424,10 @@ if (edgeMarginInput) edgeMarginInput.addEventListener('change', () => { const v = parseFloat(edgeMarginInput.value); if (Number.isFinite(v)) queueConfigUpdate({ detector: { edge_margin_db: v } }); }); +if (mergeGapInput) mergeGapInput.addEventListener('change', () => { + const v = parseFloat(mergeGapInput.value); + if (Number.isFinite(v)) queueConfigUpdate({ detector: { merge_gap_hz: v } }); +}); agcToggle.addEventListener('change', () => queueSettingsUpdate({ agc: agcToggle.checked })); dcToggle.addEventListener('change', () => queueSettingsUpdate({ dc_block: dcToggle.checked })); diff --git a/web/index.html b/web/index.html index c75478e..c83d21f 100644 --- a/web/index.html +++ b/web/index.html @@ -198,6 +198,7 @@ +