Wideband autonomous SDR analysis engine forked from sdr-visual-suite
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

232 line
6.0KB

  1. package detector
  2. import (
  3. "testing"
  4. "time"
  5. "sdr-visual-suite/internal/classifier"
  6. "sdr-visual-suite/internal/config"
  7. )
  8. func TestDetectorCreatesEvent(t *testing.T) {
  9. d := New(config.DetectorConfig{
  10. ThresholdDb: -10,
  11. MinDurationMs: 1,
  12. HoldMs: 10,
  13. EmaAlpha: 0.2,
  14. HysteresisDb: 3,
  15. MinStableFrames: 1,
  16. GapToleranceMs: 10,
  17. CFARMode: "OFF",
  18. CFARGuardCells: 2,
  19. CFARTrainCells: 16,
  20. CFARRank: 24,
  21. CFARScaleDb: 6,
  22. CFARWrapAround: true,
  23. }, 1000, 10)
  24. center := 0.0
  25. spectrum := []float64{-30, -30, -30, -5, -5, -30, -30, -30, -30, -30}
  26. now := time.Now()
  27. finished, signals := d.Process(now, spectrum, center)
  28. if len(finished) != 0 {
  29. t.Fatalf("expected no finished events yet")
  30. }
  31. if len(signals) != 1 {
  32. t.Fatalf("expected 1 signal, got %d", len(signals))
  33. }
  34. if signals[0].BWHz <= 0 {
  35. t.Fatalf("expected bandwidth > 0")
  36. }
  37. _, _ = d.Process(now.Add(5*time.Millisecond), spectrum, center)
  38. now2 := now.Add(50 * time.Millisecond)
  39. noSignal := make([]float64, len(spectrum))
  40. for i := range noSignal {
  41. noSignal[i] = -100
  42. }
  43. finished, _ = d.Process(now2, noSignal, center)
  44. if len(finished) != 1 {
  45. t.Fatalf("expected 1 finished event, got %d", len(finished))
  46. }
  47. if finished[0].Bandwidth <= 0 {
  48. t.Fatalf("event bandwidth not set")
  49. }
  50. }
  51. func TestSignalBandwidthExpansion(t *testing.T) {
  52. sampleRate := 2048000
  53. fftSize := 2048
  54. cfg := config.DetectorConfig{
  55. ThresholdDb: -20,
  56. MinDurationMs: 1,
  57. HoldMs: 10,
  58. EmaAlpha: 1.0,
  59. HysteresisDb: 3,
  60. MinStableFrames: 1,
  61. GapToleranceMs: 10,
  62. CFARMode: "OFF",
  63. CFARGuardCells: 2,
  64. CFARTrainCells: 8,
  65. CFARRank: 12,
  66. CFARScaleDb: 6,
  67. CFARWrapAround: true,
  68. EdgeMarginDb: 3.0,
  69. MaxSignalBwHz: 150000,
  70. MergeGapHz: 5000,
  71. }
  72. d := New(cfg, sampleRate, fftSize)
  73. spectrum := make([]float64, fftSize)
  74. for i := range spectrum {
  75. spectrum[i] = -100
  76. }
  77. for i := 1000; i <= 1012; i++ {
  78. spectrum[i] = -20
  79. }
  80. for j := 1; j <= 5; j++ {
  81. level := -20 - float64(j)*3
  82. if 1000-j >= 0 {
  83. spectrum[1000-j] = level
  84. }
  85. if 1012+j < fftSize {
  86. spectrum[1012+j] = level
  87. }
  88. }
  89. now := time.Now()
  90. _, signals := d.Process(now, spectrum, 434e6)
  91. if len(signals) == 0 {
  92. t.Fatal("no signals detected")
  93. }
  94. sig := signals[0]
  95. expectedMinBW := 18.0 * 1000
  96. if sig.BWHz < expectedMinBW {
  97. t.Errorf("BW too narrow: got %.0f Hz, want >= %.0f Hz (FirstBin=%d LastBin=%d)", sig.BWHz, expectedMinBW, sig.FirstBin, sig.LastBin)
  98. }
  99. }
  100. func TestMergeGap(t *testing.T) {
  101. sampleRate := 2048000
  102. fftSize := 2048
  103. cfg := config.DetectorConfig{
  104. ThresholdDb: -20,
  105. MinDurationMs: 1,
  106. HoldMs: 10,
  107. EmaAlpha: 1.0,
  108. HysteresisDb: 3,
  109. MinStableFrames: 1,
  110. GapToleranceMs: 10,
  111. CFARMode: "OFF",
  112. MergeGapHz: 5000,
  113. }
  114. d := New(cfg, sampleRate, fftSize)
  115. spectrum := make([]float64, fftSize)
  116. for i := range spectrum {
  117. spectrum[i] = -100
  118. }
  119. for i := 500; i <= 505; i++ {
  120. spectrum[i] = -20
  121. }
  122. for i := 509; i <= 514; i++ {
  123. spectrum[i] = -20
  124. }
  125. now := time.Now()
  126. _, signals := d.Process(now, spectrum, 434e6)
  127. if len(signals) != 1 {
  128. t.Errorf("expected 1 merged signal, got %d", len(signals))
  129. }
  130. if len(signals) == 1 && signals[0].BWHz < 14000 {
  131. t.Errorf("merged BW too narrow: %.0f Hz", signals[0].BWHz)
  132. }
  133. }
  134. func TestGuardTrainHzScaling(t *testing.T) {
  135. sampleRate := 2048000
  136. guardHz := 500.0
  137. trainHz := 5000.0
  138. cfg := config.DetectorConfig{
  139. ThresholdDb: -20,
  140. MinDurationMs: 1,
  141. HoldMs: 10,
  142. EmaAlpha: 1.0,
  143. HysteresisDb: 3,
  144. MinStableFrames: 1,
  145. GapToleranceMs: 10,
  146. CFARMode: "OFF",
  147. CFARGuardHz: guardHz,
  148. CFARTrainHz: trainHz,
  149. CFARScaleDb: 6,
  150. CFARWrapAround: true,
  151. }
  152. d1 := New(cfg, sampleRate, 2048)
  153. d2 := New(cfg, sampleRate, 65536)
  154. spec2048 := makeSignalSpectrum(2048, sampleRate, 500e3, 12e3, -20, -100)
  155. spec65536 := makeSignalSpectrum(65536, sampleRate, 500e3, 12e3, -20, -100)
  156. now := time.Now()
  157. _, sig1 := d1.Process(now, spec2048, 434e6)
  158. _, sig2 := d2.Process(now, spec65536, 434e6)
  159. if len(sig1) == 0 || len(sig2) == 0 {
  160. t.Fatalf("detection failed: fft2048=%d signals, fft65536=%d signals", len(sig1), len(sig2))
  161. }
  162. bw1 := sig1[0].BWHz
  163. bw2 := sig2[0].BWHz
  164. ratio := bw1 / bw2
  165. if ratio < 0.8 || ratio > 1.2 {
  166. t.Errorf("BW mismatch across FFT sizes: fft2048=%.0f Hz, fft65536=%.0f Hz", bw1, bw2)
  167. }
  168. }
  169. func TestClassStabilization(t *testing.T) {
  170. ev := &activeEvent{}
  171. histSize := 5
  172. switchRatio := 0.6
  173. wfm := &classifier.Classification{ModType: classifier.ClassWFM, Confidence: 0.8}
  174. nfm := &classifier.Classification{ModType: classifier.ClassNFM, Confidence: 0.7}
  175. ev.updateClass(wfm, histSize, switchRatio)
  176. if ev.class.ModType != classifier.ClassWFM {
  177. t.Fatalf("first class should be WFM, got %s", ev.class.ModType)
  178. }
  179. ev.updateClass(nfm, histSize, switchRatio)
  180. if ev.class.ModType != classifier.ClassWFM {
  181. t.Fatalf("should stay WFM after 1 NFM, got %s", ev.class.ModType)
  182. }
  183. ev.updateClass(nfm, histSize, switchRatio)
  184. if ev.class.ModType != classifier.ClassNFM {
  185. t.Fatalf("should switch to NFM after 2/3 majority, got %s", ev.class.ModType)
  186. }
  187. for i := 0; i < 5; i++ {
  188. ev.updateClass(wfm, histSize, switchRatio)
  189. }
  190. if ev.class.ModType != classifier.ClassWFM {
  191. t.Fatalf("should be WFM after 5 consecutive, got %s", ev.class.ModType)
  192. }
  193. ev.updateClass(nfm, histSize, switchRatio)
  194. if ev.class.ModType != classifier.ClassWFM {
  195. t.Fatalf("single outlier should not flip class, got %s", ev.class.ModType)
  196. }
  197. }
  198. func makeSignalSpectrum(fftSize int, sampleRate int, offsetHz float64, bwHz float64, signalDb float64, noiseDb float64) []float64 {
  199. spectrum := make([]float64, fftSize)
  200. for i := range spectrum {
  201. spectrum[i] = noiseDb
  202. }
  203. binWidth := float64(sampleRate) / float64(fftSize)
  204. centerBin := int(float64(fftSize)/2 + offsetHz/binWidth)
  205. halfBins := int((bwHz / 2) / binWidth)
  206. if halfBins < 1 {
  207. halfBins = 1
  208. }
  209. for i := centerBin - halfBins; i <= centerBin+halfBins; i++ {
  210. if i >= 0 && i < fftSize {
  211. spectrum[i] = signalDb
  212. }
  213. }
  214. return spectrum
  215. }