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.

263 lines
10.0KB

  1. package pipeline
  2. import (
  3. "testing"
  4. "time"
  5. )
  6. func TestScheduleCandidates(t *testing.T) {
  7. policy := Policy{MaxRefinementJobs: 2, MinCandidateSNRDb: 5}
  8. cands := []Candidate{
  9. {ID: 1, CenterHz: 100, SNRDb: 4, BandwidthHz: 10000, PeakDb: 1},
  10. {ID: 2, CenterHz: 200, SNRDb: 12, BandwidthHz: 50000, PeakDb: 3},
  11. {ID: 3, CenterHz: 300, SNRDb: 10, BandwidthHz: 25000, PeakDb: 2},
  12. {ID: 4, CenterHz: 400, SNRDb: 20, BandwidthHz: 100000, PeakDb: 5},
  13. }
  14. got := ScheduleCandidates(cands, policy)
  15. if len(got) != 3 {
  16. t.Fatalf("expected 3 scheduled candidates, got %d", len(got))
  17. }
  18. if got[0].Candidate.ID != 4 {
  19. t.Fatalf("expected strongest candidate first, got id=%d", got[0].Candidate.ID)
  20. }
  21. if got[1].Candidate.ID != 2 {
  22. t.Fatalf("expected next strongest candidate second, got id=%d", got[1].Candidate.ID)
  23. }
  24. }
  25. func TestBuildRefinementPlanTracksDrops(t *testing.T) {
  26. policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 10}
  27. cands := []Candidate{
  28. {ID: 1, CenterHz: 100, SNRDb: 4, BandwidthHz: 10000, PeakDb: 1},
  29. {ID: 2, CenterHz: 200, SNRDb: 12, BandwidthHz: 50000, PeakDb: 3},
  30. {ID: 3, CenterHz: 300, SNRDb: 11, BandwidthHz: 25000, PeakDb: 2},
  31. }
  32. plan := BuildRefinementPlan(cands, policy)
  33. if plan.TotalCandidates != 3 {
  34. t.Fatalf("expected total candidates 3, got %d", plan.TotalCandidates)
  35. }
  36. if plan.DroppedBySNR != 1 {
  37. t.Fatalf("expected 1 dropped by SNR, got %d", plan.DroppedBySNR)
  38. }
  39. if plan.DroppedByBudget != 0 {
  40. t.Fatalf("expected 0 dropped by budget in plan stage, got %d", plan.DroppedByBudget)
  41. }
  42. if len(plan.Selected) != 0 {
  43. t.Fatalf("expected no admitted selection in plan stage, got %+v", plan.Selected)
  44. }
  45. if len(plan.Ranked) != 2 {
  46. t.Fatalf("expected ranked candidates after gating, got %d", len(plan.Ranked))
  47. }
  48. if len(plan.WorkItems) != len(cands) {
  49. t.Fatalf("expected work items for all candidates, got %d", len(plan.WorkItems))
  50. }
  51. item2 := findWorkItem(plan.WorkItems, 2)
  52. if item2 == nil || item2.Status != RefinementStatusPlanned || item2.Reason != RefinementReasonPlanned {
  53. t.Fatalf("expected candidate 2 planned with reason, got %+v", item2)
  54. }
  55. item1 := findWorkItem(plan.WorkItems, 1)
  56. if item1 == nil || item1.Reason != RefinementReasonBelowSNR {
  57. t.Fatalf("expected candidate 1 dropped by snr, got %+v", item1)
  58. }
  59. item3 := findWorkItem(plan.WorkItems, 3)
  60. if item3 == nil || item3.Status != RefinementStatusPlanned {
  61. t.Fatalf("expected candidate 3 planned pre-admission, got %+v", item3)
  62. }
  63. }
  64. func TestBuildRefinementPlanRespectsMaxConcurrent(t *testing.T) {
  65. policy := Policy{MaxRefinementJobs: 5, RefinementMaxConcurrent: 2, MinCandidateSNRDb: 0}
  66. cands := []Candidate{
  67. {ID: 1, CenterHz: 100, SNRDb: 9},
  68. {ID: 2, CenterHz: 200, SNRDb: 8},
  69. {ID: 3, CenterHz: 300, SNRDb: 7},
  70. }
  71. plan := BuildRefinementPlan(cands, policy)
  72. if plan.Budget != 2 {
  73. t.Fatalf("expected budget 2, got %d", plan.Budget)
  74. }
  75. if plan.BudgetSource != "refinement.max_concurrent" {
  76. t.Fatalf("expected budget source refinement.max_concurrent, got %s", plan.BudgetSource)
  77. }
  78. if len(plan.Selected) != 0 {
  79. t.Fatalf("expected no selected until admission, got %d", len(plan.Selected))
  80. }
  81. }
  82. func TestBuildRefinementPlanAppliesMonitorSpan(t *testing.T) {
  83. policy := Policy{MaxRefinementJobs: 5, MinCandidateSNRDb: 0, MonitorStartHz: 150, MonitorEndHz: 350}
  84. cands := []Candidate{
  85. {ID: 1, CenterHz: 100, BandwidthHz: 20},
  86. {ID: 2, CenterHz: 200, BandwidthHz: 50},
  87. {ID: 3, CenterHz: 300, BandwidthHz: 100},
  88. {ID: 4, CenterHz: 500, BandwidthHz: 50},
  89. }
  90. plan := BuildRefinementPlan(cands, policy)
  91. if plan.DroppedByMonitor != 2 {
  92. t.Fatalf("expected 2 dropped by monitor, got %d", plan.DroppedByMonitor)
  93. }
  94. if len(plan.Ranked) != 2 {
  95. t.Fatalf("expected 2 ranked within monitor, got %d", len(plan.Ranked))
  96. }
  97. }
  98. func TestBuildRefinementPlanAppliesMonitorSpanCentered(t *testing.T) {
  99. policy := Policy{MaxRefinementJobs: 5, MinCandidateSNRDb: 0, MonitorCenterHz: 300, MonitorSpanHz: 200}
  100. cands := []Candidate{
  101. {ID: 1, CenterHz: 100, BandwidthHz: 20},
  102. {ID: 2, CenterHz: 250, BandwidthHz: 50},
  103. {ID: 3, CenterHz: 300, BandwidthHz: 100},
  104. {ID: 4, CenterHz: 420, BandwidthHz: 50},
  105. }
  106. plan := BuildRefinementPlan(cands, policy)
  107. if plan.DroppedByMonitor != 1 {
  108. t.Fatalf("expected 1 dropped by monitor, got %d", plan.DroppedByMonitor)
  109. }
  110. if len(plan.Ranked) != 3 {
  111. t.Fatalf("expected 3 ranked within monitor, got %d", len(plan.Ranked))
  112. }
  113. }
  114. func TestAutoSpanForHint(t *testing.T) {
  115. span, source := AutoSpanForHint("WFM_STEREO")
  116. if span < 150000 || source == "" {
  117. t.Fatalf("expected WFM span, got %.0f (%s)", span, source)
  118. }
  119. span, source = AutoSpanForHint("CW")
  120. if span != 500 || source == "" {
  121. t.Fatalf("expected CW span, got %.0f (%s)", span, source)
  122. }
  123. span, source = AutoSpanForHint("")
  124. if span != 0 || source != "" {
  125. t.Fatalf("expected empty span for unknown hint, got %.0f (%s)", span, source)
  126. }
  127. }
  128. func TestScheduleCandidatesPriorityBoost(t *testing.T) {
  129. policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 0, SignalPriorities: []string{"digital"}}
  130. got := ScheduleCandidates([]Candidate{
  131. {ID: 1, SNRDb: 15, Hint: "voice"},
  132. {ID: 2, SNRDb: 14, Hint: "digital-burst"},
  133. }, policy)
  134. if len(got) != 2 || got[0].Candidate.ID != 2 {
  135. t.Fatalf("expected priority boost to favor digital candidate, got %+v", got)
  136. }
  137. }
  138. func TestBuildRefinementPlanPriorityStats(t *testing.T) {
  139. policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 0}
  140. cands := []Candidate{
  141. {ID: 1, CenterHz: 100, SNRDb: 8, BandwidthHz: 10000, PeakDb: 2},
  142. {ID: 2, CenterHz: 200, SNRDb: 12, BandwidthHz: 20000, PeakDb: 4},
  143. }
  144. plan := BuildRefinementPlan(cands, policy)
  145. if plan.PriorityMax < plan.PriorityMin {
  146. t.Fatalf("priority bounds invalid: %+v", plan)
  147. }
  148. res := AdmitRefinementPlan(plan, policy, time.Now(), &RefinementHold{Active: map[int64]time.Time{}})
  149. if len(res.Plan.Selected) != 1 {
  150. t.Fatalf("expected 1 admitted, got %d", len(res.Plan.Selected))
  151. }
  152. if res.Plan.PriorityCutoff != res.Plan.Selected[0].Priority {
  153. t.Fatalf("expected cutoff to match selection, got %.2f vs %.2f", res.Plan.PriorityCutoff, res.Plan.Selected[0].Priority)
  154. }
  155. if res.Plan.Selected[0].Breakdown == nil {
  156. t.Fatalf("expected breakdown on selected candidate")
  157. }
  158. if res.Plan.Selected[0].Score == nil || res.Plan.Selected[0].Score.Total == 0 {
  159. t.Fatalf("expected score on selected candidate")
  160. }
  161. }
  162. func TestBuildRefinementPlanStrategyBias(t *testing.T) {
  163. policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 0, Intent: "archive-and-triage"}
  164. cands := []Candidate{
  165. {ID: 1, CenterHz: 100, SNRDb: 12, BandwidthHz: 5000, PeakDb: 1},
  166. {ID: 2, CenterHz: 200, SNRDb: 11, BandwidthHz: 100000, PeakDb: 1},
  167. }
  168. plan := BuildRefinementPlan(cands, policy)
  169. if len(plan.Ranked) != 2 {
  170. t.Fatalf("expected ranked candidates, got %d", len(plan.Ranked))
  171. }
  172. if plan.Ranked[0].Candidate.ID != 2 {
  173. t.Fatalf("expected archive-oriented strategy to favor wider candidate, got %+v", plan.Ranked[0])
  174. }
  175. }
  176. func TestAdmitRefinementPlanAppliesBudget(t *testing.T) {
  177. policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 10}
  178. cands := []Candidate{
  179. {ID: 2, CenterHz: 200, SNRDb: 12, BandwidthHz: 50000, PeakDb: 3},
  180. {ID: 3, CenterHz: 300, SNRDb: 11, BandwidthHz: 25000, PeakDb: 2},
  181. }
  182. plan := BuildRefinementPlan(cands, policy)
  183. res := AdmitRefinementPlan(plan, policy, time.Now(), &RefinementHold{Active: map[int64]time.Time{}})
  184. if len(res.Plan.Selected) != 1 || res.Plan.Selected[0].Candidate.ID != 2 {
  185. t.Fatalf("unexpected admission selection: %+v", res.Plan.Selected)
  186. }
  187. if res.Plan.DroppedByBudget != 1 {
  188. t.Fatalf("expected 1 dropped by budget, got %d", res.Plan.DroppedByBudget)
  189. }
  190. item2 := findWorkItem(res.WorkItems, 2)
  191. if item2 == nil || item2.Status != RefinementStatusAdmitted {
  192. t.Fatalf("expected candidate 2 admitted, got %+v", item2)
  193. }
  194. item3 := findWorkItem(res.WorkItems, 3)
  195. if item3 == nil || item3.Status != RefinementStatusSkipped {
  196. t.Fatalf("expected candidate 3 skipped, got %+v", item3)
  197. }
  198. }
  199. func TestAdmitRefinementPlanDisplacedByHold(t *testing.T) {
  200. policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 0}
  201. cands := []Candidate{
  202. {ID: 1, CenterHz: 100, SNRDb: 5},
  203. {ID: 2, CenterHz: 200, SNRDb: 12},
  204. }
  205. plan := BuildRefinementPlan(cands, policy)
  206. hold := &RefinementHold{Active: map[int64]time.Time{1: time.Now().Add(2 * time.Second)}}
  207. res := AdmitRefinementPlan(plan, policy, time.Now(), hold)
  208. if len(res.Plan.Selected) != 1 || res.Plan.Selected[0].Candidate.ID != 1 {
  209. t.Fatalf("expected held candidate to remain admitted, got %+v", res.Plan.Selected)
  210. }
  211. item2 := findWorkItem(res.WorkItems, 2)
  212. if item2 == nil || item2.Status != RefinementStatusDisplaced {
  213. t.Fatalf("expected higher priority candidate displaced, got %+v", item2)
  214. }
  215. }
  216. func TestRefinementStrategyUsesProfile(t *testing.T) {
  217. strategy, reason := refinementStrategy(Policy{Profile: "digital-hunting"})
  218. if strategy != "digital-hunting" || reason != "profile" {
  219. t.Fatalf("expected digital profile to set strategy, got %s (%s)", strategy, reason)
  220. }
  221. strategy, reason = refinementStrategy(Policy{Profile: "archive"})
  222. if strategy != "archive-oriented" || reason != "profile" {
  223. t.Fatalf("expected archive profile to set strategy, got %s (%s)", strategy, reason)
  224. }
  225. }
  226. func TestRefinementStrategyUsesIntentAndSurveillance(t *testing.T) {
  227. strategy, reason := refinementStrategy(Policy{Intent: "decode-digital"})
  228. if strategy != "digital-hunting" || reason != "intent" {
  229. t.Fatalf("expected intent to set digital strategy, got %s (%s)", strategy, reason)
  230. }
  231. strategy, reason = refinementStrategy(Policy{Intent: "archive-and-triage"})
  232. if strategy != "archive-oriented" || reason != "intent" {
  233. t.Fatalf("expected intent to set archive strategy, got %s (%s)", strategy, reason)
  234. }
  235. strategy, reason = refinementStrategy(Policy{SurveillanceStrategy: "multi-resolution"})
  236. if strategy != "multi-resolution" || reason != "surveillance-strategy" {
  237. t.Fatalf("expected surveillance strategy to set multi-resolution, got %s (%s)", strategy, reason)
  238. }
  239. }
  240. func findWorkItem(items []RefinementWorkItem, id int64) *RefinementWorkItem {
  241. for i := range items {
  242. if items[i].Candidate.ID == id {
  243. return &items[i]
  244. }
  245. }
  246. return nil
  247. }