diff --git a/README.md b/README.md index 967cc82..fdce0cb 100644 --- a/README.md +++ b/README.md @@ -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.frame_rate` — surveillance cadence target - `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.max_concurrent` — refinement budget hint - `refinement.min_candidate_snr_db` — floor for future scheduling decisions diff --git a/cmd/sdrd/surveillance_test.go b/cmd/sdrd/surveillance_test.go new file mode 100644 index 0000000..14094c4 --- /dev/null +++ b/cmd/sdrd/surveillance_test.go @@ -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) + } +} diff --git a/config.yaml b/config.yaml index 7a881e6..b7d94aa 100644 --- a/config.yaml +++ b/config.yaml @@ -25,6 +25,8 @@ surveillance: analysis_fft_size: 2048 frame_rate: 15 strategy: single-resolution + display_bins: 2048 + display_fps: 15 refinement: enabled: true max_concurrent: 8 diff --git a/internal/config/config.go b/internal/config/config.go index e321a5e..9af7a1b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -89,6 +89,8 @@ type SurveillanceConfig struct { AnalysisFFTSize int `yaml:"analysis_fft_size" json:"analysis_fft_size"` FrameRate int `yaml:"frame_rate" json:"frame_rate"` 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 { @@ -164,6 +166,8 @@ func Default() Config { AnalysisFFTSize: 2048, FrameRate: 15, Strategy: "single-resolution", + DisplayBins: 2048, + DisplayFPS: 15, }, Refinement: RefinementConfig{ Enabled: true, @@ -330,6 +334,12 @@ func applyDefaults(cfg Config) Config { if cfg.Surveillance.Strategy == "" { 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 { // 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 { diff --git a/internal/pipeline/policy.go b/internal/pipeline/policy.go index 127a9c0..e1c2f6a 100644 --- a/internal/pipeline/policy.go +++ b/internal/pipeline/policy.go @@ -13,6 +13,8 @@ type Policy struct { AutoDecodeClasses []string `json:"auto_decode_classes,omitempty"` SurveillanceFFTSize int `json:"surveillance_fft_size"` SurveillanceFPS int `json:"surveillance_fps"` + DisplayBins int `json:"display_bins"` + DisplayFPS int `json:"display_fps"` RefinementEnabled bool `json:"refinement_enabled"` MaxRefinementJobs int `json:"max_refinement_jobs"` 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...), SurveillanceFFTSize: cfg.Surveillance.AnalysisFFTSize, SurveillanceFPS: cfg.Surveillance.FrameRate, + DisplayBins: cfg.Surveillance.DisplayBins, + DisplayFPS: cfg.Surveillance.DisplayFPS, RefinementEnabled: cfg.Refinement.Enabled, MaxRefinementJobs: cfg.Resources.MaxRefinementJobs, MinCandidateSNRDb: cfg.Refinement.MinCandidateSNRDb, diff --git a/internal/pipeline/policy_test.go b/internal/pipeline/policy_test.go index ad7d21f..629525b 100644 --- a/internal/pipeline/policy_test.go +++ b/internal/pipeline/policy_test.go @@ -36,12 +36,14 @@ func TestPolicyFromConfig(t *testing.T) { cfg.Pipeline.Goals.SignalPriorities = []string{"broadcast-fm", "rds"} cfg.Surveillance.AnalysisFFTSize = 8192 cfg.Surveillance.FrameRate = 9 + cfg.Surveillance.DisplayBins = 1200 + cfg.Surveillance.DisplayFPS = 6 cfg.Refinement.Enabled = true cfg.Resources.MaxRefinementJobs = 5 cfg.Refinement.MinCandidateSNRDb = 2.5 cfg.Resources.PreferGPU = true 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) } if p.MonitorSpanHz != 20e6 || len(p.SignalPriorities) != 2 { diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 8af9d9e..8115652 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -18,6 +18,8 @@ type SurveillanceUpdate struct { AnalysisFFTSize *int `json:"analysis_fft_size"` FrameRate *int `json:"frame_rate"` Strategy *string `json:"strategy"` + DisplayBins *int `json:"display_bins"` + DisplayFPS *int `json:"display_fps"` } type RefinementUpdate struct { @@ -187,6 +189,20 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { if update.Surveillance.Strategy != nil { 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.Enabled != nil { diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 4660d22..365b98f 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -25,6 +25,8 @@ func TestApplyConfigUpdate(t *testing.T) { mode := "wideband-balanced" profile := "wideband-balanced" survFPS := 12 + displayBins := 1024 + displayFPS := 8 maxRefJobs := 24 updated, err := mgr.ApplyConfig(ConfigUpdate{ CenterHz: ¢er, @@ -32,7 +34,7 @@ func TestApplyConfigUpdate(t *testing.T) { FFTSize: &fftSize, TunerBwKHz: &bw, Pipeline: &PipelineUpdate{Mode: &mode, Profile: &profile}, - Surveillance: &SurveillanceUpdate{FrameRate: &survFPS}, + Surveillance: &SurveillanceUpdate{FrameRate: &survFPS, DisplayBins: &displayBins, DisplayFPS: &displayFPS}, Resources: &ResourcesUpdate{MaxRefinementJobs: &maxRefJobs}, Detector: &DetectorUpdate{ ThresholdDb: &threshold, @@ -92,6 +94,9 @@ func TestApplyConfigUpdate(t *testing.T) { if updated.Resources.MaxRefinementJobs != maxRefJobs { 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) {