diff --git a/cmd/sdrd/main.go b/cmd/sdrd/main.go index bcfa178..95c70d5 100644 --- a/cmd/sdrd/main.go +++ b/cmd/sdrd/main.go @@ -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) diff --git a/internal/cfar/ca.go b/internal/cfar/ca.go new file mode 100644 index 0000000..b13f673 --- /dev/null +++ b/internal/cfar/ca.go @@ -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 +} diff --git a/internal/cfar/caso.go b/internal/cfar/caso.go new file mode 100644 index 0000000..6a75015 --- /dev/null +++ b/internal/cfar/caso.go @@ -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 +} diff --git a/internal/cfar/cfar.go b/internal/cfar/cfar.go new file mode 100644 index 0000000..86256d6 --- /dev/null +++ b/internal/cfar/cfar.go @@ -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 + } +} diff --git a/internal/cfar/cfar_test.go b/internal/cfar/cfar_test.go new file mode 100644 index 0000000..40efb55 --- /dev/null +++ b/internal/cfar/cfar_test.go @@ -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) + } + }) + } +} diff --git a/internal/cfar/gosca.go b/internal/cfar/gosca.go new file mode 100644 index 0000000..27f2a28 --- /dev/null +++ b/internal/cfar/gosca.go @@ -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 +} diff --git a/internal/cfar/os.go b/internal/cfar/os.go new file mode 100644 index 0000000..a318d0e --- /dev/null +++ b/internal/cfar/os.go @@ -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 + } + } +} diff --git a/internal/cfar/types.go b/internal/cfar/types.go new file mode 100644 index 0000000..099fe65 --- /dev/null +++ b/internal/cfar/types.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go index 48e21b4..e78de9e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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))) diff --git a/internal/detector/detector.go b/internal/detector/detector.go index 1f9f0a8..2866fba 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -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)) diff --git a/internal/detector/detector_test.go b/internal/detector/detector_test.go index 7291a6d..3f2e8ed 100644 --- a/internal/detector/detector_test.go +++ b/internal/detector/detector_test.go @@ -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() diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 6e41579..d397bb5 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -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 { diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 00c949b..cbc1641 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -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)