diff --git a/cmd/sdrd/http_handlers.go b/cmd/sdrd/http_handlers.go index 6e645f9..303ceed 100644 --- a/cmd/sdrd/http_handlers.go +++ b/cmd/sdrd/http_handlers.go @@ -140,6 +140,9 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime recommend := map[string]any{ "mode": policy.Mode, "intent": policy.Intent, + "monitor_center_hz": policy.MonitorCenterHz, + "monitor_start_hz": policy.MonitorStartHz, + "monitor_end_hz": policy.MonitorEndHz, "monitor_span_hz": policy.MonitorSpanHz, "signal_priorities": policy.SignalPriorities, "auto_record_classes": policy.AutoRecordClasses, diff --git a/internal/pipeline/phases.go b/internal/pipeline/phases.go index 5da17b2..4118b24 100644 --- a/internal/pipeline/phases.go +++ b/internal/pipeline/phases.go @@ -27,9 +27,16 @@ type RefinementPlan struct { TotalCandidates int `json:"total_candidates"` MinCandidateSNRDb float64 `json:"min_candidate_snr_db"` 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"` DroppedBySNR int `json:"dropped_by_snr"` 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"` } diff --git a/internal/pipeline/policy.go b/internal/pipeline/policy.go index 025ed42..2f94354 100644 --- a/internal/pipeline/policy.go +++ b/internal/pipeline/policy.go @@ -30,7 +30,7 @@ type Policy struct { } func PolicyFromConfig(cfg config.Config) Policy { - return Policy{ + p := Policy{ Mode: cfg.Pipeline.Mode, Intent: cfg.Pipeline.Goals.Intent, MonitorCenterHz: cfg.CenterHz, @@ -56,6 +56,10 @@ func PolicyFromConfig(cfg config.Config) Policy { MaxDecodeJobs: cfg.Resources.MaxDecodeJobs, 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) { diff --git a/internal/pipeline/scheduler.go b/internal/pipeline/scheduler.go index 4b5c7aa..273243f 100644 --- a/internal/pipeline/scheduler.go +++ b/internal/pipeline/scheduler.go @@ -3,8 +3,16 @@ package pipeline import "sort" 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. @@ -20,6 +28,13 @@ func BuildRefinementPlan(candidates []Candidate, policy Policy) RefinementPlan { MinCandidateSNRDb: policy.MinCandidateSNRDb, 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 { return plan } @@ -34,14 +49,27 @@ func BuildRefinementPlan(candidates []Candidate, policy Policy) RefinementPlan { plan.DroppedBySNR++ 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 { - priority += minFloat64(c.BandwidthHz/25000.0, 6) * bwWeight + bwScore = minFloat64(c.BandwidthHz/25000.0, 6) * bwWeight } 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 { 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 }) + 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 if limit <= 0 || limit > len(scored) { limit = len(scored) } 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) return plan } diff --git a/internal/pipeline/scheduler_test.go b/internal/pipeline/scheduler_test.go index d8d0ebc..93eaa5d 100644 --- a/internal/pipeline/scheduler_test.go +++ b/internal/pipeline/scheduler_test.go @@ -119,3 +119,24 @@ func TestScheduleCandidatesPriorityBoost(t *testing.T) { 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") + } +}