| @@ -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 | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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) | |||
| } | |||
| } | |||
| @@ -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 { | |||
| @@ -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 })); | |||
| @@ -198,6 +198,7 @@ | |||
| <label class="field"><span>Min Stable Frames</span><input id="stableFramesInput" type="number" step="1" min="1" /></label> | |||
| <label class="field"><span>Gap Tolerance (ms)</span><input id="gapToleranceInput" type="number" step="50" min="0" /></label> | |||
| <label class="field"><span>Edge Margin (dB)</span><input id="edgeMarginInput" type="number" step="0.5" min="0" /></label> | |||
| <label class="field"><span>Merge Gap (Hz)</span><input id="mergeGapInput" type="number" step="500" min="0" /></label> | |||
| <label class="field"><span>CFAR Mode</span> | |||
| <select id="cfarModeSelect"> | |||
| <option value="OFF">Off</option> | |||