| @@ -1,9 +1,21 @@ | |||||
| package pipeline | package pipeline | ||||
| func candidateInMonitor(policy Policy, candidate Candidate) bool { | |||||
| func monitorBounds(policy Policy) (float64, float64, bool) { | |||||
| start := policy.MonitorStartHz | start := policy.MonitorStartHz | ||||
| end := policy.MonitorEndHz | 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 | return true | ||||
| } | } | ||||
| left := candidate.CenterHz | left := candidate.CenterHz | ||||
| @@ -5,6 +5,7 @@ import "sdr-wideband-suite/internal/config" | |||||
| type Policy struct { | type Policy struct { | ||||
| Mode string `json:"mode"` | Mode string `json:"mode"` | ||||
| Intent string `json:"intent"` | Intent string `json:"intent"` | ||||
| MonitorCenterHz float64 `json:"monitor_center_hz,omitempty"` | |||||
| MonitorStartHz float64 `json:"monitor_start_hz,omitempty"` | MonitorStartHz float64 `json:"monitor_start_hz,omitempty"` | ||||
| MonitorEndHz float64 `json:"monitor_end_hz,omitempty"` | MonitorEndHz float64 `json:"monitor_end_hz,omitempty"` | ||||
| MonitorSpanHz float64 `json:"monitor_span_hz,omitempty"` | MonitorSpanHz float64 `json:"monitor_span_hz,omitempty"` | ||||
| @@ -32,6 +33,7 @@ func PolicyFromConfig(cfg config.Config) Policy { | |||||
| return Policy{ | return Policy{ | ||||
| Mode: cfg.Pipeline.Mode, | Mode: cfg.Pipeline.Mode, | ||||
| Intent: cfg.Pipeline.Goals.Intent, | Intent: cfg.Pipeline.Goals.Intent, | ||||
| MonitorCenterHz: cfg.CenterHz, | |||||
| MonitorStartHz: cfg.Pipeline.Goals.MonitorStartHz, | MonitorStartHz: cfg.Pipeline.Goals.MonitorStartHz, | ||||
| MonitorEndHz: cfg.Pipeline.Goals.MonitorEndHz, | MonitorEndHz: cfg.Pipeline.Goals.MonitorEndHz, | ||||
| MonitorSpanHz: cfg.Pipeline.Goals.MonitorSpanHz, | MonitorSpanHz: cfg.Pipeline.Goals.MonitorSpanHz, | ||||
| @@ -49,6 +49,9 @@ func TestPolicyFromConfig(t *testing.T) { | |||||
| if p.MonitorSpanHz != 20e6 || len(p.SignalPriorities) != 2 { | if p.MonitorSpanHz != 20e6 || len(p.SignalPriorities) != 2 { | ||||
| t.Fatalf("unexpected policy goals: %+v", p) | 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 { | if !p.RefinementEnabled || p.MaxRefinementJobs != 5 || p.MinCandidateSNRDb != 2.5 || !p.PreferGPU { | ||||
| t.Fatalf("unexpected policy details: %+v", p) | 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) { | func TestAutoSpanForHint(t *testing.T) { | ||||
| span, source := AutoSpanForHint("WFM_STEREO") | span, source := AutoSpanForHint("WFM_STEREO") | ||||
| if span < 150000 || source == "" { | if span < 150000 || source == "" { | ||||
| @@ -10,8 +10,15 @@ import ( | |||||
| ) | ) | ||||
| type PipelineUpdate struct { | 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 { | type SurveillanceUpdate struct { | ||||
| @@ -26,12 +33,17 @@ type RefinementUpdate struct { | |||||
| Enabled *bool `json:"enabled"` | Enabled *bool `json:"enabled"` | ||||
| MaxConcurrent *int `json:"max_concurrent"` | MaxConcurrent *int `json:"max_concurrent"` | ||||
| MinCandidateSNRDb *float64 `json:"min_candidate_snr_db"` | 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 { | 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 { | 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") | 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 != nil { | ||||
| if update.Surveillance.AnalysisFFTSize != nil { | if update.Surveillance.AnalysisFFTSize != nil { | ||||
| @@ -217,6 +261,24 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { | |||||
| if update.Refinement.MinCandidateSNRDb != nil { | if update.Refinement.MinCandidateSNRDb != nil { | ||||
| next.Refinement.MinCandidateSNRDb = *update.Refinement.MinCandidateSNRDb | 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 != nil { | ||||
| if update.Resources.PreferGPU != 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 | 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 != nil { | ||||
| if update.Detector.ThresholdDb != nil { | if update.Detector.ThresholdDb != nil { | ||||
| @@ -24,18 +24,37 @@ func TestApplyConfigUpdate(t *testing.T) { | |||||
| mode := "wideband-balanced" | mode := "wideband-balanced" | ||||
| profile := "wideband-balanced" | profile := "wideband-balanced" | ||||
| intent := "wideband-surveillance" | |||||
| monitorSpan := 500000.0 | |||||
| signalPriorities := []string{"digital", "weak"} | |||||
| autoRecord := []string{"WFM"} | |||||
| autoDecode := []string{"FT8"} | |||||
| survFPS := 12 | survFPS := 12 | ||||
| displayBins := 1024 | displayBins := 1024 | ||||
| displayFPS := 8 | displayFPS := 8 | ||||
| maxRefJobs := 24 | maxRefJobs := 24 | ||||
| minSpan := 4000.0 | |||||
| maxSpan := 200000.0 | |||||
| autoSpan := false | |||||
| maxDecode := 12 | |||||
| decisionHold := 1500 | |||||
| updated, err := mgr.ApplyConfig(ConfigUpdate{ | updated, err := mgr.ApplyConfig(ConfigUpdate{ | ||||
| CenterHz: ¢er, | CenterHz: ¢er, | ||||
| SampleRate: &sampleRate, | SampleRate: &sampleRate, | ||||
| FFTSize: &fftSize, | FFTSize: &fftSize, | ||||
| TunerBwKHz: &bw, | 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}, | 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{ | Detector: &DetectorUpdate{ | ||||
| ThresholdDb: &threshold, | ThresholdDb: &threshold, | ||||
| CFARMode: &cfarMode, | CFARMode: &cfarMode, | ||||
| @@ -88,15 +107,42 @@ func TestApplyConfigUpdate(t *testing.T) { | |||||
| if updated.Pipeline.Mode != mode { | if updated.Pipeline.Mode != mode { | ||||
| t.Fatalf("pipeline mode: %v", updated.Pipeline.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 { | if updated.Surveillance.FrameRate != survFPS || updated.FrameRate != survFPS { | ||||
| t.Fatalf("surveillance frame rate: %v / %v", updated.Surveillance.FrameRate, updated.FrameRate) | t.Fatalf("surveillance frame rate: %v / %v", updated.Surveillance.FrameRate, updated.FrameRate) | ||||
| } | } | ||||
| if updated.Resources.MaxRefinementJobs != maxRefJobs { | if updated.Resources.MaxRefinementJobs != maxRefJobs { | ||||
| t.Fatalf("max refinement jobs: %v", updated.Resources.MaxRefinementJobs) | 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 { | 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) | 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) { | func TestApplyConfigRejectsInvalid(t *testing.T) { | ||||