| @@ -30,6 +30,7 @@ type DetectorConfig struct { | |||||
| CFARWrapAround bool `yaml:"cfar_wrap_around" json:"cfar_wrap_around"` | CFARWrapAround bool `yaml:"cfar_wrap_around" json:"cfar_wrap_around"` | ||||
| EdgeMarginDb float64 `yaml:"edge_margin_db" json:"edge_margin_db"` | EdgeMarginDb float64 `yaml:"edge_margin_db" json:"edge_margin_db"` | ||||
| MaxSignalBwHz float64 `yaml:"max_signal_bw_hz" json:"max_signal_bw_hz"` | 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) | // Deprecated (backward compatibility) | ||||
| CFAREnabled *bool `yaml:"cfar_enabled,omitempty" json:"cfar_enabled,omitempty"` | CFAREnabled *bool `yaml:"cfar_enabled,omitempty" json:"cfar_enabled,omitempty"` | ||||
| @@ -111,6 +112,7 @@ func Default() Config { | |||||
| CFARWrapAround: true, | CFARWrapAround: true, | ||||
| EdgeMarginDb: 3.0, | EdgeMarginDb: 3.0, | ||||
| MaxSignalBwHz: 150000, | MaxSignalBwHz: 150000, | ||||
| MergeGapHz: 5000, | |||||
| }, | }, | ||||
| Recorder: RecorderConfig{ | Recorder: RecorderConfig{ | ||||
| Enabled: false, | Enabled: false, | ||||
| @@ -197,6 +199,9 @@ func applyDefaults(cfg Config) Config { | |||||
| if cfg.Detector.MaxSignalBwHz <= 0 { | if cfg.Detector.MaxSignalBwHz <= 0 { | ||||
| cfg.Detector.MaxSignalBwHz = 150000 | cfg.Detector.MaxSignalBwHz = 150000 | ||||
| } | } | ||||
| if cfg.Detector.MergeGapHz <= 0 { | |||||
| cfg.Detector.MergeGapHz = 5000 | |||||
| } | |||||
| if cfg.FrameRate <= 0 { | if cfg.FrameRate <= 0 { | ||||
| cfg.FrameRate = 15 | cfg.FrameRate = 15 | ||||
| } | } | ||||
| @@ -34,6 +34,7 @@ type Detector struct { | |||||
| CFARScaleDb float64 | CFARScaleDb float64 | ||||
| EdgeMarginDb float64 | EdgeMarginDb float64 | ||||
| MaxSignalBwHz float64 | MaxSignalBwHz float64 | ||||
| MergeGapHz float64 | |||||
| binWidth float64 | binWidth float64 | ||||
| nbins int | nbins int | ||||
| sampleRate int | sampleRate int | ||||
| @@ -87,6 +88,7 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { | |||||
| thresholdDb := detCfg.ThresholdDb | thresholdDb := detCfg.ThresholdDb | ||||
| edgeMarginDb := detCfg.EdgeMarginDb | edgeMarginDb := detCfg.EdgeMarginDb | ||||
| maxSignalBwHz := detCfg.MaxSignalBwHz | maxSignalBwHz := detCfg.MaxSignalBwHz | ||||
| mergeGapHz := detCfg.MergeGapHz | |||||
| if minDur <= 0 { | if minDur <= 0 { | ||||
| minDur = 250 * time.Millisecond | minDur = 250 * time.Millisecond | ||||
| @@ -121,6 +123,9 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { | |||||
| if maxSignalBwHz <= 0 { | if maxSignalBwHz <= 0 { | ||||
| maxSignalBwHz = 150000 | maxSignalBwHz = 150000 | ||||
| } | } | ||||
| if mergeGapHz <= 0 { | |||||
| mergeGapHz = 5000 | |||||
| } | |||||
| if cfarRank <= 0 || cfarRank > 2*cfarTrain { | if cfarRank <= 0 || cfarRank > 2*cfarTrain { | ||||
| cfarRank = int(math.Round(0.75 * float64(2*cfarTrain))) | cfarRank = int(math.Round(0.75 * float64(2*cfarTrain))) | ||||
| if cfarRank <= 0 { | if cfarRank <= 0 { | ||||
| @@ -149,6 +154,7 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { | |||||
| CFARScaleDb: cfarScaleDb, | CFARScaleDb: cfarScaleDb, | ||||
| EdgeMarginDb: edgeMarginDb, | EdgeMarginDb: edgeMarginDb, | ||||
| MaxSignalBwHz: maxSignalBwHz, | MaxSignalBwHz: maxSignalBwHz, | ||||
| MergeGapHz: mergeGapHz, | |||||
| binWidth: float64(sampleRate) / float64(fftSize), | binWidth: float64(sampleRate) / float64(fftSize), | ||||
| nbins: fftSize, | nbins: fftSize, | ||||
| sampleRate: sampleRate, | sampleRate: sampleRate, | ||||
| @@ -323,6 +329,10 @@ func (d *Detector) mergeOverlapping(signals []Signal, centerHz float64) []Signal | |||||
| if len(signals) <= 1 { | if len(signals) <= 1 { | ||||
| return signals | 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 { | sort.Slice(signals, func(i, j int) bool { | ||||
| return signals[i].FirstBin < signals[j].FirstBin | 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++ { | for i := 1; i < len(signals); i++ { | ||||
| last := &merged[len(merged)-1] | last := &merged[len(merged)-1] | ||||
| cur := signals[i] | cur := signals[i] | ||||
| if cur.FirstBin <= last.LastBin+1 { | |||||
| gap := cur.FirstBin - last.LastBin - 1 | |||||
| if gap <= gapBins { | |||||
| if cur.LastBin > last.LastBin { | if cur.LastBin > last.LastBin { | ||||
| last.LastBin = cur.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 | centerBin := float64(last.FirstBin+last.LastBin) / 2.0 | ||||
| last.BWHz = float64(last.LastBin-last.FirstBin+1) * d.binWidth | last.BWHz = float64(last.LastBin-last.FirstBin+1) * d.binWidth | ||||
| last.CenterHz = d.centerFreqForBin(centerBin, centerHz) | last.CenterHz = d.centerFreqForBin(centerBin, centerHz) | ||||
| if cur.NoiseDb < last.NoiseDb || last.NoiseDb == 0 { | |||||
| last.NoiseDb = cur.NoiseDb | |||||
| } | |||||
| } else { | } else { | ||||
| merged = append(merged, cur) | merged = append(merged, cur) | ||||
| } | } | ||||
| @@ -72,6 +72,7 @@ func TestSignalBandwidthExpansion(t *testing.T) { | |||||
| CFARWrapAround: true, | CFARWrapAround: true, | ||||
| EdgeMarginDb: 3.0, | EdgeMarginDb: 3.0, | ||||
| MaxSignalBwHz: 150000, | MaxSignalBwHz: 150000, | ||||
| MergeGapHz: 5000, | |||||
| } | } | ||||
| d := New(cfg, sampleRate, fftSize) | d := New(cfg, sampleRate, fftSize) | ||||
| spectrum := make([]float64, 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) | 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"` | CFARScaleDb *float64 `json:"cfar_scale_db"` | ||||
| CFARWrapAround *bool `json:"cfar_wrap_around"` | CFARWrapAround *bool `json:"cfar_wrap_around"` | ||||
| EdgeMarginDb *float64 `json:"edge_margin_db"` | EdgeMarginDb *float64 `json:"edge_margin_db"` | ||||
| MergeGapHz *float64 `json:"merge_gap_hz"` | |||||
| } | } | ||||
| type SettingsUpdate struct { | type SettingsUpdate struct { | ||||
| @@ -203,6 +204,13 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { | |||||
| } | } | ||||
| next.Detector.EdgeMarginDb = v | 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 != nil { | ||||
| if update.Recorder.Enabled != nil { | if update.Recorder.Enabled != nil { | ||||
| @@ -43,6 +43,7 @@ const hysteresisInput = qs('hysteresisInput'); | |||||
| const stableFramesInput = qs('stableFramesInput'); | const stableFramesInput = qs('stableFramesInput'); | ||||
| const gapToleranceInput = qs('gapToleranceInput'); | const gapToleranceInput = qs('gapToleranceInput'); | ||||
| const edgeMarginInput = qs('edgeMarginInput'); | const edgeMarginInput = qs('edgeMarginInput'); | ||||
| const mergeGapInput = qs('mergeGapInput'); | |||||
| const agcToggle = qs('agcToggle'); | const agcToggle = qs('agcToggle'); | ||||
| const dcToggle = qs('dcToggle'); | const dcToggle = qs('dcToggle'); | ||||
| const iqToggle = qs('iqToggle'); | const iqToggle = qs('iqToggle'); | ||||
| @@ -420,6 +421,7 @@ function applyConfigToUI(cfg) { | |||||
| if (stableFramesInput) stableFramesInput.value = cfg.detector.min_stable_frames ?? 3; | if (stableFramesInput) stableFramesInput.value = cfg.detector.min_stable_frames ?? 3; | ||||
| if (gapToleranceInput) gapToleranceInput.value = cfg.detector.gap_tolerance_ms ?? cfg.detector.hold_ms; | 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 (edgeMarginInput) edgeMarginInput.value = cfg.detector.edge_margin_db ?? 3.0; | ||||
| if (mergeGapInput) mergeGapInput.value = cfg.detector.merge_gap_hz ?? 5000; | |||||
| agcToggle.checked = !!cfg.agc; | agcToggle.checked = !!cfg.agc; | ||||
| dcToggle.checked = !!cfg.dc_block; | dcToggle.checked = !!cfg.dc_block; | ||||
| iqToggle.checked = !!cfg.iq_balance; | iqToggle.checked = !!cfg.iq_balance; | ||||
| @@ -1422,6 +1424,10 @@ if (edgeMarginInput) edgeMarginInput.addEventListener('change', () => { | |||||
| const v = parseFloat(edgeMarginInput.value); | const v = parseFloat(edgeMarginInput.value); | ||||
| if (Number.isFinite(v)) queueConfigUpdate({ detector: { edge_margin_db: v } }); | 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 })); | agcToggle.addEventListener('change', () => queueSettingsUpdate({ agc: agcToggle.checked })); | ||||
| dcToggle.addEventListener('change', () => queueSettingsUpdate({ dc_block: dcToggle.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>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>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>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> | <label class="field"><span>CFAR Mode</span> | ||||
| <select id="cfarModeSelect"> | <select id="cfarModeSelect"> | ||||
| <option value="OFF">Off</option> | <option value="OFF">Off</option> | ||||