From e33efb6c3dbf3dd59fbdf00a9837b981f94fe1c3 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Sat, 21 Mar 2026 18:14:25 +0100 Subject: [PATCH] feat: introduce explicit surveillance and refinement phase results --- cmd/sdrd/dsp_loop.go | 11 +++++++---- cmd/sdrd/phase_state.go | 8 ++++++++ cmd/sdrd/phase_state_test.go | 20 ++++++++++++++++++++ cmd/sdrd/pipeline_runtime.go | 21 ++++++++++++++++++--- internal/pipeline/phases.go | 16 ++++++++++++++++ internal/pipeline/phases_test.go | 17 +++++++++++++++++ 6 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 cmd/sdrd/phase_state.go create mode 100644 cmd/sdrd/phase_state_test.go create mode 100644 internal/pipeline/phases.go create mode 100644 internal/pipeline/phases_test.go diff --git a/cmd/sdrd/dsp_loop.go b/cmd/sdrd/dsp_loop.go index 7696692..6047902 100644 --- a/cmd/sdrd/dsp_loop.go +++ b/cmd/sdrd/dsp_loop.go @@ -29,6 +29,7 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * defer logTicker.Stop() enc := json.NewEncoder(eventFile) dcBlocker := dsp.NewDCBlocker(0.995) + state := &phaseState{} for { select { case <-ctx.Done(): @@ -55,12 +56,14 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * log.Printf("received IQ samples") rt.gotSamples = true } - finished := art.finished - thresholds := art.thresholds - noiseFloor := art.noiseFloor + state.surveillance = rt.buildSurveillanceResult(art) + finished := state.surveillance.Finished + thresholds := state.surveillance.Thresholds + noiseFloor := state.surveillance.NoiseFloor var displaySignals []detector.Signal if len(art.iq) > 0 { - displaySignals = rt.refineSignals(art, extractMgr, rec) + state.refinement = rt.refineSignals(art, extractMgr, rec) + displaySignals = state.refinement.Signals if rec != nil && len(displaySignals) > 0 && len(art.allIQ) > 0 { aqCfg := extractionConfig{firTaps: rt.cfg.Recorder.ExtractionTaps, bwMult: rt.cfg.Recorder.ExtractionBwMult} streamSnips, streamRates := extractForStreaming(extractMgr, art.allIQ, rt.cfg.SampleRate, rt.cfg.CenterHz, displaySignals, rt.streamPhaseState, rt.streamOverlap, aqCfg) diff --git a/cmd/sdrd/phase_state.go b/cmd/sdrd/phase_state.go new file mode 100644 index 0000000..11f053c --- /dev/null +++ b/cmd/sdrd/phase_state.go @@ -0,0 +1,8 @@ +package main + +import "sdr-wideband-suite/internal/pipeline" + +type phaseState struct { + surveillance pipeline.SurveillanceResult + refinement pipeline.RefinementResult +} diff --git a/cmd/sdrd/phase_state_test.go b/cmd/sdrd/phase_state_test.go new file mode 100644 index 0000000..9aadbf4 --- /dev/null +++ b/cmd/sdrd/phase_state_test.go @@ -0,0 +1,20 @@ +package main + +import ( + "testing" + + "sdr-wideband-suite/internal/pipeline" +) + +func TestPhaseStateCarriesPhaseResults(t *testing.T) { + ps := &phaseState{ + surveillance: pipeline.SurveillanceResult{NoiseFloor: -90}, + refinement: pipeline.RefinementResult{Decisions: []pipeline.SignalDecision{{ShouldRecord: true}}}, + } + if ps.surveillance.NoiseFloor != -90 { + t.Fatalf("unexpected surveillance state: %+v", ps.surveillance) + } + if len(ps.refinement.Decisions) != 1 || !ps.refinement.Decisions[0].ShouldRecord { + t.Fatalf("unexpected refinement state: %+v", ps.refinement) + } +} diff --git a/cmd/sdrd/pipeline_runtime.go b/cmd/sdrd/pipeline_runtime.go index 041a3db..83a2aa4 100644 --- a/cmd/sdrd/pipeline_runtime.go +++ b/cmd/sdrd/pipeline_runtime.go @@ -212,9 +212,22 @@ func (rt *dspRuntime) captureSpectrum(srcMgr *sourceManager, rec *recorder.Manag }, nil } -func (rt *dspRuntime) refineSignals(art *spectrumArtifacts, extractMgr *extractionManager, rec *recorder.Manager) []detector.Signal { +func (rt *dspRuntime) buildSurveillanceResult(art *spectrumArtifacts) pipeline.SurveillanceResult { + if art == nil { + return pipeline.SurveillanceResult{} + } + return pipeline.SurveillanceResult{ + Candidates: pipeline.CandidatesFromSignals(art.detected, "surveillance-detector"), + Finished: art.finished, + Signals: art.detected, + NoiseFloor: art.noiseFloor, + Thresholds: art.thresholds, + } +} + +func (rt *dspRuntime) refineSignals(art *spectrumArtifacts, extractMgr *extractionManager, rec *recorder.Manager) pipeline.RefinementResult { if art == nil || len(art.iq) == 0 { - return nil + return pipeline.RefinementResult{} } policy := pipeline.PolicyFromConfig(rt.cfg) candidates := pipeline.CandidatesFromSignals(art.detected, "surveillance-detector") @@ -237,12 +250,14 @@ func (rt *dspRuntime) refineSignals(art *spectrumArtifacts, extractMgr *extracti snips, snipRates := extractSignalIQBatch(extractMgr, art.iq, rt.cfg.SampleRate, rt.cfg.CenterHz, selectedSignals) refined := pipeline.RefineCandidates(selectedCandidates, art.spectrum, rt.cfg.SampleRate, rt.cfg.FFTSize, snips, snipRates, classifier.ClassifierMode(rt.cfg.ClassifierMode)) signals := make([]detector.Signal, 0, len(refined)) + decisions := make([]pipeline.SignalDecision, 0, len(refined)) for i, ref := range refined { sig := ref.Signal signals = append(signals, sig) cls := sig.Class snipRate := ref.SnippetRate decision := pipeline.DecideSignalAction(policy, ref.Candidate, cls) + decisions = append(decisions, decision) if decision.ShouldAutoDecode && rec != nil { rt.cfg.Recorder.AutoDecode = true } @@ -265,7 +280,7 @@ func (rt *dspRuntime) refineSignals(art *spectrumArtifacts, extractMgr *extracti } } rt.det.UpdateClasses(signals) - return signals + return pipeline.RefinementResult{Signals: signals, Decisions: decisions} } func (rt *dspRuntime) updateRDS(now time.Time, rec *recorder.Manager, sig *detector.Signal, cls *classifier.Classification) { diff --git a/internal/pipeline/phases.go b/internal/pipeline/phases.go new file mode 100644 index 0000000..2cfdd16 --- /dev/null +++ b/internal/pipeline/phases.go @@ -0,0 +1,16 @@ +package pipeline + +import "sdr-wideband-suite/internal/detector" + +type SurveillanceResult struct { + Candidates []Candidate `json:"candidates"` + Finished []detector.Event `json:"finished"` + Signals []detector.Signal `json:"signals"` + NoiseFloor float64 `json:"noise_floor"` + Thresholds []float64 `json:"thresholds,omitempty"` +} + +type RefinementResult struct { + Signals []detector.Signal `json:"signals"` + Decisions []SignalDecision `json:"decisions,omitempty"` +} diff --git a/internal/pipeline/phases_test.go b/internal/pipeline/phases_test.go new file mode 100644 index 0000000..356067f --- /dev/null +++ b/internal/pipeline/phases_test.go @@ -0,0 +1,17 @@ +package pipeline + +import ( + "testing" + + "sdr-wideband-suite/internal/detector" +) + +func TestRefinementResultCarriesDecisions(t *testing.T) { + res := RefinementResult{ + Signals: []detector.Signal{{ID: 1}}, + Decisions: []SignalDecision{{ShouldRecord: true}}, + } + if len(res.Signals) != 1 || len(res.Decisions) != 1 { + t.Fatalf("unexpected refinement result: %+v", res) + } +}