| @@ -65,6 +65,8 @@ Edit `config.yaml` (autosave goes to `config.autosave.yaml`). | |||||
| - `surveillance.analysis_fft_size` — analysis FFT size used by the surveillance layer | - `surveillance.analysis_fft_size` — analysis FFT size used by the surveillance layer | ||||
| - `surveillance.frame_rate` — surveillance cadence target | - `surveillance.frame_rate` — surveillance cadence target | ||||
| - `surveillance.strategy` — currently `single-resolution`, reserved for future multi-resolution modes | - `surveillance.strategy` — currently `single-resolution`, reserved for future multi-resolution modes | ||||
| - `surveillance.display_bins` — preferred presentation density for clients/UI | |||||
| - `surveillance.display_fps` — preferred presentation cadence for clients/UI | |||||
| - `refinement.enabled` — enables explicit candidate refinement stage | - `refinement.enabled` — enables explicit candidate refinement stage | ||||
| - `refinement.max_concurrent` — refinement budget hint | - `refinement.max_concurrent` — refinement budget hint | ||||
| - `refinement.min_candidate_snr_db` — floor for future scheduling decisions | - `refinement.min_candidate_snr_db` — floor for future scheduling decisions | ||||
| @@ -0,0 +1,17 @@ | |||||
| package main | |||||
| import ( | |||||
| "testing" | |||||
| "sdr-wideband-suite/internal/config" | |||||
| ) | |||||
| func TestSurveillanceDisplayDefaults(t *testing.T) { | |||||
| cfg := config.Default() | |||||
| if cfg.Surveillance.DisplayBins != cfg.FFTSize { | |||||
| t.Fatalf("expected display bins to default to fft size, got %d vs %d", cfg.Surveillance.DisplayBins, cfg.FFTSize) | |||||
| } | |||||
| if cfg.Surveillance.DisplayFPS != cfg.FrameRate { | |||||
| t.Fatalf("expected display fps to default to frame rate, got %d vs %d", cfg.Surveillance.DisplayFPS, cfg.FrameRate) | |||||
| } | |||||
| } | |||||
| @@ -25,6 +25,8 @@ surveillance: | |||||
| analysis_fft_size: 2048 | analysis_fft_size: 2048 | ||||
| frame_rate: 15 | frame_rate: 15 | ||||
| strategy: single-resolution | strategy: single-resolution | ||||
| display_bins: 2048 | |||||
| display_fps: 15 | |||||
| refinement: | refinement: | ||||
| enabled: true | enabled: true | ||||
| max_concurrent: 8 | max_concurrent: 8 | ||||
| @@ -89,6 +89,8 @@ type SurveillanceConfig struct { | |||||
| AnalysisFFTSize int `yaml:"analysis_fft_size" json:"analysis_fft_size"` | AnalysisFFTSize int `yaml:"analysis_fft_size" json:"analysis_fft_size"` | ||||
| FrameRate int `yaml:"frame_rate" json:"frame_rate"` | FrameRate int `yaml:"frame_rate" json:"frame_rate"` | ||||
| Strategy string `yaml:"strategy" json:"strategy"` | Strategy string `yaml:"strategy" json:"strategy"` | ||||
| DisplayBins int `yaml:"display_bins" json:"display_bins"` | |||||
| DisplayFPS int `yaml:"display_fps" json:"display_fps"` | |||||
| } | } | ||||
| type RefinementConfig struct { | type RefinementConfig struct { | ||||
| @@ -164,6 +166,8 @@ func Default() Config { | |||||
| AnalysisFFTSize: 2048, | AnalysisFFTSize: 2048, | ||||
| FrameRate: 15, | FrameRate: 15, | ||||
| Strategy: "single-resolution", | Strategy: "single-resolution", | ||||
| DisplayBins: 2048, | |||||
| DisplayFPS: 15, | |||||
| }, | }, | ||||
| Refinement: RefinementConfig{ | Refinement: RefinementConfig{ | ||||
| Enabled: true, | Enabled: true, | ||||
| @@ -330,6 +334,12 @@ func applyDefaults(cfg Config) Config { | |||||
| if cfg.Surveillance.Strategy == "" { | if cfg.Surveillance.Strategy == "" { | ||||
| cfg.Surveillance.Strategy = "single-resolution" | cfg.Surveillance.Strategy = "single-resolution" | ||||
| } | } | ||||
| if cfg.Surveillance.DisplayBins <= 0 { | |||||
| cfg.Surveillance.DisplayBins = cfg.FFTSize | |||||
| } | |||||
| if cfg.Surveillance.DisplayFPS <= 0 { | |||||
| cfg.Surveillance.DisplayFPS = cfg.FrameRate | |||||
| } | |||||
| if !cfg.Refinement.Enabled { | if !cfg.Refinement.Enabled { | ||||
| // keep explicit false if user disabled it; enable by default only when unset-like zero config | // keep explicit false if user disabled it; enable by default only when unset-like zero config | ||||
| if cfg.Refinement.MaxConcurrent == 0 && cfg.Refinement.MinCandidateSNRDb == 0 { | if cfg.Refinement.MaxConcurrent == 0 && cfg.Refinement.MinCandidateSNRDb == 0 { | ||||
| @@ -13,6 +13,8 @@ type Policy struct { | |||||
| AutoDecodeClasses []string `json:"auto_decode_classes,omitempty"` | AutoDecodeClasses []string `json:"auto_decode_classes,omitempty"` | ||||
| SurveillanceFFTSize int `json:"surveillance_fft_size"` | SurveillanceFFTSize int `json:"surveillance_fft_size"` | ||||
| SurveillanceFPS int `json:"surveillance_fps"` | SurveillanceFPS int `json:"surveillance_fps"` | ||||
| DisplayBins int `json:"display_bins"` | |||||
| DisplayFPS int `json:"display_fps"` | |||||
| RefinementEnabled bool `json:"refinement_enabled"` | RefinementEnabled bool `json:"refinement_enabled"` | ||||
| MaxRefinementJobs int `json:"max_refinement_jobs"` | MaxRefinementJobs int `json:"max_refinement_jobs"` | ||||
| MinCandidateSNRDb float64 `json:"min_candidate_snr_db"` | MinCandidateSNRDb float64 `json:"min_candidate_snr_db"` | ||||
| @@ -31,6 +33,8 @@ func PolicyFromConfig(cfg config.Config) Policy { | |||||
| AutoDecodeClasses: append([]string(nil), cfg.Pipeline.Goals.AutoDecodeClasses...), | AutoDecodeClasses: append([]string(nil), cfg.Pipeline.Goals.AutoDecodeClasses...), | ||||
| SurveillanceFFTSize: cfg.Surveillance.AnalysisFFTSize, | SurveillanceFFTSize: cfg.Surveillance.AnalysisFFTSize, | ||||
| SurveillanceFPS: cfg.Surveillance.FrameRate, | SurveillanceFPS: cfg.Surveillance.FrameRate, | ||||
| DisplayBins: cfg.Surveillance.DisplayBins, | |||||
| DisplayFPS: cfg.Surveillance.DisplayFPS, | |||||
| RefinementEnabled: cfg.Refinement.Enabled, | RefinementEnabled: cfg.Refinement.Enabled, | ||||
| MaxRefinementJobs: cfg.Resources.MaxRefinementJobs, | MaxRefinementJobs: cfg.Resources.MaxRefinementJobs, | ||||
| MinCandidateSNRDb: cfg.Refinement.MinCandidateSNRDb, | MinCandidateSNRDb: cfg.Refinement.MinCandidateSNRDb, | ||||
| @@ -36,12 +36,14 @@ func TestPolicyFromConfig(t *testing.T) { | |||||
| cfg.Pipeline.Goals.SignalPriorities = []string{"broadcast-fm", "rds"} | cfg.Pipeline.Goals.SignalPriorities = []string{"broadcast-fm", "rds"} | ||||
| cfg.Surveillance.AnalysisFFTSize = 8192 | cfg.Surveillance.AnalysisFFTSize = 8192 | ||||
| cfg.Surveillance.FrameRate = 9 | cfg.Surveillance.FrameRate = 9 | ||||
| cfg.Surveillance.DisplayBins = 1200 | |||||
| cfg.Surveillance.DisplayFPS = 6 | |||||
| cfg.Refinement.Enabled = true | cfg.Refinement.Enabled = true | ||||
| cfg.Resources.MaxRefinementJobs = 5 | cfg.Resources.MaxRefinementJobs = 5 | ||||
| cfg.Refinement.MinCandidateSNRDb = 2.5 | cfg.Refinement.MinCandidateSNRDb = 2.5 | ||||
| cfg.Resources.PreferGPU = true | cfg.Resources.PreferGPU = true | ||||
| p := PolicyFromConfig(cfg) | p := PolicyFromConfig(cfg) | ||||
| if p.Mode != "archive" || p.Intent != "archive-and-triage" || p.SurveillanceFFTSize != 8192 || p.SurveillanceFPS != 9 { | |||||
| if p.Mode != "archive" || p.Intent != "archive-and-triage" || p.SurveillanceFFTSize != 8192 || p.SurveillanceFPS != 9 || p.DisplayBins != 1200 || p.DisplayFPS != 6 { | |||||
| t.Fatalf("unexpected policy: %+v", p) | t.Fatalf("unexpected policy: %+v", p) | ||||
| } | } | ||||
| if p.MonitorSpanHz != 20e6 || len(p.SignalPriorities) != 2 { | if p.MonitorSpanHz != 20e6 || len(p.SignalPriorities) != 2 { | ||||
| @@ -18,6 +18,8 @@ type SurveillanceUpdate struct { | |||||
| AnalysisFFTSize *int `json:"analysis_fft_size"` | AnalysisFFTSize *int `json:"analysis_fft_size"` | ||||
| FrameRate *int `json:"frame_rate"` | FrameRate *int `json:"frame_rate"` | ||||
| Strategy *string `json:"strategy"` | Strategy *string `json:"strategy"` | ||||
| DisplayBins *int `json:"display_bins"` | |||||
| DisplayFPS *int `json:"display_fps"` | |||||
| } | } | ||||
| type RefinementUpdate struct { | type RefinementUpdate struct { | ||||
| @@ -187,6 +189,20 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { | |||||
| if update.Surveillance.Strategy != nil { | if update.Surveillance.Strategy != nil { | ||||
| next.Surveillance.Strategy = *update.Surveillance.Strategy | next.Surveillance.Strategy = *update.Surveillance.Strategy | ||||
| } | } | ||||
| if update.Surveillance.DisplayBins != nil { | |||||
| v := *update.Surveillance.DisplayBins | |||||
| if v <= 0 { | |||||
| return m.cfg, errors.New("surveillance.display_bins must be > 0") | |||||
| } | |||||
| next.Surveillance.DisplayBins = v | |||||
| } | |||||
| if update.Surveillance.DisplayFPS != nil { | |||||
| v := *update.Surveillance.DisplayFPS | |||||
| if v <= 0 { | |||||
| return m.cfg, errors.New("surveillance.display_fps must be > 0") | |||||
| } | |||||
| next.Surveillance.DisplayFPS = v | |||||
| } | |||||
| } | } | ||||
| if update.Refinement != nil { | if update.Refinement != nil { | ||||
| if update.Refinement.Enabled != nil { | if update.Refinement.Enabled != nil { | ||||
| @@ -25,6 +25,8 @@ func TestApplyConfigUpdate(t *testing.T) { | |||||
| mode := "wideband-balanced" | mode := "wideband-balanced" | ||||
| profile := "wideband-balanced" | profile := "wideband-balanced" | ||||
| survFPS := 12 | survFPS := 12 | ||||
| displayBins := 1024 | |||||
| displayFPS := 8 | |||||
| maxRefJobs := 24 | maxRefJobs := 24 | ||||
| updated, err := mgr.ApplyConfig(ConfigUpdate{ | updated, err := mgr.ApplyConfig(ConfigUpdate{ | ||||
| CenterHz: ¢er, | CenterHz: ¢er, | ||||
| @@ -32,7 +34,7 @@ func TestApplyConfigUpdate(t *testing.T) { | |||||
| FFTSize: &fftSize, | FFTSize: &fftSize, | ||||
| TunerBwKHz: &bw, | TunerBwKHz: &bw, | ||||
| Pipeline: &PipelineUpdate{Mode: &mode, Profile: &profile}, | Pipeline: &PipelineUpdate{Mode: &mode, Profile: &profile}, | ||||
| Surveillance: &SurveillanceUpdate{FrameRate: &survFPS}, | |||||
| Surveillance: &SurveillanceUpdate{FrameRate: &survFPS, DisplayBins: &displayBins, DisplayFPS: &displayFPS}, | |||||
| Resources: &ResourcesUpdate{MaxRefinementJobs: &maxRefJobs}, | Resources: &ResourcesUpdate{MaxRefinementJobs: &maxRefJobs}, | ||||
| Detector: &DetectorUpdate{ | Detector: &DetectorUpdate{ | ||||
| ThresholdDb: &threshold, | ThresholdDb: &threshold, | ||||
| @@ -92,6 +94,9 @@ func TestApplyConfigUpdate(t *testing.T) { | |||||
| 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.Surveillance.DisplayBins != displayBins || updated.Surveillance.DisplayFPS != displayFPS { | |||||
| t.Fatalf("display settings not applied: bins=%d fps=%d", updated.Surveillance.DisplayBins, updated.Surveillance.DisplayFPS) | |||||
| } | |||||
| } | } | ||||
| func TestApplyConfigRejectsInvalid(t *testing.T) { | func TestApplyConfigRejectsInvalid(t *testing.T) { | ||||