| @@ -57,8 +57,8 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime | |||||
| prev.Detector.MinStableFrames != next.Detector.MinStableFrames || | prev.Detector.MinStableFrames != next.Detector.MinStableFrames || | ||||
| prev.Detector.GapToleranceMs != next.Detector.GapToleranceMs || | prev.Detector.GapToleranceMs != next.Detector.GapToleranceMs || | ||||
| prev.Detector.CFARMode != next.Detector.CFARMode || | 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.CFARRank != next.Detector.CFARRank || | ||||
| prev.Detector.CFARScaleDb != next.Detector.CFARScaleDb || | prev.Detector.CFARScaleDb != next.Detector.CFARScaleDb || | ||||
| prev.Detector.CFARWrapAround != next.Detector.CFARWrapAround || | prev.Detector.CFARWrapAround != next.Detector.CFARWrapAround || | ||||
| @@ -23,8 +23,10 @@ type DetectorConfig struct { | |||||
| MinStableFrames int `yaml:"min_stable_frames" json:"min_stable_frames"` | MinStableFrames int `yaml:"min_stable_frames" json:"min_stable_frames"` | ||||
| GapToleranceMs int `yaml:"gap_tolerance_ms" json:"gap_tolerance_ms"` | GapToleranceMs int `yaml:"gap_tolerance_ms" json:"gap_tolerance_ms"` | ||||
| CFARMode string `yaml:"cfar_mode" json:"cfar_mode"` | 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"` | CFARRank int `yaml:"cfar_rank" json:"cfar_rank"` | ||||
| CFARScaleDb float64 `yaml:"cfar_scale_db" json:"cfar_scale_db"` | CFARScaleDb float64 `yaml:"cfar_scale_db" json:"cfar_scale_db"` | ||||
| CFARWrapAround bool `yaml:"cfar_wrap_around" json:"cfar_wrap_around"` | CFARWrapAround bool `yaml:"cfar_wrap_around" json:"cfar_wrap_around"` | ||||
| @@ -105,6 +107,8 @@ func Default() Config { | |||||
| MinStableFrames: 3, | MinStableFrames: 3, | ||||
| GapToleranceMs: 500, | GapToleranceMs: 500, | ||||
| CFARMode: "GOSCA", | CFARMode: "GOSCA", | ||||
| CFARGuardHz: 500, | |||||
| CFARTrainHz: 5000, | |||||
| CFARGuardCells: 3, | CFARGuardCells: 3, | ||||
| CFARTrainCells: 24, | CFARTrainCells: 24, | ||||
| CFARRank: 36, | CFARRank: 36, | ||||
| @@ -178,6 +182,18 @@ func applyDefaults(cfg Config) Config { | |||||
| cfg.Detector.CFARMode = "GOSCA" | 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 { | if cfg.Detector.CFARGuardCells <= 0 { | ||||
| cfg.Detector.CFARGuardCells = 3 | cfg.Detector.CFARGuardCells = 3 | ||||
| } | } | ||||
| @@ -80,8 +80,15 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { | |||||
| hysteresis := detCfg.HysteresisDb | hysteresis := detCfg.HysteresisDb | ||||
| minStable := detCfg.MinStableFrames | minStable := detCfg.MinStableFrames | ||||
| cfarMode := detCfg.CFARMode | 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 | cfarRank := detCfg.CFARRank | ||||
| cfarScaleDb := detCfg.CFARScaleDb | cfarScaleDb := detCfg.CFARScaleDb | ||||
| cfarWrap := detCfg.CFARWrapAround | cfarWrap := detCfg.CFARWrapAround | ||||
| @@ -108,12 +115,6 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { | |||||
| if gapTolerance <= 0 { | if gapTolerance <= 0 { | ||||
| gapTolerance = hold | gapTolerance = hold | ||||
| } | } | ||||
| if cfarGuard < 0 { | |||||
| cfarGuard = 2 | |||||
| } | |||||
| if cfarTrain <= 0 { | |||||
| cfarTrain = 16 | |||||
| } | |||||
| if cfarScaleDb <= 0 { | if cfarScaleDb <= 0 { | ||||
| cfarScaleDb = 6 | cfarScaleDb = 6 | ||||
| } | } | ||||
| @@ -137,3 +137,58 @@ func TestMergeGap(t *testing.T) { | |||||
| t.Errorf("merged BW too narrow: %.0f Hz", signals[0].BWHz) | 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"` | MinStableFrames *int `json:"min_stable_frames"` | ||||
| GapToleranceMs *int `json:"gap_tolerance_ms"` | GapToleranceMs *int `json:"gap_tolerance_ms"` | ||||
| CFARMode *string `json:"cfar_mode"` | CFARMode *string `json:"cfar_mode"` | ||||
| CFARGuardHz *float64 `json:"cfar_guard_hz"` | |||||
| CFARTrainHz *float64 `json:"cfar_train_hz"` | |||||
| CFARGuardCells *int `json:"cfar_guard_cells"` | CFARGuardCells *int `json:"cfar_guard_cells"` | ||||
| CFARTrainCells *int `json:"cfar_train_cells"` | CFARTrainCells *int `json:"cfar_train_cells"` | ||||
| CFARRank *int `json:"cfar_rank"` | CFARRank *int `json:"cfar_rank"` | ||||
| @@ -173,6 +175,18 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { | |||||
| if update.Detector.CFARWrapAround != nil { | if update.Detector.CFARWrapAround != nil { | ||||
| next.Detector.CFARWrapAround = *update.Detector.CFARWrapAround | 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 != nil { | ||||
| if *update.Detector.CFARGuardCells < 0 { | if *update.Detector.CFARGuardCells < 0 { | ||||
| return m.cfg, errors.New("cfar_guard_cells must be >= 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 thresholdInput = qs('thresholdInput'); | ||||
| const cfarModeSelect = qs('cfarModeSelect'); | const cfarModeSelect = qs('cfarModeSelect'); | ||||
| const cfarWrapToggle = qs('cfarWrapToggle'); | 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 cfarRankInput = qs('cfarRankInput'); | ||||
| const cfarScaleInput = qs('cfarScaleInput'); | const cfarScaleInput = qs('cfarScaleInput'); | ||||
| const minDurationInput = qs('minDurationInput'); | const minDurationInput = qs('minDurationInput'); | ||||
| @@ -408,8 +408,8 @@ function applyConfigToUI(cfg) { | |||||
| thresholdInput.value = cfg.detector.threshold_db; | thresholdInput.value = cfg.detector.threshold_db; | ||||
| if (cfarModeSelect) cfarModeSelect.value = cfg.detector.cfar_mode || 'OFF'; | if (cfarModeSelect) cfarModeSelect.value = cfg.detector.cfar_mode || 'OFF'; | ||||
| if (cfarWrapToggle) cfarWrapToggle.checked = cfg.detector.cfar_wrap_around !== false; | 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 (cfarRankInput) cfarRankInput.value = cfg.detector.cfar_rank ?? 24; | ||||
| if (cfarScaleInput) cfarScaleInput.value = cfg.detector.cfar_scale_db ?? 6; | if (cfarScaleInput) cfarScaleInput.value = cfg.detector.cfar_scale_db ?? 6; | ||||
| const rankRow = cfarRankInput?.closest('.field'); | const rankRow = cfarRankInput?.closest('.field'); | ||||
| @@ -651,8 +651,9 @@ function drawCfarEdgeOverlay(ctx, w, h, startHz, endHz) { | |||||
| const mode = currentConfig?.detector?.cfar_mode || 'OFF'; | const mode = currentConfig?.detector?.cfar_mode || 'OFF'; | ||||
| if (mode === 'OFF') return; | if (mode === 'OFF') return; | ||||
| if (currentConfig?.detector?.cfar_wrap_around) 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; | const bins = guard + train; | ||||
| if (bins <= 0) return; | if (bins <= 0) return; | ||||
| const fftSize = latest.fft_size || latest.spectrum_db?.length; | const fftSize = latest.fft_size || latest.spectrum_db?.length; | ||||
| @@ -1380,13 +1381,13 @@ if (cfarModeSelect) cfarModeSelect.addEventListener('change', () => { | |||||
| if (cfarWrapToggle) cfarWrapToggle.addEventListener('change', () => { | if (cfarWrapToggle) cfarWrapToggle.addEventListener('change', () => { | ||||
| queueConfigUpdate({ detector: { cfar_wrap_around: cfarWrapToggle.checked } }); | 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', () => { | if (cfarRankInput) cfarRankInput.addEventListener('change', () => { | ||||
| const v = parseInt(cfarRankInput.value, 10); | const v = parseInt(cfarRankInput.value, 10); | ||||
| @@ -187,8 +187,8 @@ | |||||
| <span>Threshold</span> | <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 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> | </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 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>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> | <label class="field"><span>Min Duration (ms)</span><input id="minDurationInput" type="number" step="50" min="50" /></label> | ||||