| @@ -71,6 +71,7 @@ Edit `config.yaml` (autosave goes to `config.autosave.yaml`). | |||||
| - `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 | ||||
| - `refinement.min_span_hz` / `refinement.max_span_hz` — clamp refinement window span (0 = no clamp) | - `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.prefer_gpu` — GPU preference hint | ||||
| - `resources.max_refinement_jobs` — processing budget hint | - `resources.max_refinement_jobs` — processing budget hint | ||||
| - `resources.max_recording_streams` — recording/streaming budget hint | - `resources.max_recording_streams` — recording/streaming budget hint | ||||
| @@ -239,16 +239,27 @@ func (rt *dspRuntime) buildRefinementInput(surv pipeline.SurveillanceResult) pip | |||||
| windows := make([]pipeline.RefinementWindow, 0, len(scheduled)) | windows := make([]pipeline.RefinementWindow, 0, len(scheduled)) | ||||
| for _, sc := range scheduled { | for _, sc := range scheduled { | ||||
| span := sc.Candidate.BandwidthHz | 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 { | if policy.RefinementMinSpanHz > 0 && span < policy.RefinementMinSpanHz { | ||||
| span = policy.RefinementMinSpanHz | span = policy.RefinementMinSpanHz | ||||
| } | } | ||||
| if policy.RefinementMaxSpanHz > 0 && span > policy.RefinementMaxSpanHz { | if policy.RefinementMaxSpanHz > 0 && span > policy.RefinementMaxSpanHz { | ||||
| span = policy.RefinementMaxSpanHz | span = policy.RefinementMaxSpanHz | ||||
| } | } | ||||
| if span <= 0 { | |||||
| span = 12000 | |||||
| } | |||||
| windows = append(windows, pipeline.RefinementWindow{ | windows = append(windows, pipeline.RefinementWindow{ | ||||
| CenterHz: sc.Candidate.CenterHz, | CenterHz: sc.Candidate.CenterHz, | ||||
| SpanHz: span, | SpanHz: span, | ||||
| Source: "candidate", | |||||
| Source: windowSource, | |||||
| }) | }) | ||||
| } | } | ||||
| input := pipeline.RefinementInput{ | input := pipeline.RefinementInput{ | ||||
| @@ -33,6 +33,7 @@ refinement: | |||||
| min_candidate_snr_db: 0 | min_candidate_snr_db: 0 | ||||
| min_span_hz: 0 | min_span_hz: 0 | ||||
| max_span_hz: 0 | max_span_hz: 0 | ||||
| auto_span: true | |||||
| resources: | resources: | ||||
| prefer_gpu: true | prefer_gpu: true | ||||
| max_refinement_jobs: 8 | max_refinement_jobs: 8 | ||||
| @@ -99,6 +99,7 @@ type RefinementConfig struct { | |||||
| MinCandidateSNRDb float64 `yaml:"min_candidate_snr_db" json:"min_candidate_snr_db"` | MinCandidateSNRDb float64 `yaml:"min_candidate_snr_db" json:"min_candidate_snr_db"` | ||||
| MinSpanHz float64 `yaml:"min_span_hz" json:"min_span_hz"` | MinSpanHz float64 `yaml:"min_span_hz" json:"min_span_hz"` | ||||
| MaxSpanHz float64 `yaml:"max_span_hz" json:"max_span_hz"` | MaxSpanHz float64 `yaml:"max_span_hz" json:"max_span_hz"` | ||||
| AutoSpan bool `yaml:"auto_span" json:"auto_span"` | |||||
| } | } | ||||
| type ResourceConfig struct { | type ResourceConfig struct { | ||||
| @@ -177,6 +178,7 @@ func Default() Config { | |||||
| MinCandidateSNRDb: 0, | MinCandidateSNRDb: 0, | ||||
| MinSpanHz: 0, | MinSpanHz: 0, | ||||
| MaxSpanHz: 0, | MaxSpanHz: 0, | ||||
| AutoSpan: true, | |||||
| }, | }, | ||||
| Resources: ResourceConfig{ | Resources: ResourceConfig{ | ||||
| PreferGPU: true, | PreferGPU: true, | ||||
| @@ -20,6 +20,7 @@ type Policy struct { | |||||
| MinCandidateSNRDb float64 `json:"min_candidate_snr_db"` | MinCandidateSNRDb float64 `json:"min_candidate_snr_db"` | ||||
| RefinementMinSpanHz float64 `json:"refinement_min_span_hz"` | RefinementMinSpanHz float64 `json:"refinement_min_span_hz"` | ||||
| RefinementMaxSpanHz float64 `json:"refinement_max_span_hz"` | RefinementMaxSpanHz float64 `json:"refinement_max_span_hz"` | ||||
| RefinementAutoSpan bool `json:"refinement_auto_span"` | |||||
| PreferGPU bool `json:"prefer_gpu"` | PreferGPU bool `json:"prefer_gpu"` | ||||
| } | } | ||||
| @@ -42,6 +43,7 @@ func PolicyFromConfig(cfg config.Config) Policy { | |||||
| MinCandidateSNRDb: cfg.Refinement.MinCandidateSNRDb, | MinCandidateSNRDb: cfg.Refinement.MinCandidateSNRDb, | ||||
| RefinementMinSpanHz: cfg.Refinement.MinSpanHz, | RefinementMinSpanHz: cfg.Refinement.MinSpanHz, | ||||
| RefinementMaxSpanHz: cfg.Refinement.MaxSpanHz, | RefinementMaxSpanHz: cfg.Refinement.MaxSpanHz, | ||||
| RefinementAutoSpan: cfg.Refinement.AutoSpan, | |||||
| PreferGPU: cfg.Resources.PreferGPU, | PreferGPU: cfg.Resources.PreferGPU, | ||||
| } | } | ||||
| } | } | ||||
| @@ -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) { | func TestScheduleCandidatesPriorityBoost(t *testing.T) { | ||||
| policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 0, SignalPriorities: []string{"digital"}} | policy := Policy{MaxRefinementJobs: 1, MinCandidateSNRDb: 0, SignalPriorities: []string{"digital"}} | ||||
| got := ScheduleCandidates([]Candidate{ | got := ScheduleCandidates([]Candidate{ | ||||
| @@ -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, "" | |||||
| } | |||||
| } | |||||