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 @@
+