Bläddra i källkod

Fix CFAR integration and detector scale

master
Jan Svabenik 3 dagar sedan
förälder
incheckning
81c543ac09
13 ändrade filer med 526 tillägg och 120 borttagningar
  1. +8
    -5
      cmd/sdrd/main.go
  2. +72
    -0
      internal/cfar/ca.go
  3. +64
    -0
      internal/cfar/caso.go
  4. +27
    -0
      internal/cfar/cfar.go
  5. +73
    -0
      internal/cfar/cfar_test.go
  6. +72
    -0
      internal/cfar/gosca.go
  7. +101
    -0
      internal/cfar/os.go
  8. +30
    -0
      internal/cfar/types.go
  9. +33
    -4
      internal/config/config.go
  10. +22
    -103
      internal/detector/detector.go
  11. +1
    -1
      internal/detector/detector_test.go
  12. +14
    -3
      internal/runtime/runtime.go
  13. +9
    -4
      internal/runtime/runtime_test.go

+ 8
- 5
cmd/sdrd/main.go Visa fil

@@ -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)


+ 72
- 0
internal/cfar/ca.go Visa fil

@@ -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
}

+ 64
- 0
internal/cfar/caso.go Visa fil

@@ -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
}

+ 27
- 0
internal/cfar/cfar.go Visa fil

@@ -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
}
}

+ 73
- 0
internal/cfar/cfar_test.go Visa fil

@@ -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)
}
})
}
}

+ 72
- 0
internal/cfar/gosca.go Visa fil

@@ -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
}

+ 101
- 0
internal/cfar/os.go Visa fil

@@ -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
}
}
}

+ 30
- 0
internal/cfar/types.go Visa fil

@@ -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
}

+ 33
- 4
internal/config/config.go Visa fil

@@ -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)))


+ 22
- 103
internal/detector/detector.go Visa fil

@@ -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))


+ 1
- 1
internal/detector/detector_test.go Visa fil

@@ -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()


+ 14
- 3
internal/runtime/runtime.go Visa fil

@@ -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 {


+ 9
- 4
internal/runtime/runtime_test.go Visa fil

@@ -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)


Laddar…
Avbryt
Spara