diff --git a/README.md b/README.md index 969d64e..9a7c920 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ Edit `config.yaml` (autosave goes to `config.autosave.yaml`). - `refinement.max_concurrent` — refinement budget hint - `refinement.min_candidate_snr_db` — floor for future scheduling decisions - `refinement.min_span_hz` / `refinement.max_span_hz` — clamp refinement window span (0 = no clamp) +- `refinement.auto_span` — use mod-type heuristics when candidate bandwidth is missing/odd - `resources.prefer_gpu` — GPU preference hint - `resources.max_refinement_jobs` — processing budget hint - `resources.max_recording_streams` — recording/streaming budget hint diff --git a/cmd/sdrd/pipeline_runtime.go b/cmd/sdrd/pipeline_runtime.go index 961c02e..f38a0d3 100644 --- a/cmd/sdrd/pipeline_runtime.go +++ b/cmd/sdrd/pipeline_runtime.go @@ -239,16 +239,27 @@ func (rt *dspRuntime) buildRefinementInput(surv pipeline.SurveillanceResult) pip windows := make([]pipeline.RefinementWindow, 0, len(scheduled)) for _, sc := range scheduled { span := sc.Candidate.BandwidthHz + windowSource := "candidate" + if policy.RefinementAutoSpan && (span <= 0 || span < 2000 || span > 400000) { + autoSpan, autoSource := pipeline.AutoSpanForHint(sc.Candidate.Hint) + if autoSpan > 0 { + span = autoSpan + windowSource = autoSource + } + } if policy.RefinementMinSpanHz > 0 && span < policy.RefinementMinSpanHz { span = policy.RefinementMinSpanHz } if policy.RefinementMaxSpanHz > 0 && span > policy.RefinementMaxSpanHz { span = policy.RefinementMaxSpanHz } + if span <= 0 { + span = 12000 + } windows = append(windows, pipeline.RefinementWindow{ CenterHz: sc.Candidate.CenterHz, SpanHz: span, - Source: "candidate", + Source: windowSource, }) } input := pipeline.RefinementInput{ diff --git a/config.yaml b/config.yaml index e560eb8..7915e24 100644 --- a/config.yaml +++ b/config.yaml @@ -33,6 +33,7 @@ refinement: min_candidate_snr_db: 0 min_span_hz: 0 max_span_hz: 0 + auto_span: true resources: prefer_gpu: true max_refinement_jobs: 8 diff --git a/internal/config/config.go b/internal/config/config.go index c77e25f..fd9d785 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -99,6 +99,7 @@ type RefinementConfig struct { MinCandidateSNRDb float64 `yaml:"min_candidate_snr_db" json:"min_candidate_snr_db"` MinSpanHz float64 `yaml:"min_span_hz" json:"min_span_hz"` MaxSpanHz float64 `yaml:"max_span_hz" json:"max_span_hz"` + AutoSpan bool `yaml:"auto_span" json:"auto_span"` } type ResourceConfig struct { @@ -177,6 +178,7 @@ func Default() Config { MinCandidateSNRDb: 0, MinSpanHz: 0, MaxSpanHz: 0, + AutoSpan: true, }, Resources: ResourceConfig{ PreferGPU: true, diff --git a/internal/pipeline/policy.go b/internal/pipeline/policy.go index dc16400..cabf6d0 100644 --- a/internal/pipeline/policy.go +++ b/internal/pipeline/policy.go @@ -20,6 +20,7 @@ type Policy struct { MinCandidateSNRDb float64 `json:"min_candidate_snr_db"` RefinementMinSpanHz float64 `json:"refinement_min_span_hz"` RefinementMaxSpanHz float64 `json:"refinement_max_span_hz"` + RefinementAutoSpan bool `json:"refinement_auto_span"` PreferGPU bool `json:"prefer_gpu"` } @@ -42,6 +43,7 @@ func PolicyFromConfig(cfg config.Config) Policy { MinCandidateSNRDb: cfg.Refinement.MinCandidateSNRDb, RefinementMinSpanHz: cfg.Refinement.MinSpanHz, RefinementMaxSpanHz: cfg.Refinement.MaxSpanHz, + RefinementAutoSpan: cfg.Refinement.AutoSpan, PreferGPU: cfg.Resources.PreferGPU, } } diff --git a/internal/pipeline/scheduler_test.go b/internal/pipeline/scheduler_test.go index 5fdef5a..b0c5565 100644 --- a/internal/pipeline/scheduler_test.go +++ b/internal/pipeline/scheduler_test.go @@ -44,6 +44,21 @@ func TestBuildRefinementPlanTracksDrops(t *testing.T) { } } +func TestAutoSpanForHint(t *testing.T) { + span, source := AutoSpanForHint("WFM_STEREO") + if span < 150000 || source == "" { + t.Fatalf("expected WFM span, got %.0f (%s)", span, source) + } + span, source = AutoSpanForHint("CW") + if span != 500 || source == "" { + t.Fatalf("expected CW span, got %.0f (%s)", span, source) + } + span, source = AutoSpanForHint("") + if span != 0 || source != "" { + t.Fatalf("expected empty span for unknown hint, got %.0f (%s)", span, source) + } +} + func TestScheduleCandidatesPriorityBoost(t *testing.T) { policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 0, SignalPriorities: []string{"digital"}} got := ScheduleCandidates([]Candidate{ diff --git a/internal/pipeline/window_rules.go b/internal/pipeline/window_rules.go new file mode 100644 index 0000000..f7a261f --- /dev/null +++ b/internal/pipeline/window_rules.go @@ -0,0 +1,30 @@ +package pipeline + +import "strings" + +// AutoSpanForHint returns a suggested refinement span based on the candidate hint. +// It is intentionally conservative: spans are wide enough for robust demod/classify, +// but not so wide that refinement becomes wasteful. +func AutoSpanForHint(hint string) (float64, string) { + h := strings.ToLower(hint) + switch { + case strings.Contains(h, "wfm"): + return 200000, "auto:wfm" + case strings.Contains(h, "nfm"): + return 18000, "auto:nfm" + case strings.Contains(h, "usb") || strings.Contains(h, "lsb") || strings.Contains(h, "ssb"): + return 6000, "auto:ssb" + case strings.Contains(h, "cw"): + return 500, "auto:cw" + case strings.Contains(h, "dmr") || strings.Contains(h, "d-star") || strings.Contains(h, "dstar"): + return 15000, "auto:dig_voice" + case strings.Contains(h, "ft8") || strings.Contains(h, "wspr"): + return 4000, "auto:dig_weak" + case strings.Contains(h, "fsk") || strings.Contains(h, "psk"): + return 6000, "auto:dig" + case strings.Contains(h, "am"): + return 12000, "auto:am" + default: + return 0, "" + } +}