| @@ -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, | |||
| @@ -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 | |||
| } | |||
| @@ -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) | |||
| } | |||
| } | |||
| @@ -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 { | |||
| 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 { | |||
| @@ -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++ | |||
| } | |||
| } | |||
| } | |||
| @@ -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. | |||