| @@ -1,9 +1,21 @@ | |||
| package pipeline | |||
| func candidateInMonitor(policy Policy, candidate Candidate) bool { | |||
| func monitorBounds(policy Policy) (float64, float64, bool) { | |||
| start := policy.MonitorStartHz | |||
| end := policy.MonitorEndHz | |||
| if start == 0 || end == 0 || end <= start { | |||
| if start != 0 && end != 0 && end > start { | |||
| return start, end, true | |||
| } | |||
| if policy.MonitorSpanHz > 0 && policy.MonitorCenterHz != 0 { | |||
| half := policy.MonitorSpanHz / 2 | |||
| return policy.MonitorCenterHz - half, policy.MonitorCenterHz + half, true | |||
| } | |||
| return 0, 0, false | |||
| } | |||
| func candidateInMonitor(policy Policy, candidate Candidate) bool { | |||
| start, end, ok := monitorBounds(policy) | |||
| if !ok { | |||
| return true | |||
| } | |||
| left := candidate.CenterHz | |||
| @@ -5,6 +5,7 @@ import "sdr-wideband-suite/internal/config" | |||
| type Policy struct { | |||
| Mode string `json:"mode"` | |||
| Intent string `json:"intent"` | |||
| MonitorCenterHz float64 `json:"monitor_center_hz,omitempty"` | |||
| MonitorStartHz float64 `json:"monitor_start_hz,omitempty"` | |||
| MonitorEndHz float64 `json:"monitor_end_hz,omitempty"` | |||
| MonitorSpanHz float64 `json:"monitor_span_hz,omitempty"` | |||
| @@ -32,6 +33,7 @@ func PolicyFromConfig(cfg config.Config) Policy { | |||
| return Policy{ | |||
| Mode: cfg.Pipeline.Mode, | |||
| Intent: cfg.Pipeline.Goals.Intent, | |||
| MonitorCenterHz: cfg.CenterHz, | |||
| MonitorStartHz: cfg.Pipeline.Goals.MonitorStartHz, | |||
| MonitorEndHz: cfg.Pipeline.Goals.MonitorEndHz, | |||
| MonitorSpanHz: cfg.Pipeline.Goals.MonitorSpanHz, | |||
| @@ -49,6 +49,9 @@ func TestPolicyFromConfig(t *testing.T) { | |||
| if p.MonitorSpanHz != 20e6 || len(p.SignalPriorities) != 2 { | |||
| t.Fatalf("unexpected policy goals: %+v", p) | |||
| } | |||
| if p.MonitorCenterHz != cfg.CenterHz { | |||
| t.Fatalf("unexpected monitor center: %+v", p.MonitorCenterHz) | |||
| } | |||
| if !p.RefinementEnabled || p.MaxRefinementJobs != 5 || p.MinCandidateSNRDb != 2.5 || !p.PreferGPU { | |||
| t.Fatalf("unexpected policy details: %+v", p) | |||
| } | |||
| @@ -77,6 +77,23 @@ func TestBuildRefinementPlanAppliesMonitorSpan(t *testing.T) { | |||
| } | |||
| } | |||
| func TestBuildRefinementPlanAppliesMonitorSpanCentered(t *testing.T) { | |||
| policy := Policy{MaxRefinementJobs: 5, MinCandidateSNRDb: 0, MonitorCenterHz: 300, MonitorSpanHz: 200} | |||
| cands := []Candidate{ | |||
| {ID: 1, CenterHz: 100, BandwidthHz: 20}, | |||
| {ID: 2, CenterHz: 250, BandwidthHz: 50}, | |||
| {ID: 3, CenterHz: 300, BandwidthHz: 100}, | |||
| {ID: 4, CenterHz: 420, BandwidthHz: 50}, | |||
| } | |||
| plan := BuildRefinementPlan(cands, policy) | |||
| if plan.DroppedByMonitor != 1 { | |||
| t.Fatalf("expected 1 dropped by monitor, got %d", plan.DroppedByMonitor) | |||
| } | |||
| if len(plan.Selected) != 3 { | |||
| t.Fatalf("expected 3 selected within monitor, got %d", len(plan.Selected)) | |||
| } | |||
| } | |||
| func TestAutoSpanForHint(t *testing.T) { | |||
| span, source := AutoSpanForHint("WFM_STEREO") | |||
| if span < 150000 || source == "" { | |||
| @@ -10,8 +10,15 @@ import ( | |||
| ) | |||
| type PipelineUpdate struct { | |||
| Mode *string `json:"mode"` | |||
| Profile *string `json:"profile"` | |||
| Mode *string `json:"mode"` | |||
| Profile *string `json:"profile"` | |||
| Intent *string `json:"intent"` | |||
| MonitorStartHz *float64 `json:"monitor_start_hz"` | |||
| MonitorEndHz *float64 `json:"monitor_end_hz"` | |||
| MonitorSpanHz *float64 `json:"monitor_span_hz"` | |||
| SignalPriorities *[]string `json:"signal_priorities"` | |||
| AutoRecordClasses *[]string `json:"auto_record_classes"` | |||
| AutoDecodeClasses *[]string `json:"auto_decode_classes"` | |||
| } | |||
| type SurveillanceUpdate struct { | |||
| @@ -26,12 +33,17 @@ type RefinementUpdate struct { | |||
| Enabled *bool `json:"enabled"` | |||
| MaxConcurrent *int `json:"max_concurrent"` | |||
| MinCandidateSNRDb *float64 `json:"min_candidate_snr_db"` | |||
| MinSpanHz *float64 `json:"min_span_hz"` | |||
| MaxSpanHz *float64 `json:"max_span_hz"` | |||
| AutoSpan *bool `json:"auto_span"` | |||
| } | |||
| type ResourcesUpdate struct { | |||
| PreferGPU *bool `json:"prefer_gpu"` | |||
| MaxRefinementJobs *int `json:"max_refinement_jobs"` | |||
| MaxRecordingStreams *int `json:"max_recording_streams"` | |||
| PreferGPU *bool `json:"prefer_gpu"` | |||
| MaxRefinementJobs *int `json:"max_refinement_jobs"` | |||
| MaxRecordingStreams *int `json:"max_recording_streams"` | |||
| MaxDecodeJobs *int `json:"max_decode_jobs"` | |||
| DecisionHoldMs *int `json:"decision_hold_ms"` | |||
| } | |||
| type ConfigUpdate struct { | |||
| @@ -163,8 +175,40 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { | |||
| return m.cfg, errors.New("classifier_mode must be rule, math, or combined") | |||
| } | |||
| } | |||
| if update.Pipeline != nil && update.Pipeline.Mode != nil { | |||
| next.Pipeline.Mode = *update.Pipeline.Mode | |||
| if update.Pipeline != nil { | |||
| if update.Pipeline.Mode != nil { | |||
| next.Pipeline.Mode = *update.Pipeline.Mode | |||
| } | |||
| if update.Pipeline.Intent != nil { | |||
| next.Pipeline.Goals.Intent = *update.Pipeline.Intent | |||
| } | |||
| if update.Pipeline.MonitorStartHz != nil { | |||
| next.Pipeline.Goals.MonitorStartHz = *update.Pipeline.MonitorStartHz | |||
| } | |||
| if update.Pipeline.MonitorEndHz != nil { | |||
| next.Pipeline.Goals.MonitorEndHz = *update.Pipeline.MonitorEndHz | |||
| } | |||
| if update.Pipeline.MonitorSpanHz != nil { | |||
| if *update.Pipeline.MonitorSpanHz <= 0 { | |||
| return m.cfg, errors.New("monitor_span_hz must be > 0") | |||
| } | |||
| next.Pipeline.Goals.MonitorSpanHz = *update.Pipeline.MonitorSpanHz | |||
| } | |||
| if update.Pipeline.SignalPriorities != nil { | |||
| next.Pipeline.Goals.SignalPriorities = append([]string(nil), (*update.Pipeline.SignalPriorities)...) | |||
| } | |||
| if update.Pipeline.AutoRecordClasses != nil { | |||
| next.Pipeline.Goals.AutoRecordClasses = append([]string(nil), (*update.Pipeline.AutoRecordClasses)...) | |||
| } | |||
| if update.Pipeline.AutoDecodeClasses != nil { | |||
| next.Pipeline.Goals.AutoDecodeClasses = append([]string(nil), (*update.Pipeline.AutoDecodeClasses)...) | |||
| } | |||
| if next.Pipeline.Goals.MonitorStartHz != 0 && next.Pipeline.Goals.MonitorEndHz != 0 && next.Pipeline.Goals.MonitorEndHz <= next.Pipeline.Goals.MonitorStartHz { | |||
| return m.cfg, errors.New("monitor_end_hz must be > monitor_start_hz") | |||
| } | |||
| if next.Pipeline.Goals.MonitorSpanHz <= 0 && next.Pipeline.Goals.MonitorStartHz != 0 && next.Pipeline.Goals.MonitorEndHz != 0 && next.Pipeline.Goals.MonitorEndHz > next.Pipeline.Goals.MonitorStartHz { | |||
| next.Pipeline.Goals.MonitorSpanHz = next.Pipeline.Goals.MonitorEndHz - next.Pipeline.Goals.MonitorStartHz | |||
| } | |||
| } | |||
| if update.Surveillance != nil { | |||
| if update.Surveillance.AnalysisFFTSize != nil { | |||
| @@ -217,6 +261,24 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { | |||
| if update.Refinement.MinCandidateSNRDb != nil { | |||
| next.Refinement.MinCandidateSNRDb = *update.Refinement.MinCandidateSNRDb | |||
| } | |||
| if update.Refinement.MinSpanHz != nil { | |||
| if *update.Refinement.MinSpanHz < 0 { | |||
| return m.cfg, errors.New("refinement.min_span_hz must be >= 0") | |||
| } | |||
| next.Refinement.MinSpanHz = *update.Refinement.MinSpanHz | |||
| } | |||
| if update.Refinement.MaxSpanHz != nil { | |||
| if *update.Refinement.MaxSpanHz < 0 { | |||
| return m.cfg, errors.New("refinement.max_span_hz must be >= 0") | |||
| } | |||
| next.Refinement.MaxSpanHz = *update.Refinement.MaxSpanHz | |||
| } | |||
| if update.Refinement.AutoSpan != nil { | |||
| next.Refinement.AutoSpan = update.Refinement.AutoSpan | |||
| } | |||
| if next.Refinement.MaxSpanHz > 0 && next.Refinement.MinSpanHz > next.Refinement.MaxSpanHz { | |||
| return m.cfg, errors.New("refinement.min_span_hz must be <= refinement.max_span_hz") | |||
| } | |||
| } | |||
| if update.Resources != nil { | |||
| if update.Resources.PreferGPU != nil { | |||
| @@ -234,6 +296,18 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { | |||
| } | |||
| next.Resources.MaxRecordingStreams = *update.Resources.MaxRecordingStreams | |||
| } | |||
| if update.Resources.MaxDecodeJobs != nil { | |||
| if *update.Resources.MaxDecodeJobs <= 0 { | |||
| return m.cfg, errors.New("resources.max_decode_jobs must be > 0") | |||
| } | |||
| next.Resources.MaxDecodeJobs = *update.Resources.MaxDecodeJobs | |||
| } | |||
| if update.Resources.DecisionHoldMs != nil { | |||
| if *update.Resources.DecisionHoldMs < 0 { | |||
| return m.cfg, errors.New("resources.decision_hold_ms must be >= 0") | |||
| } | |||
| next.Resources.DecisionHoldMs = *update.Resources.DecisionHoldMs | |||
| } | |||
| } | |||
| if update.Detector != nil { | |||
| if update.Detector.ThresholdDb != nil { | |||
| @@ -24,18 +24,37 @@ func TestApplyConfigUpdate(t *testing.T) { | |||
| mode := "wideband-balanced" | |||
| profile := "wideband-balanced" | |||
| intent := "wideband-surveillance" | |||
| monitorSpan := 500000.0 | |||
| signalPriorities := []string{"digital", "weak"} | |||
| autoRecord := []string{"WFM"} | |||
| autoDecode := []string{"FT8"} | |||
| survFPS := 12 | |||
| displayBins := 1024 | |||
| displayFPS := 8 | |||
| maxRefJobs := 24 | |||
| minSpan := 4000.0 | |||
| maxSpan := 200000.0 | |||
| autoSpan := false | |||
| maxDecode := 12 | |||
| decisionHold := 1500 | |||
| updated, err := mgr.ApplyConfig(ConfigUpdate{ | |||
| CenterHz: ¢er, | |||
| SampleRate: &sampleRate, | |||
| FFTSize: &fftSize, | |||
| TunerBwKHz: &bw, | |||
| Pipeline: &PipelineUpdate{Mode: &mode, Profile: &profile}, | |||
| Pipeline: &PipelineUpdate{ | |||
| Mode: &mode, | |||
| Profile: &profile, | |||
| Intent: &intent, | |||
| MonitorSpanHz: &monitorSpan, | |||
| SignalPriorities: &signalPriorities, | |||
| AutoRecordClasses: &autoRecord, | |||
| AutoDecodeClasses: &autoDecode, | |||
| }, | |||
| Surveillance: &SurveillanceUpdate{FrameRate: &survFPS, DisplayBins: &displayBins, DisplayFPS: &displayFPS}, | |||
| Resources: &ResourcesUpdate{MaxRefinementJobs: &maxRefJobs}, | |||
| Refinement: &RefinementUpdate{MinSpanHz: &minSpan, MaxSpanHz: &maxSpan, AutoSpan: &autoSpan}, | |||
| Resources: &ResourcesUpdate{MaxRefinementJobs: &maxRefJobs, MaxDecodeJobs: &maxDecode, DecisionHoldMs: &decisionHold}, | |||
| Detector: &DetectorUpdate{ | |||
| ThresholdDb: &threshold, | |||
| CFARMode: &cfarMode, | |||
| @@ -88,15 +107,42 @@ func TestApplyConfigUpdate(t *testing.T) { | |||
| if updated.Pipeline.Mode != mode { | |||
| t.Fatalf("pipeline mode: %v", updated.Pipeline.Mode) | |||
| } | |||
| if updated.Pipeline.Goals.Intent != intent { | |||
| t.Fatalf("pipeline intent: %v", updated.Pipeline.Goals.Intent) | |||
| } | |||
| if updated.Pipeline.Goals.MonitorSpanHz != monitorSpan { | |||
| t.Fatalf("monitor span: %v", updated.Pipeline.Goals.MonitorSpanHz) | |||
| } | |||
| if len(updated.Pipeline.Goals.SignalPriorities) != len(signalPriorities) { | |||
| t.Fatalf("signal priorities not applied") | |||
| } | |||
| if len(updated.Pipeline.Goals.AutoRecordClasses) != len(autoRecord) { | |||
| t.Fatalf("auto record classes not applied") | |||
| } | |||
| if len(updated.Pipeline.Goals.AutoDecodeClasses) != len(autoDecode) { | |||
| t.Fatalf("auto decode classes not applied") | |||
| } | |||
| if updated.Surveillance.FrameRate != survFPS || updated.FrameRate != survFPS { | |||
| t.Fatalf("surveillance frame rate: %v / %v", updated.Surveillance.FrameRate, updated.FrameRate) | |||
| } | |||
| if updated.Resources.MaxRefinementJobs != maxRefJobs { | |||
| t.Fatalf("max refinement jobs: %v", updated.Resources.MaxRefinementJobs) | |||
| } | |||
| if updated.Resources.MaxDecodeJobs != maxDecode { | |||
| t.Fatalf("max decode jobs: %v", updated.Resources.MaxDecodeJobs) | |||
| } | |||
| if updated.Resources.DecisionHoldMs != decisionHold { | |||
| t.Fatalf("decision hold: %v", updated.Resources.DecisionHoldMs) | |||
| } | |||
| if updated.Surveillance.DisplayBins != displayBins || updated.Surveillance.DisplayFPS != displayFPS { | |||
| t.Fatalf("display settings not applied: bins=%d fps=%d", updated.Surveillance.DisplayBins, updated.Surveillance.DisplayFPS) | |||
| } | |||
| if updated.Refinement.MinSpanHz != minSpan || updated.Refinement.MaxSpanHz != maxSpan { | |||
| t.Fatalf("refinement span not applied: %v / %v", updated.Refinement.MinSpanHz, updated.Refinement.MaxSpanHz) | |||
| } | |||
| if updated.Refinement.AutoSpan == nil || *updated.Refinement.AutoSpan != autoSpan { | |||
| t.Fatalf("refinement auto span not applied") | |||
| } | |||
| } | |||
| func TestApplyConfigRejectsInvalid(t *testing.T) { | |||