Wideband autonomous SDR analysis engine forked from sdr-visual-suite
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

289 řádky
12KB

  1. package pipeline
  2. import (
  3. "strings"
  4. "testing"
  5. "time"
  6. )
  7. func TestDecisionQueueDropsByBudget(t *testing.T) {
  8. arbiter := NewArbiter()
  9. decisions := []SignalDecision{
  10. {Candidate: Candidate{ID: 1, SNRDb: 12}, ShouldRecord: true, ShouldAutoDecode: true},
  11. {Candidate: Candidate{ID: 2, SNRDb: 10}, ShouldRecord: true, ShouldAutoDecode: true},
  12. }
  13. budget := BudgetModel{
  14. Record: BudgetQueue{Max: 1},
  15. Decode: BudgetQueue{Max: 1},
  16. }
  17. stats := arbiter.ApplyDecisions(decisions, budget, time.Now(), Policy{DecisionHoldMs: 250})
  18. if stats.RecordDropped == 0 || stats.DecodeDropped == 0 {
  19. t.Fatalf("expected drops by budget, got %+v", stats)
  20. }
  21. allowed := 0
  22. for _, d := range decisions {
  23. if d.ShouldRecord || d.ShouldAutoDecode {
  24. allowed++
  25. continue
  26. }
  27. if !strings.HasPrefix(d.Reason, DecisionReasonQueueRecord) && !strings.HasPrefix(d.Reason, DecisionReasonQueueDecode) {
  28. t.Fatalf("unexpected decision reason: %s", d.Reason)
  29. }
  30. }
  31. if allowed != 1 {
  32. t.Fatalf("expected 1 decision allowed, got %d", allowed)
  33. }
  34. }
  35. func TestDecisionQueueEnforcesBudgets(t *testing.T) {
  36. decisions := []SignalDecision{
  37. {Candidate: Candidate{ID: 1, SNRDb: 5}, ShouldRecord: true, ShouldAutoDecode: true},
  38. {Candidate: Candidate{ID: 2, SNRDb: 15}, ShouldRecord: true, ShouldAutoDecode: true},
  39. {Candidate: Candidate{ID: 3, SNRDb: 10}, ShouldRecord: true, ShouldAutoDecode: false},
  40. }
  41. arbiter := NewArbiter()
  42. policy := Policy{SignalPriorities: []string{"digital"}, MaxRecordingStreams: 1, MaxDecodeJobs: 1}
  43. budget := BudgetModelFromPolicy(policy)
  44. stats := arbiter.ApplyDecisions(decisions, budget, time.Now(), policy)
  45. if stats.RecordSelected != 1 || stats.DecodeSelected != 1 {
  46. t.Fatalf("unexpected counts: record=%d decode=%d", stats.RecordSelected, stats.DecodeSelected)
  47. }
  48. if !decisions[1].ShouldRecord || !decisions[1].ShouldAutoDecode {
  49. t.Fatalf("expected highest SNR decision to remain allowed")
  50. }
  51. if decisions[0].ShouldRecord || decisions[0].ShouldAutoDecode {
  52. t.Fatalf("expected lowest SNR decision to be budgeted off")
  53. }
  54. if decisions[2].ShouldRecord {
  55. t.Fatalf("expected mid SNR decision to be budgeted off by record budget")
  56. }
  57. if decisions[1].RecordAdmission == nil || decisions[1].RecordAdmission.Class != AdmissionClassAdmit {
  58. t.Fatalf("expected admitted record admission, got %+v", decisions[1].RecordAdmission)
  59. }
  60. if decisions[0].RecordAdmission == nil || decisions[0].RecordAdmission.Class != AdmissionClassDefer {
  61. t.Fatalf("expected deferred record admission, got %+v", decisions[0].RecordAdmission)
  62. }
  63. }
  64. func TestDecisionQueueMonitorWindowBiasSelectsPreferred(t *testing.T) {
  65. arbiter := NewArbiter()
  66. policy := Policy{
  67. DecisionHoldMs: 250,
  68. AutoRecordClasses: []string{"test"},
  69. MonitorWindows: finalizeMonitorWindows([]MonitorWindow{
  70. {Label: "low", StartHz: 100, EndHz: 200, SpanHz: 100, Priority: -1},
  71. {Label: "high", StartHz: 300, EndHz: 400, SpanHz: 100, Priority: 1},
  72. }),
  73. }
  74. budget := BudgetModel{Record: BudgetQueue{Max: 1}}
  75. now := time.Now()
  76. decisions := []SignalDecision{
  77. DecideSignalAction(policy, Candidate{ID: 1, CenterHz: 150, SNRDb: 10, Hint: "test"}, nil),
  78. DecideSignalAction(policy, Candidate{ID: 2, CenterHz: 350, SNRDb: 10, Hint: "test"}, nil),
  79. }
  80. arbiter.ApplyDecisions(decisions, budget, now, policy)
  81. if decisions[0].MonitorBias == 0 || decisions[1].MonitorBias == 0 {
  82. t.Fatalf("expected monitor bias to be applied to both decisions")
  83. }
  84. if decisions[0].ShouldRecord {
  85. t.Fatalf("expected low-priority window decision to be deferred")
  86. }
  87. if !decisions[1].ShouldRecord {
  88. t.Fatalf("expected high-priority window decision to be selected")
  89. }
  90. if decisions[1].RecordAdmission == nil || decisions[1].RecordAdmission.Class != AdmissionClassAdmit {
  91. t.Fatalf("expected admit admission, got %+v", decisions[1].RecordAdmission)
  92. }
  93. if decisions[1].RecordAdmission == nil || !strings.Contains(decisions[1].RecordAdmission.Reason, "window:high") {
  94. t.Fatalf("expected window tag in admission reason, got %+v", decisions[1].RecordAdmission)
  95. }
  96. }
  97. func TestDecisionQueueHoldKeepsSelection(t *testing.T) {
  98. arbiter := NewArbiter()
  99. policy := Policy{DecisionHoldMs: 500}
  100. budget := BudgetModel{Record: BudgetQueue{Max: 1}, Decode: BudgetQueue{Max: 1}}
  101. now := time.Now()
  102. decisions := []SignalDecision{
  103. {Candidate: Candidate{ID: 1, SNRDb: 5}, ShouldRecord: true, ShouldAutoDecode: true},
  104. {Candidate: Candidate{ID: 2, SNRDb: 15}, ShouldRecord: true, ShouldAutoDecode: true},
  105. }
  106. arbiter.ApplyDecisions(decisions, budget, now, policy)
  107. if !decisions[1].ShouldRecord || !decisions[1].ShouldAutoDecode {
  108. t.Fatalf("expected candidate 2 to be selected initially")
  109. }
  110. decisions = []SignalDecision{
  111. {Candidate: Candidate{ID: 1, SNRDb: 32}, ShouldRecord: true, ShouldAutoDecode: true},
  112. {Candidate: Candidate{ID: 2, SNRDb: 30}, ShouldRecord: true, ShouldAutoDecode: true},
  113. {Candidate: Candidate{ID: 3, SNRDb: 10}, ShouldRecord: true, ShouldAutoDecode: true},
  114. }
  115. stats := arbiter.ApplyDecisions(decisions, budget, now.Add(100*time.Millisecond), policy)
  116. if !decisions[1].ShouldRecord || !decisions[1].ShouldAutoDecode {
  117. t.Fatalf("expected held candidate 2 to remain selected")
  118. }
  119. if decisions[0].ShouldRecord || decisions[0].ShouldAutoDecode {
  120. t.Fatalf("expected candidate 1 to remain queued behind hold")
  121. }
  122. if decisions[1].RecordAdmission == nil || decisions[1].RecordAdmission.Class != AdmissionClassHold {
  123. t.Fatalf("expected record admission hold class, got %+v", decisions[1].RecordAdmission)
  124. }
  125. if stats.DecisionHoldMs != policy.DecisionHoldMs {
  126. t.Fatalf("expected decision hold ms %d, got %d", policy.DecisionHoldMs, stats.DecisionHoldMs)
  127. }
  128. if stats.RecordDisplacedByHold != 1 || stats.RecordDisplaced != 1 {
  129. t.Fatalf("expected displaced-by-hold count 1, got %+v", stats)
  130. }
  131. }
  132. func TestDecisionQueueHighTierHoldProtected(t *testing.T) {
  133. arbiter := NewArbiter()
  134. policy := Policy{DecisionHoldMs: 500}
  135. budget := BudgetModel{Record: BudgetQueue{Max: 1}}
  136. now := time.Now()
  137. decisions := []SignalDecision{
  138. {Candidate: Candidate{ID: 1, SNRDb: 30}, ShouldRecord: true},
  139. {Candidate: Candidate{ID: 2, SNRDb: 10}, ShouldRecord: true},
  140. }
  141. arbiter.ApplyDecisions(decisions, budget, now, policy)
  142. if !decisions[0].ShouldRecord {
  143. t.Fatalf("expected candidate 1 to be selected initially")
  144. }
  145. decisions = []SignalDecision{
  146. {Candidate: Candidate{ID: 1, SNRDb: 30}, ShouldRecord: true},
  147. {Candidate: Candidate{ID: 2, SNRDb: 10}, ShouldRecord: true},
  148. {Candidate: Candidate{ID: 3, SNRDb: 32}, ShouldRecord: true},
  149. }
  150. arbiter.ApplyDecisions(decisions, budget, now.Add(100*time.Millisecond), policy)
  151. if !decisions[0].ShouldRecord {
  152. t.Fatalf("expected protected hold to keep candidate 1")
  153. }
  154. if decisions[2].ShouldRecord {
  155. t.Fatalf("expected candidate 3 to remain deferred behind protected hold")
  156. }
  157. if decisions[0].RecordAdmission == nil || decisions[0].RecordAdmission.Class != AdmissionClassHold {
  158. t.Fatalf("expected hold admission for candidate 1, got %+v", decisions[0].RecordAdmission)
  159. }
  160. if decisions[2].RecordAdmission == nil || decisions[2].RecordAdmission.Class != AdmissionClassDisplace {
  161. t.Fatalf("expected displacement admission for candidate 3, got %+v", decisions[2].RecordAdmission)
  162. }
  163. }
  164. func TestDecisionQueueFamilyPriorityProtectsHold(t *testing.T) {
  165. arbiter := NewArbiter()
  166. policy := Policy{DecisionHoldMs: 500, SignalPriorities: []string{"digital"}}
  167. budget := BudgetModel{Record: BudgetQueue{Max: 1}}
  168. now := time.Now()
  169. decisions := []SignalDecision{
  170. {Candidate: Candidate{ID: 1, SNRDb: 5, Hint: "digital"}, ShouldRecord: true},
  171. }
  172. arbiter.ApplyDecisions(decisions, budget, now, policy)
  173. if !decisions[0].ShouldRecord {
  174. t.Fatalf("expected candidate 1 to be selected initially")
  175. }
  176. decisions = []SignalDecision{
  177. {Candidate: Candidate{ID: 1, SNRDb: 5, Hint: "digital"}, ShouldRecord: true},
  178. {Candidate: Candidate{ID: 2, SNRDb: 35, Hint: "voice"}, ShouldRecord: true},
  179. }
  180. arbiter.ApplyDecisions(decisions, budget, now.Add(100*time.Millisecond), policy)
  181. if !decisions[0].ShouldRecord {
  182. t.Fatalf("expected family-priority hold to keep candidate 1")
  183. }
  184. if decisions[1].ShouldRecord {
  185. t.Fatalf("expected candidate 2 to remain deferred behind family hold")
  186. }
  187. if decisions[0].RecordAdmission == nil || decisions[0].RecordAdmission.FamilyRank != 1 {
  188. t.Fatalf("expected family rank on admission, got %+v", decisions[0].RecordAdmission)
  189. }
  190. if decisions[0].RecordAdmission == nil || decisions[0].RecordAdmission.TierFloor != PriorityTierHigh {
  191. t.Fatalf("expected tier floor on admission, got %+v", decisions[0].RecordAdmission)
  192. }
  193. }
  194. func TestDecisionQueueOpportunisticDisplacement(t *testing.T) {
  195. arbiter := NewArbiter()
  196. policy := Policy{DecisionHoldMs: 500}
  197. budget := BudgetModel{Record: BudgetQueue{Max: 1}}
  198. now := time.Now()
  199. decisions := []SignalDecision{
  200. {Candidate: Candidate{ID: 1, SNRDb: 15}, ShouldRecord: true},
  201. {Candidate: Candidate{ID: 2, SNRDb: 10}, ShouldRecord: true},
  202. }
  203. arbiter.ApplyDecisions(decisions, budget, now, policy)
  204. if !decisions[0].ShouldRecord {
  205. t.Fatalf("expected candidate 1 to be selected initially")
  206. }
  207. decisions = []SignalDecision{
  208. {Candidate: Candidate{ID: 1, SNRDb: 5}, ShouldRecord: true},
  209. {Candidate: Candidate{ID: 2, SNRDb: 4}, ShouldRecord: true},
  210. {Candidate: Candidate{ID: 3, SNRDb: 30}, ShouldRecord: true},
  211. }
  212. arbiter.ApplyDecisions(decisions, budget, now.Add(100*time.Millisecond), policy)
  213. if decisions[0].ShouldRecord {
  214. t.Fatalf("expected candidate 1 to be displaced")
  215. }
  216. if !decisions[2].ShouldRecord {
  217. t.Fatalf("expected candidate 3 to opportunistically displace hold")
  218. }
  219. if decisions[0].RecordAdmission == nil || decisions[0].RecordAdmission.Class != AdmissionClassDisplace {
  220. t.Fatalf("expected displacement admission for candidate 1, got %+v", decisions[0].RecordAdmission)
  221. }
  222. if decisions[2].RecordAdmission == nil || decisions[2].RecordAdmission.Class != AdmissionClassAdmit {
  223. t.Fatalf("expected admit admission for candidate 3, got %+v", decisions[2].RecordAdmission)
  224. }
  225. if decisions[2].RecordAdmission == nil || !strings.Contains(decisions[2].RecordAdmission.Reason, ReasonTagDisplaceOpportunist) {
  226. t.Fatalf("expected opportunistic displacement reason, got %+v", decisions[2].RecordAdmission)
  227. }
  228. }
  229. func TestDecisionQueueHoldExpiryChurn(t *testing.T) {
  230. arbiter := NewArbiter()
  231. policy := Policy{DecisionHoldMs: 100}
  232. budget := BudgetModel{Record: BudgetQueue{Max: 1}}
  233. now := time.Now()
  234. decisions := []SignalDecision{
  235. {Candidate: Candidate{ID: 1, SNRDb: 12}, ShouldRecord: true},
  236. {Candidate: Candidate{ID: 2, SNRDb: 10}, ShouldRecord: true},
  237. }
  238. arbiter.ApplyDecisions(decisions, budget, now, policy)
  239. if !decisions[0].ShouldRecord {
  240. t.Fatalf("expected candidate 1 to be selected initially")
  241. }
  242. decisions = []SignalDecision{
  243. {Candidate: Candidate{ID: 1, SNRDb: 30}, ShouldRecord: true},
  244. {Candidate: Candidate{ID: 2, SNRDb: 32}, ShouldRecord: true},
  245. {Candidate: Candidate{ID: 3, SNRDb: 5}, ShouldRecord: true},
  246. }
  247. arbiter.ApplyDecisions(decisions, budget, now.Add(50*time.Millisecond), policy)
  248. if !decisions[0].ShouldRecord {
  249. t.Fatalf("expected hold to keep candidate 1 before expiry")
  250. }
  251. decisions = []SignalDecision{
  252. {Candidate: Candidate{ID: 1, SNRDb: 30}, ShouldRecord: true},
  253. {Candidate: Candidate{ID: 2, SNRDb: 32}, ShouldRecord: true},
  254. {Candidate: Candidate{ID: 3, SNRDb: 5}, ShouldRecord: true},
  255. }
  256. arbiter.ApplyDecisions(decisions, budget, now.Add(200*time.Millisecond), policy)
  257. if decisions[0].ShouldRecord {
  258. t.Fatalf("expected candidate 1 to be released after hold expiry")
  259. }
  260. if !decisions[1].ShouldRecord {
  261. t.Fatalf("expected candidate 2 to be selected after hold expiry")
  262. }
  263. if decisions[0].RecordAdmission == nil || !strings.Contains(decisions[0].RecordAdmission.Reason, ReasonTagHoldExpired) {
  264. t.Fatalf("expected hold expiry reason, got %+v", decisions[0].RecordAdmission)
  265. }
  266. }