diff --git a/cmd/sdrd/pipeline_runtime.go b/cmd/sdrd/pipeline_runtime.go index 5f5305b..a7d2d41 100644 --- a/cmd/sdrd/pipeline_runtime.go +++ b/cmd/sdrd/pipeline_runtime.go @@ -441,6 +441,7 @@ func (rt *dspRuntime) buildSurveillanceResult(art *spectrumArtifacts) pipeline.S primaryCandidates := pipeline.CandidatesFromSignalsWithLevel(art.detected, "surveillance-detector", plan.Primary) derivedCandidates := rt.detectDerivedCandidates(art, plan) candidates := pipeline.FuseCandidates(primaryCandidates, derivedCandidates) + pipeline.ApplyMonitorWindowMatchesToCandidates(policy, candidates) scheduled := pipeline.ScheduleCandidates(candidates, policy) return pipeline.SurveillanceResult{ Level: plan.Primary, diff --git a/internal/pipeline/monitor_rules.go b/internal/pipeline/monitor_rules.go index 128dde5..47a247a 100644 --- a/internal/pipeline/monitor_rules.go +++ b/internal/pipeline/monitor_rules.go @@ -1,6 +1,12 @@ 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 { if len(goals.MonitorWindows) > 0 { @@ -11,38 +17,61 @@ func NormalizeMonitorWindows(goals config.PipelineGoalConfig, centerHz float64) } } if len(windows) > 0 { - return windows + return finalizeMonitorWindows(windows) } } if goals.MonitorStartHz > 0 && goals.MonitorEndHz > goals.MonitorStartHz { start := goals.MonitorStartHz end := goals.MonitorEndHz span := end - start - return []MonitorWindow{{ + return finalizeMonitorWindows([]MonitorWindow{{ Label: "primary", StartHz: start, EndHz: end, CenterHz: (start + end) / 2, SpanHz: span, Source: "goals:bounds", - }} + }}) } if goals.MonitorSpanHz > 0 && centerHz != 0 { half := goals.MonitorSpanHz / 2 start := centerHz - half end := centerHz + half - return []MonitorWindow{{ + return finalizeMonitorWindows([]MonitorWindow{{ Label: "primary", StartHz: start, EndHz: end, CenterHz: centerHz, SpanHz: goals.MonitorSpanHz, Source: "goals:span", - }} + }}) } 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) { minStart := 0.0 maxEnd := 0.0 @@ -114,16 +143,8 @@ func monitorBounds(policy Policy) (float64, float64, bool) { func candidateInMonitor(policy Policy, candidate Candidate) bool { 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) if !ok { @@ -142,3 +163,115 @@ func candidateBounds(candidate Candidate) (float64, float64) { } 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 +} diff --git a/internal/pipeline/monitor_rules_test.go b/internal/pipeline/monitor_rules_test.go index 4fc272e..2e4b3a1 100644 --- a/internal/pipeline/monitor_rules_test.go +++ b/internal/pipeline/monitor_rules_test.go @@ -54,3 +54,39 @@ func TestCandidateInMonitorWindows(t *testing.T) { 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) + } +} diff --git a/internal/pipeline/monitor_window_stats_test.go b/internal/pipeline/monitor_window_stats_test.go new file mode 100644 index 0000000..17b592b --- /dev/null +++ b/internal/pipeline/monitor_window_stats_test.go @@ -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) + } +} diff --git a/internal/pipeline/phases.go b/internal/pipeline/phases.go index 5739187..50dca29 100644 --- a/internal/pipeline/phases.go +++ b/internal/pipeline/phases.go @@ -54,27 +54,28 @@ type SurveillanceResult 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 { diff --git a/internal/pipeline/scheduler.go b/internal/pipeline/scheduler.go index 3441c4b..7a84b9a 100644 --- a/internal/pipeline/scheduler.go +++ b/internal/pipeline/scheduler.go @@ -28,6 +28,8 @@ type RefinementScoreDetails struct { BandwidthScore float64 `json:"bandwidth_score"` PeakScore float64 `json:"peak_score"` PolicyBoost float64 `json:"policy_boost"` + MonitorBias float64 `json:"monitor_bias,omitempty"` + MonitorDetail *MonitorWindowMatch `json:"monitor_detail,omitempty"` EvidenceScore float64 `json:"evidence_score"` EvidenceDetail *EvidenceScoreDetails `json:"evidence_detail,omitempty"` } @@ -111,6 +113,7 @@ func BuildRefinementPlanWithBudget(candidates []Candidate, policy Policy, budget } if len(policy.MonitorWindows) > 0 { plan.MonitorWindows = append([]MonitorWindow(nil), policy.MonitorWindows...) + plan.MonitorWindowStats = buildMonitorWindowStats(policy.MonitorWindows) } if len(candidates) == 0 { return plan @@ -132,7 +135,8 @@ func BuildRefinementPlanWithBudget(candidates []Candidate, policy Policy, budget family, familyRank := signalPriorityMatch(policy, candidate.Hint, "") familyFloor := signalPriorityTierFloor(familyRank) familyRankOut := familyRankForOutput(familyRank) - if !candidateInMonitor(policy, candidate) { + inMonitor := ApplyMonitorWindowMatches(policy, &candidate) + if !inMonitor { plan.DroppedByMonitor++ workItems = append(workItems, RefinementWorkItem{ Candidate: candidate, @@ -150,8 +154,10 @@ func BuildRefinementPlanWithBudget(candidates []Candidate, policy Policy, budget }) continue } + updateMonitorWindowStats(plan.MonitorWindowStats, candidate.MonitorMatches, monitorStatCandidates) if candidate.SNRDb < policy.MinCandidateSNRDb { plan.DroppedBySNR++ + updateMonitorWindowStats(plan.MonitorWindowStats, candidate.MonitorMatches, monitorStatDropped) workItems = append(workItems, RefinementWorkItem{ Candidate: candidate, Status: RefinementStatusDropped, @@ -172,6 +178,7 @@ func BuildRefinementPlanWithBudget(candidates []Candidate, policy Policy, budget bwScore := 0.0 peakScore := 0.0 policyBoost := CandidatePriorityBoost(policy, candidate.Hint) + monitorBias, monitorDetail := MonitorWindowBias(policy, candidate) if candidate.BandwidthHz > 0 { bwScore = minFloat64(candidate.BandwidthHz/25000.0, 6) * scoreModel.BandwidthWeight } @@ -183,7 +190,7 @@ func BuildRefinementPlanWithBudget(candidates []Candidate, policy Policy, budget evidenceDetail.RawScore = rawEvidenceScore evidenceDetail.WeightedScore = rawEvidenceScore * scoreModel.EvidenceWeight evidenceScore := evidenceDetail.WeightedScore - priority := snrScore + bwScore + peakScore + policyBoost + priority := snrScore + bwScore + peakScore + policyBoost + monitorBias priority += evidenceScore score := &RefinementScore{ Total: priority, @@ -192,6 +199,8 @@ func BuildRefinementPlanWithBudget(candidates []Candidate, policy Policy, budget BandwidthScore: bwScore, PeakScore: peakScore, PolicyBoost: policyBoost, + MonitorBias: monitorBias, + MonitorDetail: monitorDetail, EvidenceScore: evidenceScore, EvidenceDetail: &evidenceDetail, }, @@ -223,6 +232,7 @@ func BuildRefinementPlanWithBudget(candidates []Candidate, policy Policy, budget Reason: admissionReason(RefinementReasonPlanned, policy, holdPolicy), }, }) + updateMonitorWindowStats(plan.MonitorWindowStats, candidate.MonitorMatches, monitorStatPlanned) } sort.Slice(scored, func(i, j int) bool { if scored[i].Priority == scored[j].Priority { @@ -387,3 +397,55 @@ func minFloat64(a, b float64) float64 { } 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++ + } + } +} diff --git a/internal/pipeline/types.go b/internal/pipeline/types.go index e41a51f..a7b173c 100644 --- a/internal/pipeline/types.go +++ b/internal/pipeline/types.go @@ -8,18 +8,19 @@ import ( // Candidate is the coarse output of the surveillance detector. // It intentionally stays lightweight and cheap to produce. 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. @@ -31,12 +32,44 @@ type LevelEvidence struct { // MonitorWindow describes a monitoring window to gate candidates. 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.