diff --git a/cmd/sdrd/pipeline_runtime.go b/cmd/sdrd/pipeline_runtime.go index 0c60fc0..c578c06 100644 --- a/cmd/sdrd/pipeline_runtime.go +++ b/cmd/sdrd/pipeline_runtime.go @@ -74,6 +74,7 @@ type surveillanceLevelSpec struct { type surveillancePlan struct { Primary pipeline.AnalysisLevel Levels []pipeline.AnalysisLevel + LevelSet pipeline.SurveillanceLevelSet Presentation pipeline.AnalysisLevel Context pipeline.AnalysisContext Specs []surveillanceLevelSpec @@ -414,15 +415,16 @@ func (rt *dspRuntime) buildSurveillanceResult(art *spectrumArtifacts) pipeline.S return pipeline.SurveillanceResult{} } policy := pipeline.PolicyFromConfig(rt.cfg) - candidates := pipeline.CandidatesFromSignals(art.detected, "surveillance-detector") - scheduled := pipeline.ScheduleCandidates(candidates, policy) plan := art.surveillancePlan if plan.Primary.Name == "" { plan = rt.buildSurveillancePlan(policy) } + candidates := pipeline.CandidatesFromSignalsWithLevel(art.detected, "surveillance-detector", plan.Primary) + scheduled := pipeline.ScheduleCandidates(candidates, policy) return pipeline.SurveillanceResult{ Level: plan.Primary, Levels: plan.Levels, + LevelSet: plan.LevelSet, DisplayLevel: plan.Presentation, Context: plan.Context, Spectra: art.surveillanceSpectra, @@ -766,6 +768,7 @@ func (rt *dspRuntime) buildSurveillancePlan(policy pipeline.Policy) surveillance levels := []pipeline.AnalysisLevel{primary} specs := []surveillanceLevelSpec{{Level: primary, Decim: 1, AllowGPU: true}} context := pipeline.AnalysisContext{Surveillance: primary} + derivedLevels := make([]pipeline.AnalysisLevel, 0, 2) strategy := strings.ToLower(strings.TrimSpace(policy.SurveillanceStrategy)) switch strategy { @@ -779,15 +782,29 @@ func (rt *dspRuntime) buildSurveillancePlan(policy pipeline.Policy) surveillance levels = append(levels, derived) specs = append(specs, surveillanceLevelSpec{Level: derived, Decim: decim, AllowGPU: false}) context.Derived = append(context.Derived, derived) + derivedLevels = append(derivedLevels, derived) } } presentation := analysisLevel("presentation", "presentation", "presentation", baseRate, rt.cfg.Surveillance.DisplayBins, rt.cfg.CenterHz, span, "display", 1, baseRate) context.Presentation = presentation + levelSet := pipeline.SurveillanceLevelSet{ + Primary: primary, + Derived: append([]pipeline.AnalysisLevel(nil), derivedLevels...), + Presentation: presentation, + } + allLevels := make([]pipeline.AnalysisLevel, 0, 1+len(derivedLevels)+1) + allLevels = append(allLevels, primary) + allLevels = append(allLevels, derivedLevels...) + if presentation.Name != "" { + allLevels = append(allLevels, presentation) + } + levelSet.All = allLevels return surveillancePlan{ Primary: primary, Levels: levels, + LevelSet: levelSet, Presentation: presentation, Context: context, Specs: specs, diff --git a/internal/pipeline/phases.go b/internal/pipeline/phases.go index 783c9b8..b9ea486 100644 --- a/internal/pipeline/phases.go +++ b/internal/pipeline/phases.go @@ -28,9 +28,17 @@ type AnalysisContext struct { Derived []AnalysisLevel `json:"derived,omitempty"` } +type SurveillanceLevelSet struct { + Primary AnalysisLevel `json:"primary"` + Derived []AnalysisLevel `json:"derived,omitempty"` + Presentation AnalysisLevel `json:"presentation,omitempty"` + All []AnalysisLevel `json:"all,omitempty"` +} + type SurveillanceResult struct { Level AnalysisLevel `json:"level"` Levels []AnalysisLevel `json:"levels,omitempty"` + LevelSet SurveillanceLevelSet `json:"level_set,omitempty"` Candidates []Candidate `json:"candidates"` Scheduled []ScheduledCandidate `json:"scheduled,omitempty"` Finished []detector.Event `json:"finished"` diff --git a/internal/pipeline/types.go b/internal/pipeline/types.go index e248e0c..f5449b0 100644 --- a/internal/pipeline/types.go +++ b/internal/pipeline/types.go @@ -8,16 +8,24 @@ import ( // Candidate is the coarse output of the surveillance detector. // It intentionally stays lightweight and cheap to produce. type Candidate struct { - ID int64 `json:"id"` - CenterHz float64 `json:"center_hz"` - BandwidthHz float64 `json:"bandwidth_hz"` - PeakDb float64 `json:"peak_db"` - SNRDb float64 `json:"snr_db"` - FirstBin int `json:"first_bin"` - LastBin int `json:"last_bin"` - NoiseDb float64 `json:"noise_db,omitempty"` - Source string `json:"source,omitempty"` - Hint string `json:"hint,omitempty"` + ID int64 `json:"id"` + CenterHz float64 `json:"center_hz"` + BandwidthHz float64 `json:"bandwidth_hz"` + PeakDb float64 `json:"peak_db"` + SNRDb float64 `json:"snr_db"` + FirstBin int `json:"first_bin"` + LastBin int `json:"last_bin"` + NoiseDb float64 `json:"noise_db,omitempty"` + Source string `json:"source,omitempty"` + Hint string `json:"hint,omitempty"` + Evidence []LevelEvidence `json:"evidence,omitempty"` +} + +// LevelEvidence captures which analysis level produced a candidate. +// This is intentionally lightweight for later multi-level fusion. +type LevelEvidence struct { + Level AnalysisLevel `json:"level"` + Provenance string `json:"provenance,omitempty"` } // RefinementWindow describes the local analysis span that refinement should use. @@ -59,3 +67,15 @@ func CandidatesFromSignals(signals []detector.Signal, source string) []Candidate } return out } + +func CandidatesFromSignalsWithLevel(signals []detector.Signal, source string, level AnalysisLevel) []Candidate { + out := CandidatesFromSignals(signals, source) + if level.Name == "" && level.FFTSize == 0 && level.SampleRate == 0 { + return out + } + evidence := LevelEvidence{Level: level, Provenance: source} + for i := range out { + out[i].Evidence = append(out[i].Evidence, evidence) + } + return out +}