| @@ -441,6 +441,7 @@ func (rt *dspRuntime) buildSurveillanceResult(art *spectrumArtifacts) pipeline.S | |||||
| primaryCandidates := pipeline.CandidatesFromSignalsWithLevel(art.detected, "surveillance-detector", plan.Primary) | primaryCandidates := pipeline.CandidatesFromSignalsWithLevel(art.detected, "surveillance-detector", plan.Primary) | ||||
| derivedCandidates := rt.detectDerivedCandidates(art, plan) | derivedCandidates := rt.detectDerivedCandidates(art, plan) | ||||
| candidates := pipeline.FuseCandidates(primaryCandidates, derivedCandidates) | candidates := pipeline.FuseCandidates(primaryCandidates, derivedCandidates) | ||||
| pipeline.ApplyMonitorWindowMatchesToCandidates(policy, candidates) | |||||
| scheduled := pipeline.ScheduleCandidates(candidates, policy) | scheduled := pipeline.ScheduleCandidates(candidates, policy) | ||||
| return pipeline.SurveillanceResult{ | return pipeline.SurveillanceResult{ | ||||
| Level: plan.Primary, | Level: plan.Primary, | ||||
| @@ -1,6 +1,12 @@ | |||||
| package pipeline | package pipeline | ||||
| import "sdr-wideband-suite/internal/config" | |||||
| import ( | |||||
| "math" | |||||
| "sdr-wideband-suite/internal/config" | |||||
| ) | |||||
| const maxMonitorWindowBias = 0.2 | |||||
| func NormalizeMonitorWindows(goals config.PipelineGoalConfig, centerHz float64) []MonitorWindow { | func NormalizeMonitorWindows(goals config.PipelineGoalConfig, centerHz float64) []MonitorWindow { | ||||
| if len(goals.MonitorWindows) > 0 { | if len(goals.MonitorWindows) > 0 { | ||||
| @@ -11,38 +17,61 @@ func NormalizeMonitorWindows(goals config.PipelineGoalConfig, centerHz float64) | |||||
| } | } | ||||
| } | } | ||||
| if len(windows) > 0 { | if len(windows) > 0 { | ||||
| return windows | |||||
| return finalizeMonitorWindows(windows) | |||||
| } | } | ||||
| } | } | ||||
| if goals.MonitorStartHz > 0 && goals.MonitorEndHz > goals.MonitorStartHz { | if goals.MonitorStartHz > 0 && goals.MonitorEndHz > goals.MonitorStartHz { | ||||
| start := goals.MonitorStartHz | start := goals.MonitorStartHz | ||||
| end := goals.MonitorEndHz | end := goals.MonitorEndHz | ||||
| span := end - start | span := end - start | ||||
| return []MonitorWindow{{ | |||||
| return finalizeMonitorWindows([]MonitorWindow{{ | |||||
| Label: "primary", | Label: "primary", | ||||
| StartHz: start, | StartHz: start, | ||||
| EndHz: end, | EndHz: end, | ||||
| CenterHz: (start + end) / 2, | CenterHz: (start + end) / 2, | ||||
| SpanHz: span, | SpanHz: span, | ||||
| Source: "goals:bounds", | Source: "goals:bounds", | ||||
| }} | |||||
| }}) | |||||
| } | } | ||||
| if goals.MonitorSpanHz > 0 && centerHz != 0 { | if goals.MonitorSpanHz > 0 && centerHz != 0 { | ||||
| half := goals.MonitorSpanHz / 2 | half := goals.MonitorSpanHz / 2 | ||||
| start := centerHz - half | start := centerHz - half | ||||
| end := centerHz + half | end := centerHz + half | ||||
| return []MonitorWindow{{ | |||||
| return finalizeMonitorWindows([]MonitorWindow{{ | |||||
| Label: "primary", | Label: "primary", | ||||
| StartHz: start, | StartHz: start, | ||||
| EndHz: end, | EndHz: end, | ||||
| CenterHz: centerHz, | CenterHz: centerHz, | ||||
| SpanHz: goals.MonitorSpanHz, | SpanHz: goals.MonitorSpanHz, | ||||
| Source: "goals:span", | Source: "goals:span", | ||||
| }} | |||||
| }}) | |||||
| } | } | ||||
| return nil | return nil | ||||
| } | } | ||||
| func finalizeMonitorWindows(windows []MonitorWindow) []MonitorWindow { | |||||
| if len(windows) == 0 { | |||||
| return nil | |||||
| } | |||||
| maxSpan := 0.0 | |||||
| for _, w := range windows { | |||||
| if w.SpanHz > maxSpan { | |||||
| maxSpan = w.SpanHz | |||||
| } | |||||
| } | |||||
| for i := range windows { | |||||
| windows[i].Index = i | |||||
| if maxSpan > 0 && len(windows) > 1 && windows[i].SpanHz > 0 { | |||||
| bias := maxMonitorWindowBias * (1 - (windows[i].SpanHz / maxSpan)) | |||||
| if bias < 0 { | |||||
| bias = 0 | |||||
| } | |||||
| windows[i].PriorityBias = bias | |||||
| } | |||||
| } | |||||
| return windows | |||||
| } | |||||
| func MonitorWindowBounds(windows []MonitorWindow) (float64, float64, bool) { | func MonitorWindowBounds(windows []MonitorWindow) (float64, float64, bool) { | ||||
| minStart := 0.0 | minStart := 0.0 | ||||
| maxEnd := 0.0 | maxEnd := 0.0 | ||||
| @@ -114,16 +143,8 @@ func monitorBounds(policy Policy) (float64, float64, bool) { | |||||
| func candidateInMonitor(policy Policy, candidate Candidate) bool { | func candidateInMonitor(policy Policy, candidate Candidate) bool { | ||||
| if len(policy.MonitorWindows) > 0 { | if len(policy.MonitorWindows) > 0 { | ||||
| left, right := candidateBounds(candidate) | |||||
| for _, win := range policy.MonitorWindows { | |||||
| if win.StartHz <= 0 || win.EndHz <= 0 || win.EndHz <= win.StartHz { | |||||
| continue | |||||
| } | |||||
| if right >= win.StartHz && left <= win.EndHz { | |||||
| return true | |||||
| } | |||||
| } | |||||
| return false | |||||
| matches := MonitorWindowMatchesForCandidate(policy.MonitorWindows, candidate) | |||||
| return len(matches) > 0 | |||||
| } | } | ||||
| start, end, ok := monitorBounds(policy) | start, end, ok := monitorBounds(policy) | ||||
| if !ok { | if !ok { | ||||
| @@ -142,3 +163,115 @@ func candidateBounds(candidate Candidate) (float64, float64) { | |||||
| } | } | ||||
| return left, right | return left, right | ||||
| } | } | ||||
| func ApplyMonitorWindowMatches(policy Policy, candidate *Candidate) bool { | |||||
| if candidate == nil { | |||||
| return true | |||||
| } | |||||
| if len(policy.MonitorWindows) == 0 { | |||||
| candidate.MonitorMatches = nil | |||||
| if start, end, ok := monitorBounds(policy); ok { | |||||
| left, right := candidateBounds(*candidate) | |||||
| if right < start || left > end { | |||||
| return false | |||||
| } | |||||
| } | |||||
| return true | |||||
| } | |||||
| matches := MonitorWindowMatchesForCandidate(policy.MonitorWindows, *candidate) | |||||
| if len(matches) == 0 { | |||||
| candidate.MonitorMatches = nil | |||||
| return false | |||||
| } | |||||
| candidate.MonitorMatches = matches | |||||
| return true | |||||
| } | |||||
| func ApplyMonitorWindowMatchesToCandidates(policy Policy, candidates []Candidate) { | |||||
| if len(candidates) == 0 || len(policy.MonitorWindows) == 0 { | |||||
| return | |||||
| } | |||||
| for i := range candidates { | |||||
| _ = ApplyMonitorWindowMatches(policy, &candidates[i]) | |||||
| } | |||||
| } | |||||
| func MonitorWindowMatches(policy Policy, candidate Candidate) []MonitorWindowMatch { | |||||
| return MonitorWindowMatchesForCandidate(policy.MonitorWindows, candidate) | |||||
| } | |||||
| func MonitorWindowMatchesForCandidate(windows []MonitorWindow, candidate Candidate) []MonitorWindowMatch { | |||||
| if len(windows) == 0 { | |||||
| return nil | |||||
| } | |||||
| left, right := candidateBounds(candidate) | |||||
| pointCandidate := candidate.BandwidthHz <= 0 | |||||
| matches := make([]MonitorWindowMatch, 0, len(windows)) | |||||
| for _, win := range windows { | |||||
| if win.StartHz <= 0 || win.EndHz <= 0 || win.EndHz <= win.StartHz { | |||||
| continue | |||||
| } | |||||
| if right < win.StartHz || left > win.EndHz { | |||||
| continue | |||||
| } | |||||
| overlap := math.Min(right, win.EndHz) - math.Max(left, win.StartHz) | |||||
| coverage := 0.0 | |||||
| if win.SpanHz > 0 && overlap > 0 { | |||||
| coverage = overlap / win.SpanHz | |||||
| } | |||||
| if pointCandidate && candidate.CenterHz >= win.StartHz && candidate.CenterHz <= win.EndHz { | |||||
| coverage = 1 | |||||
| } | |||||
| if coverage < 0 { | |||||
| coverage = 0 | |||||
| } | |||||
| if coverage > 1 { | |||||
| coverage = 1 | |||||
| } | |||||
| center := win.CenterHz | |||||
| if center == 0 { | |||||
| center = (win.StartHz + win.EndHz) / 2 | |||||
| } | |||||
| distance := math.Abs(candidate.CenterHz - center) | |||||
| bias := win.PriorityBias * coverage | |||||
| matches = append(matches, MonitorWindowMatch{ | |||||
| Index: win.Index, | |||||
| Label: win.Label, | |||||
| Source: win.Source, | |||||
| StartHz: win.StartHz, | |||||
| EndHz: win.EndHz, | |||||
| CenterHz: center, | |||||
| SpanHz: win.SpanHz, | |||||
| OverlapHz: overlap, | |||||
| Coverage: coverage, | |||||
| DistanceHz: distance, | |||||
| Bias: bias, | |||||
| }) | |||||
| } | |||||
| if len(matches) == 0 { | |||||
| return nil | |||||
| } | |||||
| return matches | |||||
| } | |||||
| func MonitorWindowBias(policy Policy, candidate Candidate) (float64, *MonitorWindowMatch) { | |||||
| matches := candidate.MonitorMatches | |||||
| if len(matches) == 0 { | |||||
| matches = MonitorWindowMatches(policy, candidate) | |||||
| } | |||||
| if len(matches) == 0 { | |||||
| return 0, nil | |||||
| } | |||||
| bestIdx := 0 | |||||
| for i := 1; i < len(matches); i++ { | |||||
| if matches[i].Bias > matches[bestIdx].Bias { | |||||
| bestIdx = i | |||||
| continue | |||||
| } | |||||
| if matches[i].Bias == matches[bestIdx].Bias && matches[i].Coverage > matches[bestIdx].Coverage { | |||||
| bestIdx = i | |||||
| } | |||||
| } | |||||
| best := matches[bestIdx] | |||||
| return best.Bias, &best | |||||
| } | |||||
| @@ -54,3 +54,39 @@ func TestCandidateInMonitorWindows(t *testing.T) { | |||||
| t.Fatalf("expected candidate outside windows") | t.Fatalf("expected candidate outside windows") | ||||
| } | } | ||||
| } | } | ||||
| func TestMonitorWindowMatchesOverlap(t *testing.T) { | |||||
| policy := Policy{ | |||||
| MonitorWindows: finalizeMonitorWindows([]MonitorWindow{ | |||||
| {Label: "wide", StartHz: 100, EndHz: 300, SpanHz: 200}, | |||||
| {Label: "narrow", StartHz: 150, EndHz: 220, SpanHz: 70}, | |||||
| }), | |||||
| } | |||||
| matches := MonitorWindowMatches(policy, Candidate{CenterHz: 180, BandwidthHz: 20}) | |||||
| if len(matches) != 2 { | |||||
| t.Fatalf("expected 2 matches, got %d", len(matches)) | |||||
| } | |||||
| if matches[0].Index == matches[1].Index { | |||||
| t.Fatalf("expected distinct window matches") | |||||
| } | |||||
| } | |||||
| func TestMonitorWindowBiasPrefersNarrowWindow(t *testing.T) { | |||||
| goals := config.PipelineGoalConfig{ | |||||
| MonitorWindows: []config.MonitorWindow{ | |||||
| {Label: "wide", StartHz: 100, EndHz: 300}, | |||||
| {Label: "narrow", StartHz: 150, EndHz: 200}, | |||||
| }, | |||||
| } | |||||
| policy := Policy{MonitorWindows: NormalizeMonitorWindows(goals, 0)} | |||||
| bias, detail := MonitorWindowBias(policy, Candidate{CenterHz: 175, BandwidthHz: 10}) | |||||
| if detail == nil { | |||||
| t.Fatalf("expected monitor match detail") | |||||
| } | |||||
| if detail.Label != "narrow" { | |||||
| t.Fatalf("expected narrow window to be preferred, got %q", detail.Label) | |||||
| } | |||||
| if bias <= 0 { | |||||
| t.Fatalf("expected positive bias, got %.3f", bias) | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,45 @@ | |||||
| package pipeline | |||||
| import "testing" | |||||
| func TestMonitorWindowStatsAttribution(t *testing.T) { | |||||
| policy := Policy{ | |||||
| MonitorWindows: finalizeMonitorWindows([]MonitorWindow{ | |||||
| {Label: "wide", StartHz: 100, EndHz: 300, SpanHz: 200}, | |||||
| {Label: "narrow", StartHz: 150, EndHz: 250, SpanHz: 100}, | |||||
| }), | |||||
| MinCandidateSNRDb: 5, | |||||
| MaxRefinementJobs: 5, | |||||
| } | |||||
| candidates := []Candidate{ | |||||
| {ID: 1, CenterHz: 160, BandwidthHz: 10, SNRDb: 8}, | |||||
| {ID: 2, CenterHz: 260, BandwidthHz: 10, SNRDb: 2}, | |||||
| {ID: 3, CenterHz: 500, BandwidthHz: 10, SNRDb: 12}, | |||||
| } | |||||
| plan := BuildRefinementPlan(candidates, policy) | |||||
| if plan.DroppedByMonitor != 1 { | |||||
| t.Fatalf("expected 1 dropped by monitor, got %d", plan.DroppedByMonitor) | |||||
| } | |||||
| if len(plan.MonitorWindowStats) != 2 { | |||||
| t.Fatalf("expected 2 window stats, got %d", len(plan.MonitorWindowStats)) | |||||
| } | |||||
| var wide, narrow *MonitorWindowStats | |||||
| for i := range plan.MonitorWindowStats { | |||||
| stat := &plan.MonitorWindowStats[i] | |||||
| switch stat.Label { | |||||
| case "wide": | |||||
| wide = stat | |||||
| case "narrow": | |||||
| narrow = stat | |||||
| } | |||||
| } | |||||
| if wide == nil || narrow == nil { | |||||
| t.Fatalf("expected both window stats to be present") | |||||
| } | |||||
| if wide.Candidates != 2 || wide.Planned != 1 || wide.Dropped != 1 { | |||||
| t.Fatalf("unexpected wide stats: %+v", *wide) | |||||
| } | |||||
| if narrow.Candidates != 1 || narrow.Planned != 1 || narrow.Dropped != 0 { | |||||
| t.Fatalf("unexpected narrow stats: %+v", *narrow) | |||||
| } | |||||
| } | |||||
| @@ -54,27 +54,28 @@ type SurveillanceResult struct { | |||||
| } | } | ||||
| type RefinementPlan struct { | type RefinementPlan struct { | ||||
| TotalCandidates int `json:"total_candidates"` | |||||
| MinCandidateSNRDb float64 `json:"min_candidate_snr_db"` | |||||
| Budget int `json:"budget"` | |||||
| BudgetSource string `json:"budget_source,omitempty"` | |||||
| Strategy string `json:"strategy,omitempty"` | |||||
| StrategyReason string `json:"strategy_reason,omitempty"` | |||||
| MonitorStartHz float64 `json:"monitor_start_hz,omitempty"` | |||||
| MonitorEndHz float64 `json:"monitor_end_hz,omitempty"` | |||||
| MonitorSpanHz float64 `json:"monitor_span_hz,omitempty"` | |||||
| MonitorWindows []MonitorWindow `json:"monitor_windows,omitempty"` | |||||
| DroppedByMonitor int `json:"dropped_by_monitor"` | |||||
| DroppedBySNR int `json:"dropped_by_snr"` | |||||
| DroppedByBudget int `json:"dropped_by_budget"` | |||||
| ScoreModel RefinementScoreModel `json:"score_model,omitempty"` | |||||
| PriorityMin float64 `json:"priority_min,omitempty"` | |||||
| PriorityMax float64 `json:"priority_max,omitempty"` | |||||
| PriorityAvg float64 `json:"priority_avg,omitempty"` | |||||
| PriorityCutoff float64 `json:"priority_cutoff,omitempty"` | |||||
| Ranked []ScheduledCandidate `json:"ranked,omitempty"` | |||||
| Selected []ScheduledCandidate `json:"selected,omitempty"` | |||||
| WorkItems []RefinementWorkItem `json:"work_items,omitempty"` | |||||
| TotalCandidates int `json:"total_candidates"` | |||||
| MinCandidateSNRDb float64 `json:"min_candidate_snr_db"` | |||||
| Budget int `json:"budget"` | |||||
| BudgetSource string `json:"budget_source,omitempty"` | |||||
| Strategy string `json:"strategy,omitempty"` | |||||
| StrategyReason string `json:"strategy_reason,omitempty"` | |||||
| MonitorStartHz float64 `json:"monitor_start_hz,omitempty"` | |||||
| MonitorEndHz float64 `json:"monitor_end_hz,omitempty"` | |||||
| MonitorSpanHz float64 `json:"monitor_span_hz,omitempty"` | |||||
| MonitorWindows []MonitorWindow `json:"monitor_windows,omitempty"` | |||||
| MonitorWindowStats []MonitorWindowStats `json:"monitor_window_stats,omitempty"` | |||||
| DroppedByMonitor int `json:"dropped_by_monitor"` | |||||
| DroppedBySNR int `json:"dropped_by_snr"` | |||||
| DroppedByBudget int `json:"dropped_by_budget"` | |||||
| ScoreModel RefinementScoreModel `json:"score_model,omitempty"` | |||||
| PriorityMin float64 `json:"priority_min,omitempty"` | |||||
| PriorityMax float64 `json:"priority_max,omitempty"` | |||||
| PriorityAvg float64 `json:"priority_avg,omitempty"` | |||||
| PriorityCutoff float64 `json:"priority_cutoff,omitempty"` | |||||
| Ranked []ScheduledCandidate `json:"ranked,omitempty"` | |||||
| Selected []ScheduledCandidate `json:"selected,omitempty"` | |||||
| WorkItems []RefinementWorkItem `json:"work_items,omitempty"` | |||||
| } | } | ||||
| type RefinementRequest struct { | type RefinementRequest struct { | ||||
| @@ -28,6 +28,8 @@ type RefinementScoreDetails struct { | |||||
| BandwidthScore float64 `json:"bandwidth_score"` | BandwidthScore float64 `json:"bandwidth_score"` | ||||
| PeakScore float64 `json:"peak_score"` | PeakScore float64 `json:"peak_score"` | ||||
| PolicyBoost float64 `json:"policy_boost"` | PolicyBoost float64 `json:"policy_boost"` | ||||
| MonitorBias float64 `json:"monitor_bias,omitempty"` | |||||
| MonitorDetail *MonitorWindowMatch `json:"monitor_detail,omitempty"` | |||||
| EvidenceScore float64 `json:"evidence_score"` | EvidenceScore float64 `json:"evidence_score"` | ||||
| EvidenceDetail *EvidenceScoreDetails `json:"evidence_detail,omitempty"` | EvidenceDetail *EvidenceScoreDetails `json:"evidence_detail,omitempty"` | ||||
| } | } | ||||
| @@ -111,6 +113,7 @@ func BuildRefinementPlanWithBudget(candidates []Candidate, policy Policy, budget | |||||
| } | } | ||||
| if len(policy.MonitorWindows) > 0 { | if len(policy.MonitorWindows) > 0 { | ||||
| plan.MonitorWindows = append([]MonitorWindow(nil), policy.MonitorWindows...) | plan.MonitorWindows = append([]MonitorWindow(nil), policy.MonitorWindows...) | ||||
| plan.MonitorWindowStats = buildMonitorWindowStats(policy.MonitorWindows) | |||||
| } | } | ||||
| if len(candidates) == 0 { | if len(candidates) == 0 { | ||||
| return plan | return plan | ||||
| @@ -132,7 +135,8 @@ func BuildRefinementPlanWithBudget(candidates []Candidate, policy Policy, budget | |||||
| family, familyRank := signalPriorityMatch(policy, candidate.Hint, "") | family, familyRank := signalPriorityMatch(policy, candidate.Hint, "") | ||||
| familyFloor := signalPriorityTierFloor(familyRank) | familyFloor := signalPriorityTierFloor(familyRank) | ||||
| familyRankOut := familyRankForOutput(familyRank) | familyRankOut := familyRankForOutput(familyRank) | ||||
| if !candidateInMonitor(policy, candidate) { | |||||
| inMonitor := ApplyMonitorWindowMatches(policy, &candidate) | |||||
| if !inMonitor { | |||||
| plan.DroppedByMonitor++ | plan.DroppedByMonitor++ | ||||
| workItems = append(workItems, RefinementWorkItem{ | workItems = append(workItems, RefinementWorkItem{ | ||||
| Candidate: candidate, | Candidate: candidate, | ||||
| @@ -150,8 +154,10 @@ func BuildRefinementPlanWithBudget(candidates []Candidate, policy Policy, budget | |||||
| }) | }) | ||||
| continue | continue | ||||
| } | } | ||||
| updateMonitorWindowStats(plan.MonitorWindowStats, candidate.MonitorMatches, monitorStatCandidates) | |||||
| if candidate.SNRDb < policy.MinCandidateSNRDb { | if candidate.SNRDb < policy.MinCandidateSNRDb { | ||||
| plan.DroppedBySNR++ | plan.DroppedBySNR++ | ||||
| updateMonitorWindowStats(plan.MonitorWindowStats, candidate.MonitorMatches, monitorStatDropped) | |||||
| workItems = append(workItems, RefinementWorkItem{ | workItems = append(workItems, RefinementWorkItem{ | ||||
| Candidate: candidate, | Candidate: candidate, | ||||
| Status: RefinementStatusDropped, | Status: RefinementStatusDropped, | ||||
| @@ -172,6 +178,7 @@ func BuildRefinementPlanWithBudget(candidates []Candidate, policy Policy, budget | |||||
| bwScore := 0.0 | bwScore := 0.0 | ||||
| peakScore := 0.0 | peakScore := 0.0 | ||||
| policyBoost := CandidatePriorityBoost(policy, candidate.Hint) | policyBoost := CandidatePriorityBoost(policy, candidate.Hint) | ||||
| monitorBias, monitorDetail := MonitorWindowBias(policy, candidate) | |||||
| if candidate.BandwidthHz > 0 { | if candidate.BandwidthHz > 0 { | ||||
| bwScore = minFloat64(candidate.BandwidthHz/25000.0, 6) * scoreModel.BandwidthWeight | bwScore = minFloat64(candidate.BandwidthHz/25000.0, 6) * scoreModel.BandwidthWeight | ||||
| } | } | ||||
| @@ -183,7 +190,7 @@ func BuildRefinementPlanWithBudget(candidates []Candidate, policy Policy, budget | |||||
| evidenceDetail.RawScore = rawEvidenceScore | evidenceDetail.RawScore = rawEvidenceScore | ||||
| evidenceDetail.WeightedScore = rawEvidenceScore * scoreModel.EvidenceWeight | evidenceDetail.WeightedScore = rawEvidenceScore * scoreModel.EvidenceWeight | ||||
| evidenceScore := evidenceDetail.WeightedScore | evidenceScore := evidenceDetail.WeightedScore | ||||
| priority := snrScore + bwScore + peakScore + policyBoost | |||||
| priority := snrScore + bwScore + peakScore + policyBoost + monitorBias | |||||
| priority += evidenceScore | priority += evidenceScore | ||||
| score := &RefinementScore{ | score := &RefinementScore{ | ||||
| Total: priority, | Total: priority, | ||||
| @@ -192,6 +199,8 @@ func BuildRefinementPlanWithBudget(candidates []Candidate, policy Policy, budget | |||||
| BandwidthScore: bwScore, | BandwidthScore: bwScore, | ||||
| PeakScore: peakScore, | PeakScore: peakScore, | ||||
| PolicyBoost: policyBoost, | PolicyBoost: policyBoost, | ||||
| MonitorBias: monitorBias, | |||||
| MonitorDetail: monitorDetail, | |||||
| EvidenceScore: evidenceScore, | EvidenceScore: evidenceScore, | ||||
| EvidenceDetail: &evidenceDetail, | EvidenceDetail: &evidenceDetail, | ||||
| }, | }, | ||||
| @@ -223,6 +232,7 @@ func BuildRefinementPlanWithBudget(candidates []Candidate, policy Policy, budget | |||||
| Reason: admissionReason(RefinementReasonPlanned, policy, holdPolicy), | Reason: admissionReason(RefinementReasonPlanned, policy, holdPolicy), | ||||
| }, | }, | ||||
| }) | }) | ||||
| updateMonitorWindowStats(plan.MonitorWindowStats, candidate.MonitorMatches, monitorStatPlanned) | |||||
| } | } | ||||
| 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 { | ||||
| @@ -387,3 +397,55 @@ func minFloat64(a, b float64) float64 { | |||||
| } | } | ||||
| return b | return b | ||||
| } | } | ||||
| type monitorStatUpdate int | |||||
| const ( | |||||
| monitorStatCandidates monitorStatUpdate = iota | |||||
| monitorStatPlanned | |||||
| monitorStatDropped | |||||
| ) | |||||
| func buildMonitorWindowStats(windows []MonitorWindow) []MonitorWindowStats { | |||||
| if len(windows) == 0 { | |||||
| return nil | |||||
| } | |||||
| stats := make([]MonitorWindowStats, 0, len(windows)) | |||||
| for _, win := range windows { | |||||
| stats = append(stats, MonitorWindowStats{ | |||||
| Index: win.Index, | |||||
| Label: win.Label, | |||||
| Source: win.Source, | |||||
| StartHz: win.StartHz, | |||||
| EndHz: win.EndHz, | |||||
| CenterHz: win.CenterHz, | |||||
| SpanHz: win.SpanHz, | |||||
| PriorityBias: win.PriorityBias, | |||||
| }) | |||||
| } | |||||
| return stats | |||||
| } | |||||
| func updateMonitorWindowStats(stats []MonitorWindowStats, matches []MonitorWindowMatch, update monitorStatUpdate) { | |||||
| if len(stats) == 0 || len(matches) == 0 { | |||||
| return | |||||
| } | |||||
| index := make(map[int]int, len(stats)) | |||||
| for i := range stats { | |||||
| index[stats[i].Index] = i | |||||
| } | |||||
| for _, match := range matches { | |||||
| i, ok := index[match.Index] | |||||
| if !ok { | |||||
| continue | |||||
| } | |||||
| switch update { | |||||
| case monitorStatCandidates: | |||||
| stats[i].Candidates++ | |||||
| case monitorStatPlanned: | |||||
| stats[i].Planned++ | |||||
| case monitorStatDropped: | |||||
| stats[i].Dropped++ | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -8,18 +8,19 @@ import ( | |||||
| // Candidate is the coarse output of the surveillance detector. | // Candidate is the coarse output of the surveillance detector. | ||||
| // It intentionally stays lightweight and cheap to produce. | // It intentionally stays lightweight and cheap to produce. | ||||
| type Candidate struct { | type Candidate struct { | ||||
| ID int64 `json:"id"` | |||||
| CenterHz float64 `json:"center_hz"` | |||||
| BandwidthHz float64 `json:"bandwidth_hz"` | |||||
| PeakDb float64 `json:"peak_db"` | |||||
| SNRDb float64 `json:"snr_db"` | |||||
| FirstBin int `json:"first_bin"` | |||||
| LastBin int `json:"last_bin"` | |||||
| NoiseDb float64 `json:"noise_db,omitempty"` | |||||
| Source string `json:"source,omitempty"` | |||||
| Hint string `json:"hint,omitempty"` | |||||
| Evidence []LevelEvidence `json:"evidence,omitempty"` | |||||
| EvidenceState *CandidateEvidenceState `json:"evidence_state,omitempty"` | |||||
| ID int64 `json:"id"` | |||||
| CenterHz float64 `json:"center_hz"` | |||||
| BandwidthHz float64 `json:"bandwidth_hz"` | |||||
| PeakDb float64 `json:"peak_db"` | |||||
| SNRDb float64 `json:"snr_db"` | |||||
| FirstBin int `json:"first_bin"` | |||||
| LastBin int `json:"last_bin"` | |||||
| NoiseDb float64 `json:"noise_db,omitempty"` | |||||
| Source string `json:"source,omitempty"` | |||||
| Hint string `json:"hint,omitempty"` | |||||
| Evidence []LevelEvidence `json:"evidence,omitempty"` | |||||
| EvidenceState *CandidateEvidenceState `json:"evidence_state,omitempty"` | |||||
| MonitorMatches []MonitorWindowMatch `json:"monitor_matches,omitempty"` | |||||
| } | } | ||||
| // LevelEvidence captures which analysis level produced a candidate. | // LevelEvidence captures which analysis level produced a candidate. | ||||
| @@ -31,12 +32,44 @@ type LevelEvidence struct { | |||||
| // MonitorWindow describes a monitoring window to gate candidates. | // MonitorWindow describes a monitoring window to gate candidates. | ||||
| type MonitorWindow struct { | type MonitorWindow struct { | ||||
| Label string `json:"label,omitempty"` | |||||
| StartHz float64 `json:"start_hz,omitempty"` | |||||
| EndHz float64 `json:"end_hz,omitempty"` | |||||
| CenterHz float64 `json:"center_hz,omitempty"` | |||||
| SpanHz float64 `json:"span_hz,omitempty"` | |||||
| Source string `json:"source,omitempty"` | |||||
| Index int `json:"index,omitempty"` | |||||
| Label string `json:"label,omitempty"` | |||||
| StartHz float64 `json:"start_hz,omitempty"` | |||||
| EndHz float64 `json:"end_hz,omitempty"` | |||||
| CenterHz float64 `json:"center_hz,omitempty"` | |||||
| SpanHz float64 `json:"span_hz,omitempty"` | |||||
| Source string `json:"source,omitempty"` | |||||
| PriorityBias float64 `json:"priority_bias,omitempty"` | |||||
| } | |||||
| // MonitorWindowMatch captures how a candidate overlaps a monitor window. | |||||
| type MonitorWindowMatch struct { | |||||
| Index int `json:"index"` | |||||
| Label string `json:"label,omitempty"` | |||||
| Source string `json:"source,omitempty"` | |||||
| StartHz float64 `json:"start_hz,omitempty"` | |||||
| EndHz float64 `json:"end_hz,omitempty"` | |||||
| CenterHz float64 `json:"center_hz,omitempty"` | |||||
| SpanHz float64 `json:"span_hz,omitempty"` | |||||
| OverlapHz float64 `json:"overlap_hz,omitempty"` | |||||
| Coverage float64 `json:"coverage,omitempty"` | |||||
| DistanceHz float64 `json:"distance_hz,omitempty"` | |||||
| Bias float64 `json:"bias,omitempty"` | |||||
| } | |||||
| // MonitorWindowStats summarizes candidate attribution per monitor window. | |||||
| type MonitorWindowStats struct { | |||||
| Index int `json:"index"` | |||||
| Label string `json:"label,omitempty"` | |||||
| Source string `json:"source,omitempty"` | |||||
| StartHz float64 `json:"start_hz,omitempty"` | |||||
| EndHz float64 `json:"end_hz,omitempty"` | |||||
| CenterHz float64 `json:"center_hz,omitempty"` | |||||
| SpanHz float64 `json:"span_hz,omitempty"` | |||||
| PriorityBias float64 `json:"priority_bias,omitempty"` | |||||
| Candidates int `json:"candidates,omitempty"` | |||||
| Planned int `json:"planned,omitempty"` | |||||
| Dropped int `json:"dropped,omitempty"` | |||||
| } | } | ||||
| // RefinementWindow describes the local analysis span that refinement should use. | // RefinementWindow describes the local analysis span that refinement should use. | ||||