| @@ -319,11 +319,12 @@ func main() { | |||
| cfg.Detector.HysteresisDb, | |||
| cfg.Detector.MinStableFrames, | |||
| time.Duration(cfg.Detector.GapToleranceMs)*time.Millisecond, | |||
| cfg.Detector.CFAREnabled, | |||
| cfg.Detector.CFARMode, | |||
| cfg.Detector.CFARGuardCells, | |||
| cfg.Detector.CFARTrainCells, | |||
| cfg.Detector.CFARRank, | |||
| cfg.Detector.CFARScaleDb) | |||
| cfg.Detector.CFARScaleDb, | |||
| cfg.Detector.CFARWrapAround) | |||
| window := fftutil.Hann(cfg.FFTSize) | |||
| h := newHub() | |||
| @@ -443,11 +444,12 @@ func main() { | |||
| prev.Detector.HysteresisDb != next.Detector.HysteresisDb || | |||
| prev.Detector.MinStableFrames != next.Detector.MinStableFrames || | |||
| 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.CFARTrainCells != next.Detector.CFARTrainCells || | |||
| prev.Detector.CFARRank != next.Detector.CFARRank || | |||
| prev.Detector.CFARScaleDb != next.Detector.CFARScaleDb || | |||
| prev.Detector.CFARWrapAround != next.Detector.CFARWrapAround || | |||
| prev.SampleRate != next.SampleRate || | |||
| prev.FFTSize != next.FFTSize | |||
| windowChanged := prev.FFTSize != next.FFTSize | |||
| @@ -461,11 +463,12 @@ func main() { | |||
| next.Detector.HysteresisDb, | |||
| next.Detector.MinStableFrames, | |||
| time.Duration(next.Detector.GapToleranceMs)*time.Millisecond, | |||
| next.Detector.CFAREnabled, | |||
| next.Detector.CFARMode, | |||
| next.Detector.CFARGuardCells, | |||
| next.Detector.CFARTrainCells, | |||
| next.Detector.CFARRank, | |||
| next.Detector.CFARScaleDb) | |||
| next.Detector.CFARScaleDb, | |||
| next.Detector.CFARWrapAround) | |||
| } | |||
| if windowChanged { | |||
| 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"` | |||
| MinStableFrames int `yaml:"min_stable_frames" json:"min_stable_frames"` | |||
| 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"` | |||
| CFARTrainCells int `yaml:"cfar_train_cells" json:"cfar_train_cells"` | |||
| 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"` | |||
| // Deprecated (backward compatibility) | |||
| CFAREnabled *bool `yaml:"cfar_enabled,omitempty" json:"cfar_enabled,omitempty"` | |||
| } | |||
| type RecorderConfig struct { | |||
| @@ -89,7 +93,21 @@ func Default() Config { | |||
| AGC: false, | |||
| DCBlock: 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{ | |||
| Enabled: false, | |||
| MinSNRDb: 10, | |||
| @@ -143,11 +161,22 @@ func applyDefaults(cfg Config) Config { | |||
| if cfg.Detector.GapToleranceMs <= 0 { | |||
| 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 { | |||
| cfg.Detector.CFARGuardCells = 2 | |||
| cfg.Detector.CFARGuardCells = 3 | |||
| } | |||
| 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 { | |||
| cfg.Detector.CFARRank = int(math.Round(0.75 * float64(2*cfg.Detector.CFARTrainCells))) | |||
| @@ -5,6 +5,7 @@ import ( | |||
| "sort" | |||
| "time" | |||
| "sdr-visual-suite/internal/cfar" | |||
| "sdr-visual-suite/internal/classifier" | |||
| ) | |||
| @@ -29,19 +30,15 @@ type Detector struct { | |||
| HysteresisDb float64 | |||
| MinStableFrames int | |||
| GapTolerance time.Duration | |||
| CFAREnabled bool | |||
| CFARGuardCells int | |||
| CFARTrainCells int | |||
| CFARRank int | |||
| CFARScaleDb float64 | |||
| binWidth float64 | |||
| nbins 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 { | |||
| @@ -68,7 +65,7 @@ type Signal struct { | |||
| 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 { | |||
| minDur = 250 * time.Millisecond | |||
| } | |||
| @@ -102,6 +99,17 @@ func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Dur | |||
| 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{ | |||
| ThresholdDb: thresholdDb, | |||
| MinDuration: minDur, | |||
| @@ -110,10 +118,6 @@ func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Dur | |||
| HysteresisDb: hysteresis, | |||
| MinStableFrames: minStable, | |||
| GapTolerance: gapTolerance, | |||
| CFAREnabled: cfarEnabled, | |||
| CFARGuardCells: cfarGuard, | |||
| CFARTrainCells: cfarTrain, | |||
| CFARRank: cfarRank, | |||
| CFARScaleDb: cfarScaleDb, | |||
| binWidth: float64(sampleRate) / float64(fftSize), | |||
| nbins: fftSize, | |||
| @@ -121,6 +125,7 @@ func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Dur | |||
| ema: make([]float64, fftSize), | |||
| active: map[int64]*activeEvent{}, | |||
| nextID: 1, | |||
| cfarEngine: cfarEngine, | |||
| } | |||
| } | |||
| @@ -151,7 +156,10 @@ func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal | |||
| return nil | |||
| } | |||
| smooth := d.smoothSpectrum(spectrum) | |||
| thresholds := d.cfarThresholds(smooth) | |||
| var thresholds []float64 | |||
| if d.cfarEngine != nil { | |||
| thresholds = d.cfarEngine.Thresholds(smooth) | |||
| } | |||
| noiseGlobal := median(smooth) | |||
| var signals []Signal | |||
| 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 { | |||
| if d.ema == nil || len(d.ema) != len(spectrum) { | |||
| d.ema = make([]float64, len(spectrum)) | |||
| @@ -6,7 +6,7 @@ import ( | |||
| ) | |||
| 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 | |||
| spectrum := []float64{-30, -30, -30, -5, -5, -30, -30, -30, -30, -30} | |||
| now := time.Now() | |||
| @@ -3,6 +3,7 @@ package runtime | |||
| import ( | |||
| "errors" | |||
| "math" | |||
| "strings" | |||
| "sync" | |||
| "sdr-visual-suite/internal/config" | |||
| @@ -27,11 +28,12 @@ type DetectorUpdate struct { | |||
| HysteresisDb *float64 `json:"hysteresis_db"` | |||
| MinStableFrames *int `json:"min_stable_frames"` | |||
| GapToleranceMs *int `json:"gap_tolerance_ms"` | |||
| CFAREnabled *bool `json:"cfar_enabled"` | |||
| CFARMode *string `json:"cfar_mode"` | |||
| CFARGuardCells *int `json:"cfar_guard_cells"` | |||
| CFARTrainCells *int `json:"cfar_train_cells"` | |||
| CFARRank *int `json:"cfar_rank"` | |||
| CFARScaleDb *float64 `json:"cfar_scale_db"` | |||
| CFARWrapAround *bool `json:"cfar_wrap_around"` | |||
| } | |||
| type SettingsUpdate struct { | |||
| @@ -157,8 +159,17 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { | |||
| } | |||
| 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 < 0 { | |||
| @@ -15,7 +15,8 @@ func TestApplyConfigUpdate(t *testing.T) { | |||
| fftSize := 4096 | |||
| threshold := -35.0 | |||
| bw := 1536 | |||
| cfarEnabled := true | |||
| cfarMode := "OS" | |||
| cfarWrap := true | |||
| cfarGuard := 2 | |||
| cfarTrain := 12 | |||
| cfarRank := 18 | |||
| @@ -28,7 +29,8 @@ func TestApplyConfigUpdate(t *testing.T) { | |||
| TunerBwKHz: &bw, | |||
| Detector: &DetectorUpdate{ | |||
| ThresholdDb: &threshold, | |||
| CFAREnabled: &cfarEnabled, | |||
| CFARMode: &cfarMode, | |||
| CFARWrapAround: &cfarWrap, | |||
| CFARGuardCells: &cfarGuard, | |||
| CFARTrainCells: &cfarTrain, | |||
| CFARRank: &cfarRank, | |||
| @@ -50,8 +52,11 @@ func TestApplyConfigUpdate(t *testing.T) { | |||
| if updated.Detector.ThresholdDb != threshold { | |||
| 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 { | |||
| t.Fatalf("cfar guard: %v", updated.Detector.CFARGuardCells) | |||