diff --git a/internal/pipeline/monitor_rules.go b/internal/pipeline/monitor_rules.go index da708ae..aa3c6d3 100644 --- a/internal/pipeline/monitor_rules.go +++ b/internal/pipeline/monitor_rules.go @@ -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 diff --git a/internal/pipeline/policy.go b/internal/pipeline/policy.go index fa76486..025ed42 100644 --- a/internal/pipeline/policy.go +++ b/internal/pipeline/policy.go @@ -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, diff --git a/internal/pipeline/policy_test.go b/internal/pipeline/policy_test.go index 629525b..b2c186d 100644 --- a/internal/pipeline/policy_test.go +++ b/internal/pipeline/policy_test.go @@ -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) } diff --git a/internal/pipeline/scheduler_test.go b/internal/pipeline/scheduler_test.go index 497ad74..d8d0ebc 100644 --- a/internal/pipeline/scheduler_test.go +++ b/internal/pipeline/scheduler_test.go @@ -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 == "" { diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 8115652..cdbe1fa 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -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 { diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 365b98f..3381ad2 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -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) {