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.

256 lines
7.8KB

  1. package pipeline
  2. import (
  3. "sort"
  4. "strings"
  5. )
  6. type ScheduledCandidate struct {
  7. Candidate Candidate `json:"candidate"`
  8. Priority float64 `json:"priority"`
  9. Score *RefinementScore `json:"score,omitempty"`
  10. Breakdown *RefinementScoreDetails `json:"breakdown,omitempty"`
  11. }
  12. type RefinementScoreModel struct {
  13. SNRWeight float64 `json:"snr_weight"`
  14. BandwidthWeight float64 `json:"bandwidth_weight"`
  15. PeakWeight float64 `json:"peak_weight"`
  16. }
  17. type RefinementScoreDetails struct {
  18. SNRScore float64 `json:"snr_score"`
  19. BandwidthScore float64 `json:"bandwidth_score"`
  20. PeakScore float64 `json:"peak_score"`
  21. PolicyBoost float64 `json:"policy_boost"`
  22. }
  23. type RefinementScore struct {
  24. Total float64 `json:"total"`
  25. Breakdown RefinementScoreDetails `json:"breakdown"`
  26. Weights *RefinementScoreModel `json:"weights,omitempty"`
  27. }
  28. type RefinementWorkItem struct {
  29. Candidate Candidate `json:"candidate"`
  30. Window RefinementWindow `json:"window,omitempty"`
  31. Execution *RefinementExecution `json:"execution,omitempty"`
  32. Priority float64 `json:"priority,omitempty"`
  33. Score *RefinementScore `json:"score,omitempty"`
  34. Breakdown *RefinementScoreDetails `json:"breakdown,omitempty"`
  35. Status string `json:"status,omitempty"`
  36. Reason string `json:"reason,omitempty"`
  37. }
  38. type RefinementExecution struct {
  39. Stage string `json:"stage,omitempty"`
  40. SampleRate int `json:"sample_rate,omitempty"`
  41. FFTSize int `json:"fft_size,omitempty"`
  42. CenterHz float64 `json:"center_hz,omitempty"`
  43. SpanHz float64 `json:"span_hz,omitempty"`
  44. Source string `json:"source,omitempty"`
  45. }
  46. const (
  47. RefinementStatusSelected = "selected"
  48. RefinementStatusDropped = "dropped"
  49. RefinementStatusDeferred = "deferred"
  50. )
  51. const (
  52. RefinementReasonSelected = "selected"
  53. RefinementReasonMonitorGate = "dropped:monitor"
  54. RefinementReasonBelowSNR = "dropped:snr"
  55. RefinementReasonBudget = "dropped:budget"
  56. RefinementReasonDisabled = "dropped:disabled"
  57. RefinementReasonUnclassified = "dropped:unclassified"
  58. )
  59. // BuildRefinementPlan scores and budgets candidates for costly local refinement.
  60. // Current heuristic is intentionally simple and deterministic; later phases can add
  61. // richer scoring (novelty, persistence, profile-aware band priorities, decoder value).
  62. func BuildRefinementPlan(candidates []Candidate, policy Policy) RefinementPlan {
  63. strategy, strategyReason := refinementStrategy(policy)
  64. budgetModel := BudgetModelFromPolicy(policy)
  65. budget := budgetModel.Refinement.Max
  66. plan := RefinementPlan{
  67. TotalCandidates: len(candidates),
  68. MinCandidateSNRDb: policy.MinCandidateSNRDb,
  69. Budget: budget,
  70. BudgetSource: budgetModel.Refinement.Source,
  71. Strategy: strategy,
  72. StrategyReason: strategyReason,
  73. }
  74. if start, end, ok := monitorBounds(policy); ok {
  75. plan.MonitorStartHz = start
  76. plan.MonitorEndHz = end
  77. if end > start {
  78. plan.MonitorSpanHz = end - start
  79. }
  80. }
  81. if len(candidates) == 0 {
  82. return plan
  83. }
  84. snrWeight, bwWeight, peakWeight := refinementIntentWeights(policy.Intent)
  85. scoreModel := RefinementScoreModel{
  86. SNRWeight: snrWeight,
  87. BandwidthWeight: bwWeight,
  88. PeakWeight: peakWeight,
  89. }
  90. scoreModel = applyStrategyWeights(strategy, scoreModel)
  91. plan.ScoreModel = scoreModel
  92. scored := make([]ScheduledCandidate, 0, len(candidates))
  93. workItems := make([]RefinementWorkItem, 0, len(candidates))
  94. for _, c := range candidates {
  95. if !candidateInMonitor(policy, c) {
  96. plan.DroppedByMonitor++
  97. workItems = append(workItems, RefinementWorkItem{
  98. Candidate: c,
  99. Status: RefinementStatusDropped,
  100. Reason: RefinementReasonMonitorGate,
  101. })
  102. continue
  103. }
  104. if c.SNRDb < policy.MinCandidateSNRDb {
  105. plan.DroppedBySNR++
  106. workItems = append(workItems, RefinementWorkItem{
  107. Candidate: c,
  108. Status: RefinementStatusDropped,
  109. Reason: RefinementReasonBelowSNR,
  110. })
  111. continue
  112. }
  113. snrScore := c.SNRDb * scoreModel.SNRWeight
  114. bwScore := 0.0
  115. peakScore := 0.0
  116. policyBoost := CandidatePriorityBoost(policy, c.Hint)
  117. if c.BandwidthHz > 0 {
  118. bwScore = minFloat64(c.BandwidthHz/25000.0, 6) * scoreModel.BandwidthWeight
  119. }
  120. if c.PeakDb > 0 {
  121. peakScore = (c.PeakDb / 20.0) * scoreModel.PeakWeight
  122. }
  123. priority := snrScore + bwScore + peakScore + policyBoost
  124. score := &RefinementScore{
  125. Total: priority,
  126. Breakdown: RefinementScoreDetails{
  127. SNRScore: snrScore,
  128. BandwidthScore: bwScore,
  129. PeakScore: peakScore,
  130. PolicyBoost: policyBoost,
  131. },
  132. Weights: &scoreModel,
  133. }
  134. scored = append(scored, ScheduledCandidate{
  135. Candidate: c,
  136. Priority: priority,
  137. Score: score,
  138. Breakdown: &score.Breakdown,
  139. })
  140. workItems = append(workItems, RefinementWorkItem{
  141. Candidate: c,
  142. Priority: priority,
  143. Score: score,
  144. Breakdown: &score.Breakdown,
  145. Status: RefinementStatusDeferred,
  146. })
  147. }
  148. sort.Slice(scored, func(i, j int) bool {
  149. if scored[i].Priority == scored[j].Priority {
  150. return scored[i].Candidate.CenterHz < scored[j].Candidate.CenterHz
  151. }
  152. return scored[i].Priority > scored[j].Priority
  153. })
  154. if len(scored) > 0 {
  155. minPriority := scored[0].Priority
  156. maxPriority := scored[0].Priority
  157. sumPriority := 0.0
  158. for _, s := range scored {
  159. if s.Priority < minPriority {
  160. minPriority = s.Priority
  161. }
  162. if s.Priority > maxPriority {
  163. maxPriority = s.Priority
  164. }
  165. sumPriority += s.Priority
  166. }
  167. plan.PriorityMin = minPriority
  168. plan.PriorityMax = maxPriority
  169. plan.PriorityAvg = sumPriority / float64(len(scored))
  170. }
  171. limit := plan.Budget
  172. if limit <= 0 || limit > len(scored) {
  173. limit = len(scored)
  174. }
  175. plan.Selected = scored[:limit]
  176. if len(plan.Selected) > 0 {
  177. plan.PriorityCutoff = plan.Selected[len(plan.Selected)-1].Priority
  178. }
  179. plan.DroppedByBudget = len(scored) - len(plan.Selected)
  180. if len(plan.Selected) > 0 {
  181. selected := map[int64]struct{}{}
  182. for _, s := range plan.Selected {
  183. selected[s.Candidate.ID] = struct{}{}
  184. }
  185. for i := range workItems {
  186. item := &workItems[i]
  187. if _, ok := selected[item.Candidate.ID]; ok {
  188. item.Status = RefinementStatusSelected
  189. item.Reason = RefinementReasonSelected
  190. } else if item.Status == RefinementStatusDeferred {
  191. item.Status = RefinementStatusDropped
  192. item.Reason = RefinementReasonBudget
  193. }
  194. }
  195. }
  196. plan.WorkItems = workItems
  197. return plan
  198. }
  199. func ScheduleCandidates(candidates []Candidate, policy Policy) []ScheduledCandidate {
  200. return BuildRefinementPlan(candidates, policy).Selected
  201. }
  202. func refinementStrategy(policy Policy) (string, string) {
  203. intent := strings.ToLower(strings.TrimSpace(policy.Intent))
  204. switch {
  205. case strings.Contains(intent, "digital") || strings.Contains(intent, "hunt") || strings.Contains(intent, "decode"):
  206. return "digital-hunting", "intent"
  207. case strings.Contains(intent, "archive") || strings.Contains(intent, "triage") || strings.Contains(policy.Mode, "archive"):
  208. return "archive-oriented", "intent"
  209. case strings.Contains(strings.ToLower(policy.SurveillanceStrategy), "multi"):
  210. return "multi-resolution", "surveillance-strategy"
  211. default:
  212. return "single-resolution", "default"
  213. }
  214. }
  215. func applyStrategyWeights(strategy string, model RefinementScoreModel) RefinementScoreModel {
  216. switch strings.ToLower(strings.TrimSpace(strategy)) {
  217. case "digital-hunting":
  218. model.SNRWeight *= 1.4
  219. model.BandwidthWeight *= 0.75
  220. model.PeakWeight *= 1.2
  221. case "archive-oriented":
  222. model.SNRWeight *= 1.1
  223. model.BandwidthWeight *= 1.6
  224. model.PeakWeight *= 1.05
  225. case "multi-resolution", "multi", "multi-res", "multi_res":
  226. model.SNRWeight *= 1.15
  227. model.BandwidthWeight *= 1.1
  228. model.PeakWeight *= 1.15
  229. case "single-resolution":
  230. model.SNRWeight *= 1.1
  231. model.BandwidthWeight *= 1.0
  232. model.PeakWeight *= 1.0
  233. }
  234. return model
  235. }
  236. func minFloat64(a, b float64) float64 {
  237. if a < b {
  238. return a
  239. }
  240. return b
  241. }