| @@ -319,11 +319,12 @@ func main() { | |||||
| cfg.Detector.HysteresisDb, | cfg.Detector.HysteresisDb, | ||||
| cfg.Detector.MinStableFrames, | cfg.Detector.MinStableFrames, | ||||
| time.Duration(cfg.Detector.GapToleranceMs)*time.Millisecond, | time.Duration(cfg.Detector.GapToleranceMs)*time.Millisecond, | ||||
| cfg.Detector.CFAREnabled, | |||||
| cfg.Detector.CFARMode, | |||||
| cfg.Detector.CFARGuardCells, | cfg.Detector.CFARGuardCells, | ||||
| cfg.Detector.CFARTrainCells, | cfg.Detector.CFARTrainCells, | ||||
| cfg.Detector.CFARRank, | cfg.Detector.CFARRank, | ||||
| cfg.Detector.CFARScaleDb) | |||||
| cfg.Detector.CFARScaleDb, | |||||
| cfg.Detector.CFARWrapAround) | |||||
| window := fftutil.Hann(cfg.FFTSize) | window := fftutil.Hann(cfg.FFTSize) | ||||
| h := newHub() | h := newHub() | ||||
| @@ -443,11 +444,12 @@ func main() { | |||||
| prev.Detector.HysteresisDb != next.Detector.HysteresisDb || | prev.Detector.HysteresisDb != next.Detector.HysteresisDb || | ||||
| 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.CFAREnabled != next.Detector.CFAREnabled || | |||||
| prev.Detector.CFARMode != next.Detector.CFARMode || | |||||
| prev.Detector.CFARGuardCells != next.Detector.CFARGuardCells || | prev.Detector.CFARGuardCells != next.Detector.CFARGuardCells || | ||||
| prev.Detector.CFARTrainCells != next.Detector.CFARTrainCells || | prev.Detector.CFARTrainCells != next.Detector.CFARTrainCells || | ||||
| 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.SampleRate != next.SampleRate || | prev.SampleRate != next.SampleRate || | ||||
| prev.FFTSize != next.FFTSize | prev.FFTSize != next.FFTSize | ||||
| windowChanged := prev.FFTSize != next.FFTSize | windowChanged := prev.FFTSize != next.FFTSize | ||||
| @@ -461,11 +463,12 @@ func main() { | |||||
| next.Detector.HysteresisDb, | next.Detector.HysteresisDb, | ||||
| next.Detector.MinStableFrames, | next.Detector.MinStableFrames, | ||||
| time.Duration(next.Detector.GapToleranceMs)*time.Millisecond, | time.Duration(next.Detector.GapToleranceMs)*time.Millisecond, | ||||
| next.Detector.CFAREnabled, | |||||
| next.Detector.CFARMode, | |||||
| next.Detector.CFARGuardCells, | next.Detector.CFARGuardCells, | ||||
| next.Detector.CFARTrainCells, | next.Detector.CFARTrainCells, | ||||
| next.Detector.CFARRank, | next.Detector.CFARRank, | ||||
| next.Detector.CFARScaleDb) | |||||
| next.Detector.CFARScaleDb, | |||||
| next.Detector.CFARWrapAround) | |||||
| } | } | ||||
| if windowChanged { | if windowChanged { | ||||
| newWindow = fftutil.Hann(next.FFTSize) | newWindow = fftutil.Hann(next.FFTSize) | ||||
| @@ -0,0 +1,72 @@ | |||||
| package cfar | |||||
| // cellAvg implements CA-CFAR with a sliding sum window. | |||||
| type cellAvg struct { | |||||
| guard int | |||||
| train int | |||||
| scaleDb float64 | |||||
| wrapAround bool | |||||
| } | |||||
| func newCA(cfg Config) CFAR { | |||||
| return &cellAvg{ | |||||
| guard: cfg.GuardCells, | |||||
| train: cfg.TrainCells, | |||||
| scaleDb: cfg.ScaleDb, | |||||
| wrapAround: cfg.WrapAround, | |||||
| } | |||||
| } | |||||
| func (c *cellAvg) Thresholds(spectrum []float64) []float64 { | |||||
| n := len(spectrum) | |||||
| if n == 0 { | |||||
| return nil | |||||
| } | |||||
| out := make([]float64, n) | |||||
| train := c.train | |||||
| guard := c.guard | |||||
| total := 2 * train | |||||
| if total == 0 { | |||||
| return out | |||||
| } | |||||
| at := func(i int) float64 { | |||||
| if c.wrapAround { | |||||
| return spectrum[((i%n)+n)%n] | |||||
| } | |||||
| if i < 0 || i >= n { | |||||
| return spectrum[clampInt(i, 0, n-1)] | |||||
| } | |||||
| return spectrum[i] | |||||
| } | |||||
| var leftSum, rightSum float64 | |||||
| for k := 1; k <= train; k++ { | |||||
| leftSum += at(0 - guard - k) | |||||
| rightSum += at(0 + guard + k) | |||||
| } | |||||
| invN := 1.0 / float64(total) | |||||
| out[0] = (leftSum+rightSum)*invN + c.scaleDb | |||||
| for i := 1; i < n; i++ { | |||||
| leftSum -= at(i - 1 - guard - train) | |||||
| leftSum += at(i - guard - 1) | |||||
| rightSum -= at(i - 1 + guard + 1) | |||||
| rightSum += at(i + guard + train) | |||||
| out[i] = (leftSum+rightSum)*invN + c.scaleDb | |||||
| } | |||||
| return out | |||||
| } | |||||
| func clampInt(v, lo, hi int) int { | |||||
| if v < lo { | |||||
| return lo | |||||
| } | |||||
| if v > hi { | |||||
| return hi | |||||
| } | |||||
| return v | |||||
| } | |||||
| @@ -0,0 +1,64 @@ | |||||
| package cfar | |||||
| type caso struct { | |||||
| guard int | |||||
| train int | |||||
| scaleDb float64 | |||||
| wrapAround bool | |||||
| } | |||||
| func newCASO(cfg Config) CFAR { | |||||
| return &caso{guard: cfg.GuardCells, train: cfg.TrainCells, scaleDb: cfg.ScaleDb, wrapAround: cfg.WrapAround} | |||||
| } | |||||
| func (c *caso) Thresholds(spectrum []float64) []float64 { | |||||
| n := len(spectrum) | |||||
| if n == 0 { | |||||
| return nil | |||||
| } | |||||
| out := make([]float64, n) | |||||
| train := c.train | |||||
| guard := c.guard | |||||
| if train == 0 { | |||||
| return out | |||||
| } | |||||
| inv := 1.0 / float64(train) | |||||
| at := func(i int) float64 { | |||||
| if c.wrapAround { | |||||
| return spectrum[((i%n)+n)%n] | |||||
| } | |||||
| if i < 0 || i >= n { | |||||
| return spectrum[clampInt(i, 0, n-1)] | |||||
| } | |||||
| return spectrum[i] | |||||
| } | |||||
| var leftSum, rightSum float64 | |||||
| for k := 1; k <= train; k++ { | |||||
| leftSum += at(0 - guard - k) | |||||
| rightSum += at(0 + guard + k) | |||||
| } | |||||
| lm := leftSum * inv | |||||
| rm := rightSum * inv | |||||
| noise := lm | |||||
| if rm < noise { | |||||
| noise = rm | |||||
| } | |||||
| out[0] = noise + c.scaleDb | |||||
| for i := 1; i < n; i++ { | |||||
| leftSum -= at(i - 1 - guard - train) | |||||
| leftSum += at(i - guard - 1) | |||||
| rightSum -= at(i - 1 + guard + 1) | |||||
| rightSum += at(i + guard + train) | |||||
| lm = leftSum * inv | |||||
| rm = rightSum * inv | |||||
| noise = lm | |||||
| if rm < noise { | |||||
| noise = rm | |||||
| } | |||||
| out[i] = noise + c.scaleDb | |||||
| } | |||||
| return out | |||||
| } | |||||
| @@ -0,0 +1,27 @@ | |||||
| package cfar | |||||
| // New creates a CFAR detector for the given mode. | |||||
| // Returns nil if mode is ModeOff or empty. | |||||
| func New(cfg Config) CFAR { | |||||
| if cfg.TrainCells <= 0 { | |||||
| return nil | |||||
| } | |||||
| if cfg.GuardCells < 0 { | |||||
| cfg.GuardCells = 0 | |||||
| } | |||||
| if cfg.ScaleDb <= 0 { | |||||
| cfg.ScaleDb = 6 | |||||
| } | |||||
| switch cfg.Mode { | |||||
| case ModeCA: | |||||
| return newCA(cfg) | |||||
| case ModeOS: | |||||
| return newOS(cfg) | |||||
| case ModeGOSCA: | |||||
| return newGOSCA(cfg) | |||||
| case ModeCASO: | |||||
| return newCASO(cfg) | |||||
| default: | |||||
| return nil | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,73 @@ | |||||
| package cfar | |||||
| import "testing" | |||||
| func makeSpectrum(n int, noiseDb float64, signals [][2]int, sigDb float64) []float64 { | |||||
| s := make([]float64, n) | |||||
| for i := range s { | |||||
| s[i] = noiseDb | |||||
| } | |||||
| for _, sig := range signals { | |||||
| for i := sig[0]; i <= sig[1] && i < n; i++ { | |||||
| s[i] = sigDb | |||||
| } | |||||
| } | |||||
| return s | |||||
| } | |||||
| func TestAllVariantsDetectSignal(t *testing.T) { | |||||
| spec := makeSpectrum(1024, -100, [][2]int{{500, 510}}, -20) | |||||
| for _, mode := range []Mode{ModeCA, ModeOS, ModeGOSCA, ModeCASO} { | |||||
| c := New(Config{Mode: mode, GuardCells: 2, TrainCells: 16, Rank: 24, ScaleDb: 6, WrapAround: true}) | |||||
| if c == nil { | |||||
| t.Fatalf("%s: nil", mode) | |||||
| } | |||||
| th := c.Thresholds(spec) | |||||
| if len(th) != 1024 { | |||||
| t.Fatalf("%s: len=%d", mode, len(th)) | |||||
| } | |||||
| if spec[505] < th[505] { | |||||
| t.Fatalf("%s: signal not above threshold", mode) | |||||
| } | |||||
| if spec[200] >= th[200] { | |||||
| t.Fatalf("%s: noise above threshold", mode) | |||||
| } | |||||
| } | |||||
| } | |||||
| func TestWrapAroundEdges(t *testing.T) { | |||||
| spec := makeSpectrum(256, -100, [][2]int{{0, 5}}, -20) | |||||
| c := New(Config{Mode: ModeCA, GuardCells: 2, TrainCells: 8, ScaleDb: 6, WrapAround: true}) | |||||
| th := c.Thresholds(spec) | |||||
| if th[0] <= -200 || th[0] > 0 { | |||||
| t.Fatalf("edge threshold bad: %v", th[0]) | |||||
| } | |||||
| if th[255] <= -200 || th[255] > 0 { | |||||
| t.Fatalf("wrap threshold bad: %v", th[255]) | |||||
| } | |||||
| } | |||||
| func TestGOSCAMaskingProtection(t *testing.T) { | |||||
| spec := makeSpectrum(1024, -100, [][2]int{{500, 510}, {530, 540}}, -20) | |||||
| cGosca := New(Config{Mode: ModeGOSCA, GuardCells: 2, TrainCells: 16, ScaleDb: 6, WrapAround: true}) | |||||
| cCA := New(Config{Mode: ModeCA, GuardCells: 2, TrainCells: 16, ScaleDb: 6, WrapAround: true}) | |||||
| thG := cGosca.Thresholds(spec) | |||||
| thC := cCA.Thresholds(spec) | |||||
| midBin := 520 | |||||
| if thG[midBin] < thC[midBin] { | |||||
| t.Logf("GOSCA=%f CA=%f at bin %d — GOSCA correctly higher", thG[midBin], thC[midBin], midBin) | |||||
| } | |||||
| } | |||||
| func BenchmarkCFAR(b *testing.B) { | |||||
| spec := makeSpectrum(2048, -100, [][2]int{{500, 510}, {1000, 1020}}, -20) | |||||
| for _, mode := range []Mode{ModeCA, ModeOS, ModeGOSCA, ModeCASO} { | |||||
| cfg := Config{Mode: mode, GuardCells: 2, TrainCells: 16, Rank: 24, ScaleDb: 6, WrapAround: true} | |||||
| c := New(cfg) | |||||
| b.Run(string(mode), func(b *testing.B) { | |||||
| for i := 0; i < b.N; i++ { | |||||
| c.Thresholds(spec) | |||||
| } | |||||
| }) | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,72 @@ | |||||
| package cfar | |||||
| // gosca implements Greatest-Of Selection with Cell Averaging. | |||||
| type gosca struct { | |||||
| guard int | |||||
| train int | |||||
| scaleDb float64 | |||||
| wrapAround bool | |||||
| } | |||||
| func newGOSCA(cfg Config) CFAR { | |||||
| return &gosca{ | |||||
| guard: cfg.GuardCells, | |||||
| train: cfg.TrainCells, | |||||
| scaleDb: cfg.ScaleDb, | |||||
| wrapAround: cfg.WrapAround, | |||||
| } | |||||
| } | |||||
| func (g *gosca) Thresholds(spectrum []float64) []float64 { | |||||
| n := len(spectrum) | |||||
| if n == 0 { | |||||
| return nil | |||||
| } | |||||
| out := make([]float64, n) | |||||
| train := g.train | |||||
| guard := g.guard | |||||
| if train == 0 { | |||||
| return out | |||||
| } | |||||
| inv := 1.0 / float64(train) | |||||
| at := func(i int) float64 { | |||||
| if g.wrapAround { | |||||
| return spectrum[((i%n)+n)%n] | |||||
| } | |||||
| if i < 0 || i >= n { | |||||
| return spectrum[clampInt(i, 0, n-1)] | |||||
| } | |||||
| return spectrum[i] | |||||
| } | |||||
| var leftSum, rightSum float64 | |||||
| for k := 1; k <= train; k++ { | |||||
| leftSum += at(0 - guard - k) | |||||
| rightSum += at(0 + guard + k) | |||||
| } | |||||
| leftMean := leftSum * inv | |||||
| rightMean := rightSum * inv | |||||
| noise := leftMean | |||||
| if rightMean > noise { | |||||
| noise = rightMean | |||||
| } | |||||
| out[0] = noise + g.scaleDb | |||||
| for i := 1; i < n; i++ { | |||||
| leftSum -= at(i - 1 - guard - train) | |||||
| leftSum += at(i - guard - 1) | |||||
| rightSum -= at(i - 1 + guard + 1) | |||||
| rightSum += at(i + guard + train) | |||||
| leftMean = leftSum * inv | |||||
| rightMean = rightSum * inv | |||||
| noise = leftMean | |||||
| if rightMean > noise { | |||||
| noise = rightMean | |||||
| } | |||||
| out[i] = noise + g.scaleDb | |||||
| } | |||||
| return out | |||||
| } | |||||
| @@ -0,0 +1,101 @@ | |||||
| package cfar | |||||
| import "sort" | |||||
| type orderedStat struct { | |||||
| guard int | |||||
| train int | |||||
| rank int | |||||
| scaleDb float64 | |||||
| wrapAround bool | |||||
| } | |||||
| func newOS(cfg Config) CFAR { | |||||
| rank := cfg.Rank - 1 | |||||
| total := 2 * cfg.TrainCells | |||||
| if rank < 0 { | |||||
| rank = 0 | |||||
| } | |||||
| if rank >= total { | |||||
| rank = total - 1 | |||||
| } | |||||
| return &orderedStat{ | |||||
| guard: cfg.GuardCells, | |||||
| train: cfg.TrainCells, | |||||
| rank: rank, | |||||
| scaleDb: cfg.ScaleDb, | |||||
| wrapAround: cfg.WrapAround, | |||||
| } | |||||
| } | |||||
| func (o *orderedStat) Thresholds(spectrum []float64) []float64 { | |||||
| n := len(spectrum) | |||||
| if n == 0 { | |||||
| return nil | |||||
| } | |||||
| out := make([]float64, n) | |||||
| train := o.train | |||||
| guard := o.guard | |||||
| at := func(i int) float64 { | |||||
| if o.wrapAround { | |||||
| return spectrum[((i%n)+n)%n] | |||||
| } | |||||
| if i < 0 || i >= n { | |||||
| return spectrum[clampInt(i, 0, n-1)] | |||||
| } | |||||
| return spectrum[i] | |||||
| } | |||||
| win := make([]float64, 0, 2*train) | |||||
| for k := 1; k <= train; k++ { | |||||
| win = append(win, at(0-guard-k)) | |||||
| win = append(win, at(0+guard+k)) | |||||
| } | |||||
| sort.Float64s(win) | |||||
| out[0] = win[o.rank] + o.scaleDb | |||||
| for i := 1; i < n; i++ { | |||||
| removeFromSorted(&win, at(i-1-guard-train)) | |||||
| removeFromSorted(&win, at(i-1+guard+1)) | |||||
| insertSorted(&win, at(i-guard-1)) | |||||
| insertSorted(&win, at(i+guard+train)) | |||||
| out[i] = win[o.rank] + o.scaleDb | |||||
| } | |||||
| return out | |||||
| } | |||||
| func insertSorted(s *[]float64, v float64) { | |||||
| idx := sort.SearchFloat64s(*s, v) | |||||
| *s = append(*s, 0) | |||||
| copy((*s)[idx+1:], (*s)[idx:]) | |||||
| (*s)[idx] = v | |||||
| } | |||||
| func removeFromSorted(s *[]float64, v float64) { | |||||
| idx := sort.SearchFloat64s(*s, v) | |||||
| if idx < len(*s) && (*s)[idx] == v { | |||||
| *s = append((*s)[:idx], (*s)[idx+1:]...) | |||||
| return | |||||
| } | |||||
| for i := idx - 1; i >= 0; i-- { | |||||
| if (*s)[i] == v { | |||||
| *s = append((*s)[:i], (*s)[i+1:]...) | |||||
| return | |||||
| } | |||||
| if (*s)[i] < v { | |||||
| break | |||||
| } | |||||
| } | |||||
| for i := idx + 1; i < len(*s); i++ { | |||||
| if (*s)[i] == v { | |||||
| *s = append((*s)[:i], (*s)[i+1:]...) | |||||
| return | |||||
| } | |||||
| if (*s)[i] > v { | |||||
| break | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,30 @@ | |||||
| package cfar | |||||
| // Mode selects the CFAR algorithm variant. | |||||
| type Mode string | |||||
| const ( | |||||
| ModeOff Mode = "OFF" | |||||
| ModeCA Mode = "CA" | |||||
| ModeOS Mode = "OS" | |||||
| ModeGOSCA Mode = "GOSCA" | |||||
| ModeCASO Mode = "CASO" | |||||
| ) | |||||
| // Config holds all CFAR parameters. | |||||
| type Config struct { | |||||
| Mode Mode | |||||
| GuardCells int | |||||
| TrainCells int | |||||
| Rank int | |||||
| ScaleDb float64 | |||||
| WrapAround bool | |||||
| } | |||||
| // CFAR computes adaptive thresholds for a spectrum. | |||||
| type CFAR interface { | |||||
| // Thresholds returns per-bin detection thresholds in dB. | |||||
| // spectrum is power in dB, length n. | |||||
| // Returned slice has length n. No NaN values. | |||||
| Thresholds(spectrum []float64) []float64 | |||||
| } | |||||
| @@ -22,11 +22,15 @@ type DetectorConfig struct { | |||||
| HysteresisDb float64 `yaml:"hysteresis_db" json:"hysteresis_db"` | HysteresisDb float64 `yaml:"hysteresis_db" json:"hysteresis_db"` | ||||
| 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"` | ||||
| CFAREnabled bool `yaml:"cfar_enabled" json:"cfar_enabled"` | |||||
| CFARMode string `yaml:"cfar_mode" json:"cfar_mode"` | |||||
| CFARGuardCells int `yaml:"cfar_guard_cells" json:"cfar_guard_cells"` | CFARGuardCells int `yaml:"cfar_guard_cells" json:"cfar_guard_cells"` | ||||
| CFARTrainCells int `yaml:"cfar_train_cells" json:"cfar_train_cells"` | CFARTrainCells int `yaml:"cfar_train_cells" json:"cfar_train_cells"` | ||||
| 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"` | |||||
| // Deprecated (backward compatibility) | |||||
| CFAREnabled *bool `yaml:"cfar_enabled,omitempty" json:"cfar_enabled,omitempty"` | |||||
| } | } | ||||
| type RecorderConfig struct { | type RecorderConfig struct { | ||||
| @@ -89,7 +93,21 @@ func Default() Config { | |||||
| AGC: false, | AGC: false, | ||||
| DCBlock: false, | DCBlock: false, | ||||
| IQBalance: false, | IQBalance: false, | ||||
| Detector: DetectorConfig{ThresholdDb: -20, MinDurationMs: 250, HoldMs: 500, EmaAlpha: 0.2, HysteresisDb: 3, MinStableFrames: 3, GapToleranceMs: 500, CFAREnabled: true, CFARGuardCells: 2, CFARTrainCells: 16, CFARRank: 24, CFARScaleDb: 6}, | |||||
| Detector: DetectorConfig{ | |||||
| ThresholdDb: -20, | |||||
| MinDurationMs: 250, | |||||
| HoldMs: 500, | |||||
| EmaAlpha: 0.2, | |||||
| HysteresisDb: 3, | |||||
| MinStableFrames: 3, | |||||
| GapToleranceMs: 500, | |||||
| CFARMode: "GOSCA", | |||||
| CFARGuardCells: 3, | |||||
| CFARTrainCells: 24, | |||||
| CFARRank: 36, | |||||
| CFARScaleDb: 6, | |||||
| CFARWrapAround: true, | |||||
| }, | |||||
| Recorder: RecorderConfig{ | Recorder: RecorderConfig{ | ||||
| Enabled: false, | Enabled: false, | ||||
| MinSNRDb: 10, | MinSNRDb: 10, | ||||
| @@ -143,11 +161,22 @@ func applyDefaults(cfg Config) Config { | |||||
| if cfg.Detector.GapToleranceMs <= 0 { | if cfg.Detector.GapToleranceMs <= 0 { | ||||
| cfg.Detector.GapToleranceMs = cfg.Detector.HoldMs | cfg.Detector.GapToleranceMs = cfg.Detector.HoldMs | ||||
| } | } | ||||
| if cfg.Detector.CFARMode == "" { | |||||
| if cfg.Detector.CFAREnabled != nil { | |||||
| if *cfg.Detector.CFAREnabled { | |||||
| cfg.Detector.CFARMode = "OS" | |||||
| } else { | |||||
| cfg.Detector.CFARMode = "OFF" | |||||
| } | |||||
| } else { | |||||
| cfg.Detector.CFARMode = "GOSCA" | |||||
| } | |||||
| } | |||||
| if cfg.Detector.CFARGuardCells <= 0 { | if cfg.Detector.CFARGuardCells <= 0 { | ||||
| cfg.Detector.CFARGuardCells = 2 | |||||
| cfg.Detector.CFARGuardCells = 3 | |||||
| } | } | ||||
| if cfg.Detector.CFARTrainCells <= 0 { | if cfg.Detector.CFARTrainCells <= 0 { | ||||
| cfg.Detector.CFARTrainCells = 16 | |||||
| cfg.Detector.CFARTrainCells = 24 | |||||
| } | } | ||||
| if cfg.Detector.CFARRank <= 0 || cfg.Detector.CFARRank > 2*cfg.Detector.CFARTrainCells { | if cfg.Detector.CFARRank <= 0 || cfg.Detector.CFARRank > 2*cfg.Detector.CFARTrainCells { | ||||
| cfg.Detector.CFARRank = int(math.Round(0.75 * float64(2*cfg.Detector.CFARTrainCells))) | cfg.Detector.CFARRank = int(math.Round(0.75 * float64(2*cfg.Detector.CFARTrainCells))) | ||||
| @@ -5,6 +5,7 @@ import ( | |||||
| "sort" | "sort" | ||||
| "time" | "time" | ||||
| "sdr-visual-suite/internal/cfar" | |||||
| "sdr-visual-suite/internal/classifier" | "sdr-visual-suite/internal/classifier" | ||||
| ) | ) | ||||
| @@ -29,19 +30,15 @@ type Detector struct { | |||||
| HysteresisDb float64 | HysteresisDb float64 | ||||
| MinStableFrames int | MinStableFrames int | ||||
| GapTolerance time.Duration | GapTolerance time.Duration | ||||
| CFAREnabled bool | |||||
| CFARGuardCells int | |||||
| CFARTrainCells int | |||||
| CFARRank int | |||||
| CFARScaleDb float64 | CFARScaleDb float64 | ||||
| binWidth float64 | binWidth float64 | ||||
| nbins int | nbins int | ||||
| sampleRate int | sampleRate int | ||||
| ema []float64 | |||||
| active map[int64]*activeEvent | |||||
| nextID int64 | |||||
| ema []float64 | |||||
| active map[int64]*activeEvent | |||||
| nextID int64 | |||||
| cfarEngine cfar.CFAR | |||||
| } | } | ||||
| type activeEvent struct { | type activeEvent struct { | ||||
| @@ -68,7 +65,7 @@ type Signal struct { | |||||
| Class *classifier.Classification `json:"class,omitempty"` | Class *classifier.Classification `json:"class,omitempty"` | ||||
| } | } | ||||
| func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Duration, emaAlpha, hysteresis float64, minStable int, gapTolerance time.Duration, cfarEnabled bool, cfarGuard, cfarTrain, cfarRank int, cfarScaleDb float64) *Detector { | |||||
| func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Duration, emaAlpha, hysteresis float64, minStable int, gapTolerance time.Duration, cfarMode string, cfarGuard, cfarTrain, cfarRank int, cfarScaleDb float64, cfarWrap bool) *Detector { | |||||
| if minDur <= 0 { | if minDur <= 0 { | ||||
| minDur = 250 * time.Millisecond | minDur = 250 * time.Millisecond | ||||
| } | } | ||||
| @@ -102,6 +99,17 @@ func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Dur | |||||
| cfarRank = 1 | cfarRank = 1 | ||||
| } | } | ||||
| } | } | ||||
| var cfarEngine cfar.CFAR | |||||
| if cfarMode != "" && cfarMode != "OFF" { | |||||
| cfarEngine = cfar.New(cfar.Config{ | |||||
| Mode: cfar.Mode(cfarMode), | |||||
| GuardCells: cfarGuard, | |||||
| TrainCells: cfarTrain, | |||||
| Rank: cfarRank, | |||||
| ScaleDb: cfarScaleDb, | |||||
| WrapAround: cfarWrap, | |||||
| }) | |||||
| } | |||||
| return &Detector{ | return &Detector{ | ||||
| ThresholdDb: thresholdDb, | ThresholdDb: thresholdDb, | ||||
| MinDuration: minDur, | MinDuration: minDur, | ||||
| @@ -110,10 +118,6 @@ func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Dur | |||||
| HysteresisDb: hysteresis, | HysteresisDb: hysteresis, | ||||
| MinStableFrames: minStable, | MinStableFrames: minStable, | ||||
| GapTolerance: gapTolerance, | GapTolerance: gapTolerance, | ||||
| CFAREnabled: cfarEnabled, | |||||
| CFARGuardCells: cfarGuard, | |||||
| CFARTrainCells: cfarTrain, | |||||
| CFARRank: cfarRank, | |||||
| CFARScaleDb: cfarScaleDb, | CFARScaleDb: cfarScaleDb, | ||||
| binWidth: float64(sampleRate) / float64(fftSize), | binWidth: float64(sampleRate) / float64(fftSize), | ||||
| nbins: fftSize, | nbins: fftSize, | ||||
| @@ -121,6 +125,7 @@ func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Dur | |||||
| ema: make([]float64, fftSize), | ema: make([]float64, fftSize), | ||||
| active: map[int64]*activeEvent{}, | active: map[int64]*activeEvent{}, | ||||
| nextID: 1, | nextID: 1, | ||||
| cfarEngine: cfarEngine, | |||||
| } | } | ||||
| } | } | ||||
| @@ -151,7 +156,10 @@ func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal | |||||
| return nil | return nil | ||||
| } | } | ||||
| smooth := d.smoothSpectrum(spectrum) | smooth := d.smoothSpectrum(spectrum) | ||||
| thresholds := d.cfarThresholds(smooth) | |||||
| var thresholds []float64 | |||||
| if d.cfarEngine != nil { | |||||
| thresholds = d.cfarEngine.Thresholds(smooth) | |||||
| } | |||||
| noiseGlobal := median(smooth) | noiseGlobal := median(smooth) | ||||
| var signals []Signal | var signals []Signal | ||||
| in := false | in := false | ||||
| @@ -209,95 +217,6 @@ func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise | |||||
| } | } | ||||
| } | } | ||||
| func (d *Detector) cfarThresholds(spectrum []float64) []float64 { | |||||
| if !d.CFAREnabled || d.CFARTrainCells <= 0 { | |||||
| return nil | |||||
| } | |||||
| n := len(spectrum) | |||||
| train := d.CFARTrainCells | |||||
| guard := d.CFARGuardCells | |||||
| totalTrain := 2 * train | |||||
| if totalTrain <= 0 { | |||||
| return nil | |||||
| } | |||||
| rank := d.CFARRank | |||||
| if rank <= 0 || rank > totalTrain { | |||||
| rank = int(math.Round(0.75 * float64(totalTrain))) | |||||
| if rank <= 0 { | |||||
| rank = 1 | |||||
| } | |||||
| } | |||||
| rankIdx := rank - 1 | |||||
| thresholds := make([]float64, n) | |||||
| buf := make([]float64, 0, totalTrain) | |||||
| firstValid := guard + train | |||||
| lastValid := n - guard - train - 1 | |||||
| for i := 0; i < n; i++ { | |||||
| if i < firstValid || i > lastValid { | |||||
| thresholds[i] = math.NaN() | |||||
| } | |||||
| } | |||||
| if firstValid > lastValid { | |||||
| return thresholds | |||||
| } | |||||
| // Build initial sorted window for first valid bin. | |||||
| leftStart := firstValid - guard - train | |||||
| buf = append(buf, spectrum[leftStart:leftStart+train]...) | |||||
| buf = append(buf, spectrum[firstValid+guard+1:firstValid+guard+1+train]...) | |||||
| sort.Float64s(buf) | |||||
| thresholds[firstValid] = buf[rankIdx] + d.CFARScaleDb | |||||
| // Slide window: remove outgoing bins and insert incoming bins (O(train) per step). | |||||
| for i := firstValid + 1; i <= lastValid; i++ { | |||||
| outLeft := spectrum[i-guard-train-1] | |||||
| outRight := spectrum[i+guard] | |||||
| inLeft := spectrum[i-guard-1] | |||||
| inRight := spectrum[i+guard+train] | |||||
| buf = removeValue(buf, outLeft) | |||||
| buf = removeValue(buf, outRight) | |||||
| buf = insertValue(buf, inLeft) | |||||
| buf = insertValue(buf, inRight) | |||||
| thresholds[i] = buf[rankIdx] + d.CFARScaleDb | |||||
| } | |||||
| return thresholds | |||||
| } | |||||
| func removeValue(sorted []float64, v float64) []float64 { | |||||
| if len(sorted) == 0 { | |||||
| return sorted | |||||
| } | |||||
| idx := sort.SearchFloat64s(sorted, v) | |||||
| if idx < len(sorted) && sorted[idx] == v { | |||||
| return append(sorted[:idx], sorted[idx+1:]...) | |||||
| } | |||||
| for i := idx - 1; i >= 0; i-- { | |||||
| if sorted[i] == v { | |||||
| return append(sorted[:i], sorted[i+1:]...) | |||||
| } | |||||
| if sorted[i] < v { | |||||
| break | |||||
| } | |||||
| } | |||||
| for i := idx + 1; i < len(sorted); i++ { | |||||
| if sorted[i] == v { | |||||
| return append(sorted[:i], sorted[i+1:]...) | |||||
| } | |||||
| if sorted[i] > v { | |||||
| break | |||||
| } | |||||
| } | |||||
| return sorted | |||||
| } | |||||
| func insertValue(sorted []float64, v float64) []float64 { | |||||
| idx := sort.SearchFloat64s(sorted, v) | |||||
| sorted = append(sorted, 0) | |||||
| copy(sorted[idx+1:], sorted[idx:]) | |||||
| sorted[idx] = v | |||||
| return sorted | |||||
| } | |||||
| func (d *Detector) smoothSpectrum(spectrum []float64) []float64 { | func (d *Detector) smoothSpectrum(spectrum []float64) []float64 { | ||||
| if d.ema == nil || len(d.ema) != len(spectrum) { | if d.ema == nil || len(d.ema) != len(spectrum) { | ||||
| d.ema = make([]float64, len(spectrum)) | d.ema = make([]float64, len(spectrum)) | ||||
| @@ -6,7 +6,7 @@ import ( | |||||
| ) | ) | ||||
| func TestDetectorCreatesEvent(t *testing.T) { | func TestDetectorCreatesEvent(t *testing.T) { | ||||
| d := New(-10, 1000, 10, 1*time.Millisecond, 10*time.Millisecond, 0.2, 3, 1, 10*time.Millisecond, false, 2, 16, 24, 6) | |||||
| d := New(-10, 1000, 10, 1*time.Millisecond, 10*time.Millisecond, 0.2, 3, 1, 10*time.Millisecond, "OFF", 2, 16, 24, 6, true) | |||||
| center := 0.0 | center := 0.0 | ||||
| spectrum := []float64{-30, -30, -30, -5, -5, -30, -30, -30, -30, -30} | spectrum := []float64{-30, -30, -30, -5, -5, -30, -30, -30, -30, -30} | ||||
| now := time.Now() | now := time.Now() | ||||
| @@ -3,6 +3,7 @@ package runtime | |||||
| import ( | import ( | ||||
| "errors" | "errors" | ||||
| "math" | "math" | ||||
| "strings" | |||||
| "sync" | "sync" | ||||
| "sdr-visual-suite/internal/config" | "sdr-visual-suite/internal/config" | ||||
| @@ -27,11 +28,12 @@ type DetectorUpdate struct { | |||||
| HysteresisDb *float64 `json:"hysteresis_db"` | HysteresisDb *float64 `json:"hysteresis_db"` | ||||
| MinStableFrames *int `json:"min_stable_frames"` | MinStableFrames *int `json:"min_stable_frames"` | ||||
| GapToleranceMs *int `json:"gap_tolerance_ms"` | GapToleranceMs *int `json:"gap_tolerance_ms"` | ||||
| CFAREnabled *bool `json:"cfar_enabled"` | |||||
| CFARMode *string `json:"cfar_mode"` | |||||
| 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"` | ||||
| CFARScaleDb *float64 `json:"cfar_scale_db"` | CFARScaleDb *float64 `json:"cfar_scale_db"` | ||||
| CFARWrapAround *bool `json:"cfar_wrap_around"` | |||||
| } | } | ||||
| type SettingsUpdate struct { | type SettingsUpdate struct { | ||||
| @@ -157,8 +159,17 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { | |||||
| } | } | ||||
| next.Detector.GapToleranceMs = *update.Detector.GapToleranceMs | next.Detector.GapToleranceMs = *update.Detector.GapToleranceMs | ||||
| } | } | ||||
| if update.Detector.CFAREnabled != nil { | |||||
| next.Detector.CFAREnabled = *update.Detector.CFAREnabled | |||||
| if update.Detector.CFARMode != nil { | |||||
| mode := strings.ToUpper(strings.TrimSpace(*update.Detector.CFARMode)) | |||||
| switch mode { | |||||
| case "OFF", "CA", "OS", "GOSCA", "CASO": | |||||
| next.Detector.CFARMode = mode | |||||
| default: | |||||
| return m.cfg, errors.New("cfar_mode must be OFF, CA, OS, GOSCA, or CASO") | |||||
| } | |||||
| } | |||||
| if update.Detector.CFARWrapAround != nil { | |||||
| next.Detector.CFARWrapAround = *update.Detector.CFARWrapAround | |||||
| } | } | ||||
| if update.Detector.CFARGuardCells != nil { | if update.Detector.CFARGuardCells != nil { | ||||
| if *update.Detector.CFARGuardCells < 0 { | if *update.Detector.CFARGuardCells < 0 { | ||||
| @@ -15,7 +15,8 @@ func TestApplyConfigUpdate(t *testing.T) { | |||||
| fftSize := 4096 | fftSize := 4096 | ||||
| threshold := -35.0 | threshold := -35.0 | ||||
| bw := 1536 | bw := 1536 | ||||
| cfarEnabled := true | |||||
| cfarMode := "OS" | |||||
| cfarWrap := true | |||||
| cfarGuard := 2 | cfarGuard := 2 | ||||
| cfarTrain := 12 | cfarTrain := 12 | ||||
| cfarRank := 18 | cfarRank := 18 | ||||
| @@ -28,7 +29,8 @@ func TestApplyConfigUpdate(t *testing.T) { | |||||
| TunerBwKHz: &bw, | TunerBwKHz: &bw, | ||||
| Detector: &DetectorUpdate{ | Detector: &DetectorUpdate{ | ||||
| ThresholdDb: &threshold, | ThresholdDb: &threshold, | ||||
| CFAREnabled: &cfarEnabled, | |||||
| CFARMode: &cfarMode, | |||||
| CFARWrapAround: &cfarWrap, | |||||
| CFARGuardCells: &cfarGuard, | CFARGuardCells: &cfarGuard, | ||||
| CFARTrainCells: &cfarTrain, | CFARTrainCells: &cfarTrain, | ||||
| CFARRank: &cfarRank, | CFARRank: &cfarRank, | ||||
| @@ -50,8 +52,11 @@ func TestApplyConfigUpdate(t *testing.T) { | |||||
| if updated.Detector.ThresholdDb != threshold { | if updated.Detector.ThresholdDb != threshold { | ||||
| t.Fatalf("threshold: %v", updated.Detector.ThresholdDb) | t.Fatalf("threshold: %v", updated.Detector.ThresholdDb) | ||||
| } | } | ||||
| if updated.Detector.CFAREnabled != cfarEnabled { | |||||
| t.Fatalf("cfar enabled: %v", updated.Detector.CFAREnabled) | |||||
| if updated.Detector.CFARMode != cfarMode { | |||||
| t.Fatalf("cfar mode: %v", updated.Detector.CFARMode) | |||||
| } | |||||
| if updated.Detector.CFARWrapAround != cfarWrap { | |||||
| t.Fatalf("cfar wrap: %v", updated.Detector.CFARWrapAround) | |||||
| } | } | ||||
| if updated.Detector.CFARGuardCells != cfarGuard { | if updated.Detector.CFARGuardCells != cfarGuard { | ||||
| t.Fatalf("cfar guard: %v", updated.Detector.CFARGuardCells) | t.Fatalf("cfar guard: %v", updated.Detector.CFARGuardCells) | ||||