| @@ -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 | |||
| } | |||
| @@ -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) | |||
| @@ -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 { | |||
| @@ -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 | |||
| } | |||
| @@ -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 | |||
| @@ -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 | |||
| } | |||