Просмотр исходного кода

Add monitor window matches and stats

master
Jan Svabenik 7 часов назад
Родитель
Сommit
ac64d6bf50
7 измененных файлов: 368 добавлений и 57 удалений
  1. +1
    -0
      cmd/sdrd/pipeline_runtime.go
  2. +149
    -16
      internal/pipeline/monitor_rules.go
  3. +36
    -0
      internal/pipeline/monitor_rules_test.go
  4. +45
    -0
      internal/pipeline/monitor_window_stats_test.go
  5. +22
    -21
      internal/pipeline/phases.go
  6. +64
    -2
      internal/pipeline/scheduler.go
  7. +51
    -18
      internal/pipeline/types.go

+ 1
- 0
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,


+ 149
- 16
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
}

+ 36
- 0
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)
}
}

+ 45
- 0
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)
}
}

+ 22
- 21
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 {


+ 64
- 2
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++
}
}
}

+ 51
- 18
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.


Загрузка…
Отмена
Сохранить