| @@ -133,6 +133,14 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * | |||||
| }) | }) | ||||
| } | } | ||||
| debugInfo = &SpectrumDebug{Thresholds: thresholds, NoiseFloor: noiseFloor, Scores: scoreDebug} | 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 { | if hasPlan { | ||||
| debugInfo.RefinementPlan = &plan | debugInfo.RefinementPlan = &plan | ||||
| } | } | ||||
| @@ -1,6 +1,7 @@ | |||||
| package main | package main | ||||
| import ( | import ( | ||||
| "fmt" | |||||
| "math" | "math" | ||||
| "strings" | "strings" | ||||
| "sync" | "sync" | ||||
| @@ -30,6 +31,8 @@ type rdsState struct { | |||||
| type dspRuntime struct { | type dspRuntime struct { | ||||
| cfg config.Config | cfg config.Config | ||||
| det *detector.Detector | det *detector.Detector | ||||
| derivedDetectors map[string]*derivedDetector | |||||
| nextDerivedBase int64 | |||||
| window []float64 | window []float64 | ||||
| plan *fftutil.CmplxPlan | plan *fftutil.CmplxPlan | ||||
| detailWindow []float64 | detailWindow []float64 | ||||
| @@ -65,6 +68,13 @@ type spectrumArtifacts struct { | |||||
| now time.Time | now time.Time | ||||
| } | } | ||||
| type derivedDetector struct { | |||||
| det *detector.Detector | |||||
| sampleRate int | |||||
| fftSize int | |||||
| idBase int64 | |||||
| } | |||||
| type surveillanceLevelSpec struct { | type surveillanceLevelSpec struct { | ||||
| Level pipeline.AnalysisLevel | Level pipeline.AnalysisLevel | ||||
| Decim int | Decim int | ||||
| @@ -80,6 +90,8 @@ type surveillancePlan struct { | |||||
| Specs []surveillanceLevelSpec | Specs []surveillanceLevelSpec | ||||
| } | } | ||||
| const derivedIDBlock = int64(1_000_000_000) | |||||
| func newDSPRuntime(cfg config.Config, det *detector.Detector, window []float64, gpuState *gpuStatus) *dspRuntime { | func newDSPRuntime(cfg config.Config, det *detector.Detector, window []float64, gpuState *gpuStatus) *dspRuntime { | ||||
| detailFFT := cfg.Refinement.DetailFFTSize | detailFFT := cfg.Refinement.DetailFFTSize | ||||
| if detailFFT <= 0 { | if detailFFT <= 0 { | ||||
| @@ -88,6 +100,8 @@ func newDSPRuntime(cfg config.Config, det *detector.Detector, window []float64, | |||||
| rt := &dspRuntime{ | rt := &dspRuntime{ | ||||
| cfg: cfg, | cfg: cfg, | ||||
| det: det, | det: det, | ||||
| derivedDetectors: map[string]*derivedDetector{}, | |||||
| nextDerivedBase: -derivedIDBlock, | |||||
| window: window, | window: window, | ||||
| plan: fftutil.NewCmplxPlan(cfg.FFTSize), | plan: fftutil.NewCmplxPlan(cfg.FFTSize), | ||||
| detailWindow: fftutil.Hann(detailFFT), | detailWindow: fftutil.Hann(detailFFT), | ||||
| @@ -168,6 +182,10 @@ func (rt *dspRuntime) applyUpdate(upd dspUpdate, srcMgr *sourceManager, rec *rec | |||||
| rt.survWindows = map[int][]float64{} | rt.survWindows = map[int][]float64{} | ||||
| rt.survPlans = map[int]*fftutil.CmplxPlan{} | 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.dcEnabled = upd.dcBlock | ||||
| rt.iqEnabled = upd.iqBalance | rt.iqEnabled = upd.iqBalance | ||||
| if rt.cfg.FFTSize != prevFFT || rt.cfg.UseGPUFFT != prevUseGPU { | 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 == "" { | if plan.Primary.Name == "" { | ||||
| plan = rt.buildSurveillancePlan(policy) | 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) | scheduled := pipeline.ScheduleCandidates(candidates, policy) | ||||
| return pipeline.SurveillanceResult{ | return pipeline.SurveillanceResult{ | ||||
| Level: plan.Primary, | 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 { | func (rt *dspRuntime) buildRefinementInput(surv pipeline.SurveillanceResult, now time.Time) pipeline.RefinementInput { | ||||
| policy := pipeline.PolicyFromConfig(rt.cfg) | policy := pipeline.PolicyFromConfig(rt.cfg) | ||||
| plan := pipeline.BuildRefinementPlan(surv.Candidates, policy) | plan := pipeline.BuildRefinementPlan(surv.Candidates, policy) | ||||
| @@ -14,13 +14,15 @@ import ( | |||||
| ) | ) | ||||
| type SpectrumDebug struct { | 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 { | 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"` | SNRWeight float64 `json:"snr_weight"` | ||||
| BandwidthWeight float64 `json:"bandwidth_weight"` | BandwidthWeight float64 `json:"bandwidth_weight"` | ||||
| PeakWeight float64 `json:"peak_weight"` | PeakWeight float64 `json:"peak_weight"` | ||||
| EvidenceWeight float64 `json:"evidence_weight"` | |||||
| } | } | ||||
| type RefinementScoreDetails struct { | type RefinementScoreDetails struct { | ||||
| @@ -23,6 +24,7 @@ type RefinementScoreDetails struct { | |||||
| BandwidthScore float64 `json:"bandwidth_score"` | BandwidthScore float64 `json:"bandwidth_score"` | ||||
| PeakScore float64 `json:"peak_score"` | PeakScore float64 `json:"peak_score"` | ||||
| PolicyBoost float64 `json:"policy_boost"` | PolicyBoost float64 `json:"policy_boost"` | ||||
| EvidenceScore float64 `json:"evidence_score"` | |||||
| } | } | ||||
| type RefinementScore struct { | type RefinementScore struct { | ||||
| @@ -105,6 +107,7 @@ func BuildRefinementPlan(candidates []Candidate, policy Policy) RefinementPlan { | |||||
| SNRWeight: snrWeight, | SNRWeight: snrWeight, | ||||
| BandwidthWeight: bwWeight, | BandwidthWeight: bwWeight, | ||||
| PeakWeight: peakWeight, | PeakWeight: peakWeight, | ||||
| EvidenceWeight: 0.6, | |||||
| } | } | ||||
| scoreModel = applyStrategyWeights(strategy, scoreModel) | scoreModel = applyStrategyWeights(strategy, scoreModel) | ||||
| plan.ScoreModel = scoreModel | plan.ScoreModel = scoreModel | ||||
| @@ -139,7 +142,9 @@ func BuildRefinementPlan(candidates []Candidate, policy Policy) RefinementPlan { | |||||
| if c.PeakDb > 0 { | if c.PeakDb > 0 { | ||||
| peakScore = (c.PeakDb / 20.0) * scoreModel.PeakWeight | peakScore = (c.PeakDb / 20.0) * scoreModel.PeakWeight | ||||
| } | } | ||||
| evidenceScore := candidateEvidenceScore(c) * scoreModel.EvidenceWeight | |||||
| priority := snrScore + bwScore + peakScore + policyBoost | priority := snrScore + bwScore + peakScore + policyBoost | ||||
| priority += evidenceScore | |||||
| score := &RefinementScore{ | score := &RefinementScore{ | ||||
| Total: priority, | Total: priority, | ||||
| Breakdown: RefinementScoreDetails{ | Breakdown: RefinementScoreDetails{ | ||||
| @@ -147,6 +152,7 @@ func BuildRefinementPlan(candidates []Candidate, policy Policy) RefinementPlan { | |||||
| BandwidthScore: bwScore, | BandwidthScore: bwScore, | ||||
| PeakScore: peakScore, | PeakScore: peakScore, | ||||
| PolicyBoost: policyBoost, | PolicyBoost: policyBoost, | ||||
| EvidenceScore: evidenceScore, | |||||
| }, | }, | ||||
| Weights: &scoreModel, | Weights: &scoreModel, | ||||
| } | } | ||||
| @@ -244,6 +250,14 @@ func applyStrategyWeights(strategy string, model RefinementScoreModel) Refinemen | |||||
| return model | 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 { | func minFloat64(a, b float64) float64 { | ||||
| if a < b { | if a < b { | ||||
| return a | return a | ||||
| @@ -75,7 +75,7 @@ func CandidatesFromSignalsWithLevel(signals []detector.Signal, source string, le | |||||
| } | } | ||||
| evidence := LevelEvidence{Level: level, Provenance: source} | evidence := LevelEvidence{Level: level, Provenance: source} | ||||
| for i := range out { | for i := range out { | ||||
| out[i].Evidence = append(out[i].Evidence, evidence) | |||||
| AddCandidateEvidence(&out[i], evidence) | |||||
| } | } | ||||
| return out | return out | ||||
| } | } | ||||