| @@ -57,8 +57,8 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime | |||
| prev.Detector.MinStableFrames != next.Detector.MinStableFrames || | |||
| prev.Detector.GapToleranceMs != next.Detector.GapToleranceMs || | |||
| prev.Detector.CFARMode != next.Detector.CFARMode || | |||
| prev.Detector.CFARGuardCells != next.Detector.CFARGuardCells || | |||
| prev.Detector.CFARTrainCells != next.Detector.CFARTrainCells || | |||
| prev.Detector.CFARGuardHz != next.Detector.CFARGuardHz || | |||
| prev.Detector.CFARTrainHz != next.Detector.CFARTrainHz || | |||
| prev.Detector.CFARRank != next.Detector.CFARRank || | |||
| prev.Detector.CFARScaleDb != next.Detector.CFARScaleDb || | |||
| prev.Detector.CFARWrapAround != next.Detector.CFARWrapAround || | |||
| @@ -23,8 +23,10 @@ type DetectorConfig struct { | |||
| MinStableFrames int `yaml:"min_stable_frames" json:"min_stable_frames"` | |||
| GapToleranceMs int `yaml:"gap_tolerance_ms" json:"gap_tolerance_ms"` | |||
| CFARMode string `yaml:"cfar_mode" json:"cfar_mode"` | |||
| CFARGuardCells int `yaml:"cfar_guard_cells" json:"cfar_guard_cells"` | |||
| CFARTrainCells int `yaml:"cfar_train_cells" json:"cfar_train_cells"` | |||
| CFARGuardHz float64 `yaml:"cfar_guard_hz" json:"cfar_guard_hz"` | |||
| CFARTrainHz float64 `yaml:"cfar_train_hz" json:"cfar_train_hz"` | |||
| CFARGuardCells int `yaml:"cfar_guard_cells,omitempty" json:"cfar_guard_cells,omitempty"` | |||
| CFARTrainCells int `yaml:"cfar_train_cells,omitempty" json:"cfar_train_cells,omitempty"` | |||
| 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"` | |||
| @@ -105,6 +107,8 @@ func Default() Config { | |||
| MinStableFrames: 3, | |||
| GapToleranceMs: 500, | |||
| CFARMode: "GOSCA", | |||
| CFARGuardHz: 500, | |||
| CFARTrainHz: 5000, | |||
| CFARGuardCells: 3, | |||
| CFARTrainCells: 24, | |||
| CFARRank: 36, | |||
| @@ -178,6 +182,18 @@ func applyDefaults(cfg Config) Config { | |||
| cfg.Detector.CFARMode = "GOSCA" | |||
| } | |||
| } | |||
| if cfg.Detector.CFARGuardHz <= 0 && cfg.Detector.CFARGuardCells > 0 { | |||
| cfg.Detector.CFARGuardHz = float64(cfg.Detector.CFARGuardCells) * 62.5 | |||
| } | |||
| if cfg.Detector.CFARTrainHz <= 0 && cfg.Detector.CFARTrainCells > 0 { | |||
| cfg.Detector.CFARTrainHz = float64(cfg.Detector.CFARTrainCells) * 62.5 | |||
| } | |||
| if cfg.Detector.CFARGuardHz <= 0 { | |||
| cfg.Detector.CFARGuardHz = 500 | |||
| } | |||
| if cfg.Detector.CFARTrainHz <= 0 { | |||
| cfg.Detector.CFARTrainHz = 5000 | |||
| } | |||
| if cfg.Detector.CFARGuardCells <= 0 { | |||
| cfg.Detector.CFARGuardCells = 3 | |||
| } | |||
| @@ -80,8 +80,15 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { | |||
| hysteresis := detCfg.HysteresisDb | |||
| minStable := detCfg.MinStableFrames | |||
| cfarMode := detCfg.CFARMode | |||
| cfarGuard := detCfg.CFARGuardCells | |||
| cfarTrain := detCfg.CFARTrainCells | |||
| binWidth := float64(sampleRate) / float64(fftSize) | |||
| cfarGuard := int(math.Ceil(detCfg.CFARGuardHz / binWidth)) | |||
| if cfarGuard < 0 { | |||
| cfarGuard = 0 | |||
| } | |||
| cfarTrain := int(math.Ceil(detCfg.CFARTrainHz / binWidth)) | |||
| if cfarTrain < 1 { | |||
| cfarTrain = 1 | |||
| } | |||
| cfarRank := detCfg.CFARRank | |||
| cfarScaleDb := detCfg.CFARScaleDb | |||
| cfarWrap := detCfg.CFARWrapAround | |||
| @@ -108,12 +115,6 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { | |||
| if gapTolerance <= 0 { | |||
| gapTolerance = hold | |||
| } | |||
| if cfarGuard < 0 { | |||
| cfarGuard = 2 | |||
| } | |||
| if cfarTrain <= 0 { | |||
| cfarTrain = 16 | |||
| } | |||
| if cfarScaleDb <= 0 { | |||
| cfarScaleDb = 6 | |||
| } | |||
| @@ -137,3 +137,58 @@ func TestMergeGap(t *testing.T) { | |||
| t.Errorf("merged BW too narrow: %.0f Hz", signals[0].BWHz) | |||
| } | |||
| } | |||
| func TestGuardTrainHzScaling(t *testing.T) { | |||
| sampleRate := 2048000 | |||
| guardHz := 500.0 | |||
| trainHz := 5000.0 | |||
| cfg := config.DetectorConfig{ | |||
| ThresholdDb: -20, | |||
| MinDurationMs: 1, | |||
| HoldMs: 10, | |||
| EmaAlpha: 1.0, | |||
| HysteresisDb: 3, | |||
| MinStableFrames: 1, | |||
| GapToleranceMs: 10, | |||
| CFARMode: "OFF", | |||
| CFARGuardHz: guardHz, | |||
| CFARTrainHz: trainHz, | |||
| CFARScaleDb: 6, | |||
| CFARWrapAround: true, | |||
| } | |||
| d1 := New(cfg, sampleRate, 2048) | |||
| d2 := New(cfg, sampleRate, 65536) | |||
| spec2048 := makeSignalSpectrum(2048, sampleRate, 500e3, 12e3, -20, -100) | |||
| spec65536 := makeSignalSpectrum(65536, sampleRate, 500e3, 12e3, -20, -100) | |||
| now := time.Now() | |||
| _, sig1 := d1.Process(now, spec2048, 434e6) | |||
| _, sig2 := d2.Process(now, spec65536, 434e6) | |||
| if len(sig1) == 0 || len(sig2) == 0 { | |||
| t.Fatalf("detection failed: fft2048=%d signals, fft65536=%d signals", len(sig1), len(sig2)) | |||
| } | |||
| bw1 := sig1[0].BWHz | |||
| bw2 := sig2[0].BWHz | |||
| ratio := bw1 / bw2 | |||
| if ratio < 0.8 || ratio > 1.2 { | |||
| t.Errorf("BW mismatch across FFT sizes: fft2048=%.0f Hz, fft65536=%.0f Hz", bw1, bw2) | |||
| } | |||
| } | |||
| func makeSignalSpectrum(fftSize int, sampleRate int, offsetHz float64, bwHz float64, signalDb float64, noiseDb float64) []float64 { | |||
| spectrum := make([]float64, fftSize) | |||
| for i := range spectrum { | |||
| spectrum[i] = noiseDb | |||
| } | |||
| binWidth := float64(sampleRate) / float64(fftSize) | |||
| centerBin := int(float64(fftSize)/2 + offsetHz/binWidth) | |||
| halfBins := int((bwHz / 2) / binWidth) | |||
| if halfBins < 1 { | |||
| halfBins = 1 | |||
| } | |||
| for i := centerBin - halfBins; i <= centerBin+halfBins; i++ { | |||
| if i >= 0 && i < fftSize { | |||
| spectrum[i] = signalDb | |||
| } | |||
| } | |||
| return spectrum | |||
| } | |||
| @@ -29,6 +29,8 @@ type DetectorUpdate struct { | |||
| MinStableFrames *int `json:"min_stable_frames"` | |||
| GapToleranceMs *int `json:"gap_tolerance_ms"` | |||
| CFARMode *string `json:"cfar_mode"` | |||
| CFARGuardHz *float64 `json:"cfar_guard_hz"` | |||
| CFARTrainHz *float64 `json:"cfar_train_hz"` | |||
| CFARGuardCells *int `json:"cfar_guard_cells"` | |||
| CFARTrainCells *int `json:"cfar_train_cells"` | |||
| CFARRank *int `json:"cfar_rank"` | |||
| @@ -173,6 +175,18 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { | |||
| if update.Detector.CFARWrapAround != nil { | |||
| next.Detector.CFARWrapAround = *update.Detector.CFARWrapAround | |||
| } | |||
| if update.Detector.CFARGuardHz != nil { | |||
| if *update.Detector.CFARGuardHz < 0 { | |||
| return m.cfg, errors.New("cfar_guard_hz must be >= 0") | |||
| } | |||
| next.Detector.CFARGuardHz = *update.Detector.CFARGuardHz | |||
| } | |||
| if update.Detector.CFARTrainHz != nil { | |||
| if *update.Detector.CFARTrainHz <= 0 { | |||
| return m.cfg, errors.New("cfar_train_hz must be > 0") | |||
| } | |||
| next.Detector.CFARTrainHz = *update.Detector.CFARTrainHz | |||
| } | |||
| if update.Detector.CFARGuardCells != nil { | |||
| if *update.Detector.CFARGuardCells < 0 { | |||
| return m.cfg, errors.New("cfar_guard_cells must be >= 0") | |||
| @@ -32,8 +32,8 @@ const thresholdRange = qs('thresholdRange'); | |||
| const thresholdInput = qs('thresholdInput'); | |||
| const cfarModeSelect = qs('cfarModeSelect'); | |||
| const cfarWrapToggle = qs('cfarWrapToggle'); | |||
| const cfarGuardInput = qs('cfarGuardInput'); | |||
| const cfarTrainInput = qs('cfarTrainInput'); | |||
| const cfarGuardHzInput = qs('cfarGuardHzInput'); | |||
| const cfarTrainHzInput = qs('cfarTrainHzInput'); | |||
| const cfarRankInput = qs('cfarRankInput'); | |||
| const cfarScaleInput = qs('cfarScaleInput'); | |||
| const minDurationInput = qs('minDurationInput'); | |||
| @@ -408,8 +408,8 @@ function applyConfigToUI(cfg) { | |||
| thresholdInput.value = cfg.detector.threshold_db; | |||
| if (cfarModeSelect) cfarModeSelect.value = cfg.detector.cfar_mode || 'OFF'; | |||
| if (cfarWrapToggle) cfarWrapToggle.checked = cfg.detector.cfar_wrap_around !== false; | |||
| if (cfarGuardInput) cfarGuardInput.value = cfg.detector.cfar_guard_cells ?? 2; | |||
| if (cfarTrainInput) cfarTrainInput.value = cfg.detector.cfar_train_cells ?? 16; | |||
| if (cfarGuardHzInput) cfarGuardHzInput.value = cfg.detector.cfar_guard_hz ?? 500; | |||
| if (cfarTrainHzInput) cfarTrainHzInput.value = cfg.detector.cfar_train_hz ?? 5000; | |||
| if (cfarRankInput) cfarRankInput.value = cfg.detector.cfar_rank ?? 24; | |||
| if (cfarScaleInput) cfarScaleInput.value = cfg.detector.cfar_scale_db ?? 6; | |||
| const rankRow = cfarRankInput?.closest('.field'); | |||
| @@ -651,8 +651,9 @@ function drawCfarEdgeOverlay(ctx, w, h, startHz, endHz) { | |||
| const mode = currentConfig?.detector?.cfar_mode || 'OFF'; | |||
| if (mode === 'OFF') return; | |||
| if (currentConfig?.detector?.cfar_wrap_around) return; | |||
| const guard = currentConfig.detector.cfar_guard_cells ?? 0; | |||
| const train = currentConfig.detector.cfar_train_cells ?? 0; | |||
| const binWidth = (currentConfig.sample_rate || 2048000) / (latest.fft_size || latest.spectrum_db?.length || 32768); | |||
| const guard = Math.ceil((currentConfig.detector.cfar_guard_hz ?? 500) / binWidth); | |||
| const train = Math.ceil((currentConfig.detector.cfar_train_hz ?? 5000) / binWidth); | |||
| const bins = guard + train; | |||
| if (bins <= 0) return; | |||
| const fftSize = latest.fft_size || latest.spectrum_db?.length; | |||
| @@ -1380,13 +1381,13 @@ if (cfarModeSelect) cfarModeSelect.addEventListener('change', () => { | |||
| if (cfarWrapToggle) cfarWrapToggle.addEventListener('change', () => { | |||
| queueConfigUpdate({ detector: { cfar_wrap_around: cfarWrapToggle.checked } }); | |||
| }); | |||
| if (cfarGuardInput) cfarGuardInput.addEventListener('change', () => { | |||
| const v = parseInt(cfarGuardInput.value, 10); | |||
| if (Number.isFinite(v)) queueConfigUpdate({ detector: { cfar_guard_cells: v } }); | |||
| if (cfarGuardHzInput) cfarGuardHzInput.addEventListener('change', () => { | |||
| const v = parseFloat(cfarGuardHzInput.value); | |||
| if (Number.isFinite(v) && v >= 0) queueConfigUpdate({ detector: { cfar_guard_hz: v } }); | |||
| }); | |||
| if (cfarTrainInput) cfarTrainInput.addEventListener('change', () => { | |||
| const v = parseInt(cfarTrainInput.value, 10); | |||
| if (Number.isFinite(v)) queueConfigUpdate({ detector: { cfar_train_cells: v } }); | |||
| if (cfarTrainHzInput) cfarTrainHzInput.addEventListener('change', () => { | |||
| const v = parseFloat(cfarTrainHzInput.value); | |||
| if (Number.isFinite(v) && v > 0) queueConfigUpdate({ detector: { cfar_train_hz: v } }); | |||
| }); | |||
| if (cfarRankInput) cfarRankInput.addEventListener('change', () => { | |||
| const v = parseInt(cfarRankInput.value, 10); | |||
| @@ -187,8 +187,8 @@ | |||
| <span>Threshold</span> | |||
| <div class="slider-row"><input id="thresholdRange" type="range" min="-120" max="0" step="1" class="range--warn" /><input id="thresholdInput" type="number" min="-120" max="0" step="1" class="slider-num" /><em>dB</em></div> | |||
| </div> | |||
| <label class="field"><span>CFAR Guard Cells</span><input id="cfarGuardInput" type="number" step="1" min="0" /></label> | |||
| <label class="field"><span>CFAR Train Cells</span><input id="cfarTrainInput" type="number" step="1" min="1" /></label> | |||
| <label class="field"><span>CFAR Guard (Hz)</span><input id="cfarGuardHzInput" type="number" step="100" min="0" /></label> | |||
| <label class="field"><span>CFAR Train (Hz)</span><input id="cfarTrainHzInput" type="number" step="500" min="100" /></label> | |||
| <label class="field"><span>CFAR Rank</span><input id="cfarRankInput" type="number" step="1" min="1" /></label> | |||
| <label class="field"><span>CFAR Scale (dB)</span><input id="cfarScaleInput" type="number" step="0.5" min="0" /></label> | |||
| <label class="field"><span>Min Duration (ms)</span><input id="minDurationInput" type="number" step="50" min="50" /></label> | |||