| @@ -296,12 +296,16 @@ func (rt *dspRuntime) buildRefinementInput(surv pipeline.SurveillanceResult) pip | |||||
| for _, sc := range scheduled { | for _, sc := range scheduled { | ||||
| windows = append(windows, pipeline.RefinementWindowForCandidate(policy, sc.Candidate)) | windows = append(windows, pipeline.RefinementWindowForCandidate(policy, sc.Candidate)) | ||||
| } | } | ||||
| levelSpan := spanForPolicy(policy, float64(rt.cfg.SampleRate)) | |||||
| if _, maxSpan, ok := windowSpanBounds(windows); ok { | |||||
| levelSpan = maxSpan | |||||
| } | |||||
| level := pipeline.AnalysisLevel{ | level := pipeline.AnalysisLevel{ | ||||
| Name: "refinement", | Name: "refinement", | ||||
| SampleRate: rt.cfg.SampleRate, | SampleRate: rt.cfg.SampleRate, | ||||
| FFTSize: rt.cfg.FFTSize, | FFTSize: rt.cfg.FFTSize, | ||||
| CenterHz: rt.cfg.CenterHz, | CenterHz: rt.cfg.CenterHz, | ||||
| SpanHz: spanForPolicy(policy, float64(rt.cfg.SampleRate)), | |||||
| SpanHz: levelSpan, | |||||
| Source: "refinement-window", | Source: "refinement-window", | ||||
| } | } | ||||
| input := pipeline.RefinementInput{ | input := pipeline.RefinementInput{ | ||||
| @@ -508,6 +512,25 @@ func spanForPolicy(policy pipeline.Policy, fallback float64) float64 { | |||||
| return fallback | return fallback | ||||
| } | } | ||||
| func windowSpanBounds(windows []pipeline.RefinementWindow) (float64, float64, bool) { | |||||
| minSpan := 0.0 | |||||
| maxSpan := 0.0 | |||||
| ok := false | |||||
| for _, w := range windows { | |||||
| if w.SpanHz <= 0 { | |||||
| continue | |||||
| } | |||||
| if !ok || w.SpanHz < minSpan { | |||||
| minSpan = w.SpanHz | |||||
| } | |||||
| if !ok || w.SpanHz > maxSpan { | |||||
| maxSpan = w.SpanHz | |||||
| } | |||||
| ok = true | |||||
| } | |||||
| return minSpan, maxSpan, ok | |||||
| } | |||||
| func surveillanceLevels(policy pipeline.Policy, primary pipeline.AnalysisLevel, secondary pipeline.AnalysisLevel) []pipeline.AnalysisLevel { | func surveillanceLevels(policy pipeline.Policy, primary pipeline.AnalysisLevel, secondary pipeline.AnalysisLevel) []pipeline.AnalysisLevel { | ||||
| levels := []pipeline.AnalysisLevel{primary} | levels := []pipeline.AnalysisLevel{primary} | ||||
| strategy := strings.ToLower(strings.TrimSpace(policy.SurveillanceStrategy)) | strategy := strings.ToLower(strings.TrimSpace(policy.SurveillanceStrategy)) | ||||
| @@ -57,3 +57,18 @@ func TestSurveillanceLevelsRespectStrategy(t *testing.T) { | |||||
| t.Fatalf("expected secondary level for multi-res, got %d", len(levels)) | t.Fatalf("expected secondary level for multi-res, got %d", len(levels)) | ||||
| } | } | ||||
| } | } | ||||
| func TestWindowSpanBounds(t *testing.T) { | |||||
| windows := []pipeline.RefinementWindow{ | |||||
| {SpanHz: 8000}, | |||||
| {SpanHz: 16000}, | |||||
| {SpanHz: 12000}, | |||||
| } | |||||
| minSpan, maxSpan, ok := windowSpanBounds(windows) | |||||
| if !ok { | |||||
| t.Fatalf("expected spans to be found") | |||||
| } | |||||
| if minSpan != 8000 || maxSpan != 16000 { | |||||
| t.Fatalf("unexpected span bounds: min %.0f max %.0f", minSpan, maxSpan) | |||||
| } | |||||
| } | |||||
| @@ -42,12 +42,15 @@ func RefinementWindowForCandidate(policy Policy, candidate Candidate) Refinement | |||||
| } | } | ||||
| if policy.RefinementMinSpanHz > 0 && span < policy.RefinementMinSpanHz { | if policy.RefinementMinSpanHz > 0 && span < policy.RefinementMinSpanHz { | ||||
| span = policy.RefinementMinSpanHz | span = policy.RefinementMinSpanHz | ||||
| windowSource = "policy:min_span" | |||||
| } | } | ||||
| if policy.RefinementMaxSpanHz > 0 && span > policy.RefinementMaxSpanHz { | if policy.RefinementMaxSpanHz > 0 && span > policy.RefinementMaxSpanHz { | ||||
| span = policy.RefinementMaxSpanHz | span = policy.RefinementMaxSpanHz | ||||
| windowSource = "policy:max_span" | |||||
| } | } | ||||
| if span <= 0 { | if span <= 0 { | ||||
| span = 12000 | span = 12000 | ||||
| windowSource = "default" | |||||
| } | } | ||||
| return RefinementWindow{ | return RefinementWindow{ | ||||
| CenterHz: candidate.CenterHz, | CenterHz: candidate.CenterHz, | ||||
| @@ -0,0 +1,23 @@ | |||||
| package pipeline | |||||
| import "testing" | |||||
| func TestRefinementWindowClampsToPolicy(t *testing.T) { | |||||
| policy := Policy{RefinementMinSpanHz: 12000, RefinementMaxSpanHz: 20000, RefinementAutoSpan: false} | |||||
| win := RefinementWindowForCandidate(policy, Candidate{CenterHz: 1e6, BandwidthHz: 8000}) | |||||
| if win.SpanHz != 12000 || win.Source != "policy:min_span" { | |||||
| t.Fatalf("expected min clamp, got span %.0f source %q", win.SpanHz, win.Source) | |||||
| } | |||||
| win = RefinementWindowForCandidate(policy, Candidate{CenterHz: 1e6, BandwidthHz: 50000}) | |||||
| if win.SpanHz != 20000 || win.Source != "policy:max_span" { | |||||
| t.Fatalf("expected max clamp, got span %.0f source %q", win.SpanHz, win.Source) | |||||
| } | |||||
| } | |||||
| func TestRefinementWindowDefaultsWhenEmpty(t *testing.T) { | |||||
| policy := Policy{RefinementAutoSpan: false} | |||||
| win := RefinementWindowForCandidate(policy, Candidate{CenterHz: 1e6, BandwidthHz: 0}) | |||||
| if win.SpanHz != 12000 || win.Source != "default" { | |||||
| t.Fatalf("expected default span, got span %.0f source %q", win.SpanHz, win.Source) | |||||
| } | |||||
| } | |||||