diff --git a/cmd/sdrd/http_handlers.go b/cmd/sdrd/http_handlers.go index 1e338a8..ad0499f 100644 --- a/cmd/sdrd/http_handlers.go +++ b/cmd/sdrd/http_handlers.go @@ -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 || diff --git a/internal/config/config.go b/internal/config/config.go index db95927..1971698 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/detector/detector.go b/internal/detector/detector.go index a5fed3f..6d4f354 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -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 } diff --git a/internal/detector/detector_test.go b/internal/detector/detector_test.go index fd04acf..c6373c7 100644 --- a/internal/detector/detector_test.go +++ b/internal/detector/detector_test.go @@ -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 +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 447d94d..3f79848 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -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") diff --git a/web/app.js b/web/app.js index 0a92394..fcf5345 100644 --- a/web/app.js +++ b/web/app.js @@ -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); diff --git a/web/index.html b/web/index.html index c83d21f..5fcd55e 100644 --- a/web/index.html +++ b/web/index.html @@ -187,8 +187,8 @@ Threshold
- - + +