From 1524000fc334a6fa784642f1d30d26e8f69dc3bc Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Sun, 22 Mar 2026 10:05:07 +0100 Subject: [PATCH] Add derived candidate fusion and evidence scoring --- cmd/sdrd/dsp_loop.go | 8 ++ cmd/sdrd/pipeline_runtime.go | 97 +++++++++++++++++++- cmd/sdrd/types.go | 16 ++-- internal/pipeline/candidate_fusion.go | 124 ++++++++++++++++++++++++++ internal/pipeline/scheduler.go | 14 +++ internal/pipeline/types.go | 2 +- 6 files changed, 252 insertions(+), 9 deletions(-) create mode 100644 internal/pipeline/candidate_fusion.go diff --git a/cmd/sdrd/dsp_loop.go b/cmd/sdrd/dsp_loop.go index b351895..3716a6f 100644 --- a/cmd/sdrd/dsp_loop.go +++ b/cmd/sdrd/dsp_loop.go @@ -133,6 +133,14 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * }) } debugInfo = &SpectrumDebug{Thresholds: thresholds, NoiseFloor: noiseFloor, Scores: scoreDebug} + candidateSources := buildCandidateSourceSummary(state.surveillance.Candidates) + candidateEvidence := buildCandidateEvidenceSummary(state.surveillance.Candidates) + if len(candidateSources) > 0 { + debugInfo.CandidateSources = candidateSources + } + if len(candidateEvidence) > 0 { + debugInfo.CandidateEvidence = candidateEvidence + } if hasPlan { debugInfo.RefinementPlan = &plan } diff --git a/cmd/sdrd/pipeline_runtime.go b/cmd/sdrd/pipeline_runtime.go index c578c06..e272acf 100644 --- a/cmd/sdrd/pipeline_runtime.go +++ b/cmd/sdrd/pipeline_runtime.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "math" "strings" "sync" @@ -30,6 +31,8 @@ type rdsState struct { type dspRuntime struct { cfg config.Config det *detector.Detector + derivedDetectors map[string]*derivedDetector + nextDerivedBase int64 window []float64 plan *fftutil.CmplxPlan detailWindow []float64 @@ -65,6 +68,13 @@ type spectrumArtifacts struct { now time.Time } +type derivedDetector struct { + det *detector.Detector + sampleRate int + fftSize int + idBase int64 +} + type surveillanceLevelSpec struct { Level pipeline.AnalysisLevel Decim int @@ -80,6 +90,8 @@ type surveillancePlan struct { Specs []surveillanceLevelSpec } +const derivedIDBlock = int64(1_000_000_000) + func newDSPRuntime(cfg config.Config, det *detector.Detector, window []float64, gpuState *gpuStatus) *dspRuntime { detailFFT := cfg.Refinement.DetailFFTSize if detailFFT <= 0 { @@ -88,6 +100,8 @@ func newDSPRuntime(cfg config.Config, det *detector.Detector, window []float64, rt := &dspRuntime{ cfg: cfg, det: det, + derivedDetectors: map[string]*derivedDetector{}, + nextDerivedBase: -derivedIDBlock, window: window, plan: fftutil.NewCmplxPlan(cfg.FFTSize), detailWindow: fftutil.Hann(detailFFT), @@ -168,6 +182,10 @@ func (rt *dspRuntime) applyUpdate(upd dspUpdate, srcMgr *sourceManager, rec *rec rt.survWindows = map[int][]float64{} rt.survPlans = map[int]*fftutil.CmplxPlan{} } + if upd.det != nil || prevSampleRate != rt.cfg.SampleRate || prevFFT != rt.cfg.FFTSize { + rt.derivedDetectors = map[string]*derivedDetector{} + rt.nextDerivedBase = -derivedIDBlock + } rt.dcEnabled = upd.dcBlock rt.iqEnabled = upd.iqBalance if rt.cfg.FFTSize != prevFFT || rt.cfg.UseGPUFFT != prevUseGPU { @@ -419,7 +437,9 @@ func (rt *dspRuntime) buildSurveillanceResult(art *spectrumArtifacts) pipeline.S if plan.Primary.Name == "" { plan = rt.buildSurveillancePlan(policy) } - candidates := pipeline.CandidatesFromSignalsWithLevel(art.detected, "surveillance-detector", plan.Primary) + primaryCandidates := pipeline.CandidatesFromSignalsWithLevel(art.detected, "surveillance-detector", plan.Primary) + derivedCandidates := rt.detectDerivedCandidates(art, plan) + candidates := pipeline.FuseCandidates(primaryCandidates, derivedCandidates) scheduled := pipeline.ScheduleCandidates(candidates, policy) return pipeline.SurveillanceResult{ Level: plan.Primary, @@ -437,6 +457,81 @@ func (rt *dspRuntime) buildSurveillanceResult(art *spectrumArtifacts) pipeline.S } } +func (rt *dspRuntime) detectDerivedCandidates(art *spectrumArtifacts, plan surveillancePlan) []pipeline.Candidate { + if art == nil || len(plan.LevelSet.Derived) == 0 { + return nil + } + spectra := map[string][]float64{} + for _, spec := range art.surveillanceSpectra { + if spec.Level.Name == "" || len(spec.Spectrum) == 0 { + continue + } + spectra[spec.Level.Name] = spec.Spectrum + } + if len(spectra) == 0 { + return nil + } + out := make([]pipeline.Candidate, 0, len(plan.LevelSet.Derived)) + for _, level := range plan.LevelSet.Derived { + if level.Name == "" { + continue + } + spectrum := spectra[level.Name] + if len(spectrum) == 0 { + continue + } + entry := rt.derivedDetectorForLevel(level) + if entry == nil || entry.det == nil { + continue + } + _, signals := entry.det.Process(art.now, spectrum, level.CenterHz) + if len(signals) == 0 { + continue + } + cands := pipeline.CandidatesFromSignalsWithLevel(signals, "surveillance-derived", level) + for i := range cands { + if cands[i].ID == 0 { + continue + } + cands[i].ID = entry.idBase - cands[i].ID + } + out = append(out, cands...) + } + if len(out) == 0 { + return nil + } + return out +} + +func (rt *dspRuntime) derivedDetectorForLevel(level pipeline.AnalysisLevel) *derivedDetector { + if level.SampleRate <= 0 || level.FFTSize <= 0 { + return nil + } + if rt.derivedDetectors == nil { + rt.derivedDetectors = map[string]*derivedDetector{} + } + key := level.Name + if key == "" { + key = fmt.Sprintf("%d:%d", level.SampleRate, level.FFTSize) + } + entry := rt.derivedDetectors[key] + if entry != nil && entry.sampleRate == level.SampleRate && entry.fftSize == level.FFTSize { + return entry + } + if rt.nextDerivedBase == 0 { + rt.nextDerivedBase = -derivedIDBlock + } + entry = &derivedDetector{ + det: detector.New(rt.cfg.Detector, level.SampleRate, level.FFTSize), + sampleRate: level.SampleRate, + fftSize: level.FFTSize, + idBase: rt.nextDerivedBase, + } + rt.nextDerivedBase -= derivedIDBlock + rt.derivedDetectors[key] = entry + return entry +} + func (rt *dspRuntime) buildRefinementInput(surv pipeline.SurveillanceResult, now time.Time) pipeline.RefinementInput { policy := pipeline.PolicyFromConfig(rt.cfg) plan := pipeline.BuildRefinementPlan(surv.Candidates, policy) diff --git a/cmd/sdrd/types.go b/cmd/sdrd/types.go index 493aa89..f964dfc 100644 --- a/cmd/sdrd/types.go +++ b/cmd/sdrd/types.go @@ -14,13 +14,15 @@ import ( ) type SpectrumDebug struct { - Thresholds []float64 `json:"thresholds,omitempty"` - NoiseFloor float64 `json:"noise_floor,omitempty"` - Scores []map[string]any `json:"scores,omitempty"` - RefinementPlan *pipeline.RefinementPlan `json:"refinement_plan,omitempty"` - Windows *RefinementWindowStats `json:"refinement_windows,omitempty"` - Refinement *RefinementDebug `json:"refinement,omitempty"` - Decisions *DecisionDebug `json:"decisions,omitempty"` + Thresholds []float64 `json:"thresholds,omitempty"` + NoiseFloor float64 `json:"noise_floor,omitempty"` + Scores []map[string]any `json:"scores,omitempty"` + CandidateSources map[string]int `json:"candidate_sources,omitempty"` + CandidateEvidence []CandidateEvidenceSummary `json:"candidate_evidence,omitempty"` + RefinementPlan *pipeline.RefinementPlan `json:"refinement_plan,omitempty"` + Windows *RefinementWindowStats `json:"refinement_windows,omitempty"` + Refinement *RefinementDebug `json:"refinement,omitempty"` + Decisions *DecisionDebug `json:"decisions,omitempty"` } type RefinementWindowStats struct { diff --git a/internal/pipeline/candidate_fusion.go b/internal/pipeline/candidate_fusion.go new file mode 100644 index 0000000..d754896 --- /dev/null +++ b/internal/pipeline/candidate_fusion.go @@ -0,0 +1,124 @@ +package pipeline + +import "math" + +func AddCandidateEvidence(candidate *Candidate, evidence LevelEvidence) { + if candidate == nil { + return + } + levelName := evidence.Level.Name + if levelName == "" { + levelName = "unknown" + } + for _, ev := range candidate.Evidence { + evLevel := ev.Level.Name + if evLevel == "" { + evLevel = "unknown" + } + if evLevel == levelName && ev.Provenance == evidence.Provenance { + return + } + } + candidate.Evidence = append(candidate.Evidence, evidence) +} + +func MergeCandidateEvidence(dst *Candidate, src Candidate) { + if dst == nil || len(src.Evidence) == 0 { + return + } + for _, ev := range src.Evidence { + AddCandidateEvidence(dst, ev) + } +} + +func CandidateEvidenceLevelCount(candidate Candidate) int { + if len(candidate.Evidence) == 0 { + return 0 + } + levels := map[string]struct{}{} + for _, ev := range candidate.Evidence { + name := ev.Level.Name + if name == "" { + name = "unknown" + } + levels[name] = struct{}{} + } + return len(levels) +} + +func FuseCandidates(primary []Candidate, derived []Candidate) []Candidate { + if len(primary) == 0 && len(derived) == 0 { + return nil + } + out := make([]Candidate, 0, len(primary)+len(derived)) + out = append(out, primary...) + if len(derived) == 0 { + return out + } + used := make([]bool, len(derived)) + for i := range out { + for j, cand := range derived { + if used[j] { + continue + } + if !candidatesOverlap(out[i], cand) { + continue + } + MergeCandidateEvidence(&out[i], cand) + used[j] = true + } + } + for j, cand := range derived { + if used[j] { + continue + } + out = append(out, cand) + } + return out +} + +func candidatesOverlap(a Candidate, b Candidate) bool { + spanA := candidateSpanHz(a) + spanB := candidateSpanHz(b) + if spanA <= 0 { + spanA = 25000 + } + if spanB <= 0 { + spanB = 25000 + } + guard := 0.0 + if binA, binB := candidateBinHz(a), candidateBinHz(b); binA > 0 || binB > 0 { + guard = 0.5 * math.Max(binA, binB) + } + leftA := a.CenterHz - spanA/2 - guard + rightA := a.CenterHz + spanA/2 + guard + leftB := b.CenterHz - spanB/2 - guard + rightB := b.CenterHz + spanB/2 + guard + return leftA <= rightB && leftB <= rightA +} + +func candidateSpanHz(candidate Candidate) float64 { + if candidate.BandwidthHz > 0 { + return candidate.BandwidthHz + } + if candidate.LastBin < candidate.FirstBin { + return 0 + } + binHz := candidateBinHz(candidate) + if binHz <= 0 { + return 0 + } + return float64(candidate.LastBin-candidate.FirstBin+1) * binHz +} + +func candidateBinHz(candidate Candidate) float64 { + for _, ev := range candidate.Evidence { + if ev.Level.BinHz > 0 { + return ev.Level.BinHz + } + if ev.Level.SampleRate > 0 && ev.Level.FFTSize > 0 { + return float64(ev.Level.SampleRate) / float64(ev.Level.FFTSize) + } + } + return 0 +} diff --git a/internal/pipeline/scheduler.go b/internal/pipeline/scheduler.go index 16892db..cb563f4 100644 --- a/internal/pipeline/scheduler.go +++ b/internal/pipeline/scheduler.go @@ -16,6 +16,7 @@ type RefinementScoreModel struct { SNRWeight float64 `json:"snr_weight"` BandwidthWeight float64 `json:"bandwidth_weight"` PeakWeight float64 `json:"peak_weight"` + EvidenceWeight float64 `json:"evidence_weight"` } type RefinementScoreDetails struct { @@ -23,6 +24,7 @@ type RefinementScoreDetails struct { BandwidthScore float64 `json:"bandwidth_score"` PeakScore float64 `json:"peak_score"` PolicyBoost float64 `json:"policy_boost"` + EvidenceScore float64 `json:"evidence_score"` } type RefinementScore struct { @@ -105,6 +107,7 @@ func BuildRefinementPlan(candidates []Candidate, policy Policy) RefinementPlan { SNRWeight: snrWeight, BandwidthWeight: bwWeight, PeakWeight: peakWeight, + EvidenceWeight: 0.6, } scoreModel = applyStrategyWeights(strategy, scoreModel) plan.ScoreModel = scoreModel @@ -139,7 +142,9 @@ func BuildRefinementPlan(candidates []Candidate, policy Policy) RefinementPlan { if c.PeakDb > 0 { peakScore = (c.PeakDb / 20.0) * scoreModel.PeakWeight } + evidenceScore := candidateEvidenceScore(c) * scoreModel.EvidenceWeight priority := snrScore + bwScore + peakScore + policyBoost + priority += evidenceScore score := &RefinementScore{ Total: priority, Breakdown: RefinementScoreDetails{ @@ -147,6 +152,7 @@ func BuildRefinementPlan(candidates []Candidate, policy Policy) RefinementPlan { BandwidthScore: bwScore, PeakScore: peakScore, PolicyBoost: policyBoost, + EvidenceScore: evidenceScore, }, Weights: &scoreModel, } @@ -244,6 +250,14 @@ func applyStrategyWeights(strategy string, model RefinementScoreModel) Refinemen return model } +func candidateEvidenceScore(candidate Candidate) float64 { + levels := CandidateEvidenceLevelCount(candidate) + if levels <= 1 { + return 0 + } + return float64(levels - 1) +} + func minFloat64(a, b float64) float64 { if a < b { return a diff --git a/internal/pipeline/types.go b/internal/pipeline/types.go index f5449b0..f453edb 100644 --- a/internal/pipeline/types.go +++ b/internal/pipeline/types.go @@ -75,7 +75,7 @@ func CandidatesFromSignalsWithLevel(signals []detector.Signal, source string, le } evidence := LevelEvidence{Level: level, Provenance: source} for i := range out { - out[i].Evidence = append(out[i].Evidence, evidence) + AddCandidateEvidence(&out[i], evidence) } return out }