| @@ -140,6 +140,9 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime | |||||
| recommend := map[string]any{ | recommend := map[string]any{ | ||||
| "mode": policy.Mode, | "mode": policy.Mode, | ||||
| "intent": policy.Intent, | "intent": policy.Intent, | ||||
| "monitor_center_hz": policy.MonitorCenterHz, | |||||
| "monitor_start_hz": policy.MonitorStartHz, | |||||
| "monitor_end_hz": policy.MonitorEndHz, | |||||
| "monitor_span_hz": policy.MonitorSpanHz, | "monitor_span_hz": policy.MonitorSpanHz, | ||||
| "signal_priorities": policy.SignalPriorities, | "signal_priorities": policy.SignalPriorities, | ||||
| "auto_record_classes": policy.AutoRecordClasses, | "auto_record_classes": policy.AutoRecordClasses, | ||||
| @@ -27,9 +27,16 @@ type RefinementPlan struct { | |||||
| TotalCandidates int `json:"total_candidates"` | TotalCandidates int `json:"total_candidates"` | ||||
| MinCandidateSNRDb float64 `json:"min_candidate_snr_db"` | MinCandidateSNRDb float64 `json:"min_candidate_snr_db"` | ||||
| Budget int `json:"budget"` | Budget int `json:"budget"` | ||||
| MonitorStartHz float64 `json:"monitor_start_hz,omitempty"` | |||||
| MonitorEndHz float64 `json:"monitor_end_hz,omitempty"` | |||||
| MonitorSpanHz float64 `json:"monitor_span_hz,omitempty"` | |||||
| DroppedByMonitor int `json:"dropped_by_monitor"` | DroppedByMonitor int `json:"dropped_by_monitor"` | ||||
| DroppedBySNR int `json:"dropped_by_snr"` | DroppedBySNR int `json:"dropped_by_snr"` | ||||
| DroppedByBudget int `json:"dropped_by_budget"` | DroppedByBudget int `json:"dropped_by_budget"` | ||||
| PriorityMin float64 `json:"priority_min,omitempty"` | |||||
| PriorityMax float64 `json:"priority_max,omitempty"` | |||||
| PriorityAvg float64 `json:"priority_avg,omitempty"` | |||||
| PriorityCutoff float64 `json:"priority_cutoff,omitempty"` | |||||
| Selected []ScheduledCandidate `json:"selected,omitempty"` | Selected []ScheduledCandidate `json:"selected,omitempty"` | ||||
| } | } | ||||
| @@ -30,7 +30,7 @@ type Policy struct { | |||||
| } | } | ||||
| func PolicyFromConfig(cfg config.Config) Policy { | func PolicyFromConfig(cfg config.Config) Policy { | ||||
| return Policy{ | |||||
| p := Policy{ | |||||
| Mode: cfg.Pipeline.Mode, | Mode: cfg.Pipeline.Mode, | ||||
| Intent: cfg.Pipeline.Goals.Intent, | Intent: cfg.Pipeline.Goals.Intent, | ||||
| MonitorCenterHz: cfg.CenterHz, | MonitorCenterHz: cfg.CenterHz, | ||||
| @@ -56,6 +56,10 @@ func PolicyFromConfig(cfg config.Config) Policy { | |||||
| MaxDecodeJobs: cfg.Resources.MaxDecodeJobs, | MaxDecodeJobs: cfg.Resources.MaxDecodeJobs, | ||||
| DecisionHoldMs: cfg.Resources.DecisionHoldMs, | DecisionHoldMs: cfg.Resources.DecisionHoldMs, | ||||
| } | } | ||||
| if p.MonitorSpanHz <= 0 && p.MonitorStartHz != 0 && p.MonitorEndHz != 0 && p.MonitorEndHz > p.MonitorStartHz { | |||||
| p.MonitorSpanHz = p.MonitorEndHz - p.MonitorStartHz | |||||
| } | |||||
| return p | |||||
| } | } | ||||
| func ApplyNamedProfile(cfg *config.Config, name string) { | func ApplyNamedProfile(cfg *config.Config, name string) { | ||||
| @@ -3,8 +3,16 @@ package pipeline | |||||
| import "sort" | import "sort" | ||||
| type ScheduledCandidate struct { | type ScheduledCandidate struct { | ||||
| Candidate Candidate `json:"candidate"` | |||||
| Priority float64 `json:"priority"` | |||||
| Candidate Candidate `json:"candidate"` | |||||
| Priority float64 `json:"priority"` | |||||
| Breakdown *PriorityBreakdown `json:"breakdown,omitempty"` | |||||
| } | |||||
| type PriorityBreakdown struct { | |||||
| SNRScore float64 `json:"snr_score"` | |||||
| BandwidthScore float64 `json:"bandwidth_score"` | |||||
| PeakScore float64 `json:"peak_score"` | |||||
| PolicyBoost float64 `json:"policy_boost"` | |||||
| } | } | ||||
| // BuildRefinementPlan scores and budgets candidates for costly local refinement. | // BuildRefinementPlan scores and budgets candidates for costly local refinement. | ||||
| @@ -20,6 +28,13 @@ func BuildRefinementPlan(candidates []Candidate, policy Policy) RefinementPlan { | |||||
| MinCandidateSNRDb: policy.MinCandidateSNRDb, | MinCandidateSNRDb: policy.MinCandidateSNRDb, | ||||
| Budget: budget, | Budget: budget, | ||||
| } | } | ||||
| if start, end, ok := monitorBounds(policy); ok { | |||||
| plan.MonitorStartHz = start | |||||
| plan.MonitorEndHz = end | |||||
| if end > start { | |||||
| plan.MonitorSpanHz = end - start | |||||
| } | |||||
| } | |||||
| if len(candidates) == 0 { | if len(candidates) == 0 { | ||||
| return plan | return plan | ||||
| } | } | ||||
| @@ -34,14 +49,27 @@ func BuildRefinementPlan(candidates []Candidate, policy Policy) RefinementPlan { | |||||
| plan.DroppedBySNR++ | plan.DroppedBySNR++ | ||||
| continue | continue | ||||
| } | } | ||||
| priority := c.SNRDb*snrWeight + CandidatePriorityBoost(policy, c.Hint) | |||||
| snrScore := c.SNRDb * snrWeight | |||||
| bwScore := 0.0 | |||||
| peakScore := 0.0 | |||||
| policyBoost := CandidatePriorityBoost(policy, c.Hint) | |||||
| if c.BandwidthHz > 0 { | if c.BandwidthHz > 0 { | ||||
| priority += minFloat64(c.BandwidthHz/25000.0, 6) * bwWeight | |||||
| bwScore = minFloat64(c.BandwidthHz/25000.0, 6) * bwWeight | |||||
| } | } | ||||
| if c.PeakDb > 0 { | if c.PeakDb > 0 { | ||||
| priority += (c.PeakDb / 20.0) * peakWeight | |||||
| peakScore = (c.PeakDb / 20.0) * peakWeight | |||||
| } | } | ||||
| scored = append(scored, ScheduledCandidate{Candidate: c, Priority: priority}) | |||||
| priority := snrScore + bwScore + peakScore + policyBoost | |||||
| scored = append(scored, ScheduledCandidate{ | |||||
| Candidate: c, | |||||
| Priority: priority, | |||||
| Breakdown: &PriorityBreakdown{ | |||||
| SNRScore: snrScore, | |||||
| BandwidthScore: bwScore, | |||||
| PeakScore: peakScore, | |||||
| PolicyBoost: policyBoost, | |||||
| }, | |||||
| }) | |||||
| } | } | ||||
| sort.Slice(scored, func(i, j int) bool { | sort.Slice(scored, func(i, j int) bool { | ||||
| if scored[i].Priority == scored[j].Priority { | if scored[i].Priority == scored[j].Priority { | ||||
| @@ -49,11 +77,31 @@ func BuildRefinementPlan(candidates []Candidate, policy Policy) RefinementPlan { | |||||
| } | } | ||||
| return scored[i].Priority > scored[j].Priority | return scored[i].Priority > scored[j].Priority | ||||
| }) | }) | ||||
| if len(scored) > 0 { | |||||
| minPriority := scored[0].Priority | |||||
| maxPriority := scored[0].Priority | |||||
| sumPriority := 0.0 | |||||
| for _, s := range scored { | |||||
| if s.Priority < minPriority { | |||||
| minPriority = s.Priority | |||||
| } | |||||
| if s.Priority > maxPriority { | |||||
| maxPriority = s.Priority | |||||
| } | |||||
| sumPriority += s.Priority | |||||
| } | |||||
| plan.PriorityMin = minPriority | |||||
| plan.PriorityMax = maxPriority | |||||
| plan.PriorityAvg = sumPriority / float64(len(scored)) | |||||
| } | |||||
| limit := plan.Budget | limit := plan.Budget | ||||
| if limit <= 0 || limit > len(scored) { | if limit <= 0 || limit > len(scored) { | ||||
| limit = len(scored) | limit = len(scored) | ||||
| } | } | ||||
| plan.Selected = scored[:limit] | plan.Selected = scored[:limit] | ||||
| if len(plan.Selected) > 0 { | |||||
| plan.PriorityCutoff = plan.Selected[len(plan.Selected)-1].Priority | |||||
| } | |||||
| plan.DroppedByBudget = len(scored) - len(plan.Selected) | plan.DroppedByBudget = len(scored) - len(plan.Selected) | ||||
| return plan | return plan | ||||
| } | } | ||||
| @@ -119,3 +119,24 @@ func TestScheduleCandidatesPriorityBoost(t *testing.T) { | |||||
| t.Fatalf("expected priority boost to favor digital candidate, got %+v", got) | t.Fatalf("expected priority boost to favor digital candidate, got %+v", got) | ||||
| } | } | ||||
| } | } | ||||
| func TestBuildRefinementPlanPriorityStats(t *testing.T) { | |||||
| policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 0} | |||||
| cands := []Candidate{ | |||||
| {ID: 1, CenterHz: 100, SNRDb: 8, BandwidthHz: 10000, PeakDb: 2}, | |||||
| {ID: 2, CenterHz: 200, SNRDb: 12, BandwidthHz: 20000, PeakDb: 4}, | |||||
| } | |||||
| plan := BuildRefinementPlan(cands, policy) | |||||
| if plan.PriorityMax < plan.PriorityMin { | |||||
| t.Fatalf("priority bounds invalid: %+v", plan) | |||||
| } | |||||
| if len(plan.Selected) != 1 { | |||||
| t.Fatalf("expected 1 selected, got %d", len(plan.Selected)) | |||||
| } | |||||
| if plan.PriorityCutoff != plan.Selected[0].Priority { | |||||
| t.Fatalf("expected cutoff to match selection, got %.2f vs %.2f", plan.PriorityCutoff, plan.Selected[0].Priority) | |||||
| } | |||||
| if plan.Selected[0].Breakdown == nil { | |||||
| t.Fatalf("expected breakdown on selected candidate") | |||||
| } | |||||
| } | |||||