diff --git a/cmd/sdrd/main.go b/cmd/sdrd/main.go index cfeb18f..c301f2a 100644 --- a/cmd/sdrd/main.go +++ b/cmd/sdrd/main.go @@ -17,6 +17,7 @@ import ( "github.com/gorilla/websocket" + "sdr-visual-suite/internal/classifier" "sdr-visual-suite/internal/config" "sdr-visual-suite/internal/detector" "sdr-visual-suite/internal/dsp" @@ -704,6 +705,13 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * } now := time.Now() finished, signals := det.Process(now, spectrum, cfg.CenterHz) + // enrich classification with temporal IQ features + if len(iq) > 0 { + for i := range signals { + cls := classifier.Classify(classifier.SignalInput{FirstBin: signals[i].FirstBin, LastBin: signals[i].LastBin, SNRDb: signals[i].SNRDb}, spectrum, cfg.SampleRate, cfg.FFTSize, iq) + signals[i].Class = cls + } + } eventMu.Lock() for _, ev := range finished { _ = enc.Encode(ev) diff --git a/internal/classifier/classifier.go b/internal/classifier/classifier.go index 9bb54b0..1c32fc9 100644 --- a/internal/classifier/classifier.go +++ b/internal/classifier/classifier.go @@ -1,11 +1,18 @@ package classifier // Classify builds features and applies the rule-based classifier. -func Classify(input SignalInput, spectrum []float64, sampleRate int, fftSize int) *Classification { +func Classify(input SignalInput, spectrum []float64, sampleRate int, fftSize int, iq []complex64) *Classification { if len(spectrum) == 0 || input.FirstBin < 0 || input.LastBin < 0 { return nil } feat := ExtractFeatures(input, spectrum, sampleRate, fftSize) + if len(iq) > 0 { + envVar, zc, instStd, crest := ExtractTemporalFeatures(iq) + feat.EnvVariance = envVar + feat.ZeroCross = zc + feat.InstFreqStd = instStd + feat.CrestFactor = crest + } cls := RuleClassify(feat) return &cls } diff --git a/internal/classifier/classifier_test.go b/internal/classifier/classifier_test.go index dc9625b..f4439b1 100644 --- a/internal/classifier/classifier_test.go +++ b/internal/classifier/classifier_test.go @@ -14,7 +14,7 @@ func TestRuleClassifyWFM(t *testing.T) { for i := start; i <= end; i++ { spectrum[i] = -10 } - cls := Classify(SignalInput{FirstBin: start, LastBin: end}, spectrum, sampleRate, fftSize) + cls := Classify(SignalInput{FirstBin: start, LastBin: end}, spectrum, sampleRate, fftSize, nil) if cls == nil || cls.ModType != ClassWFM { t.Fatalf("expected WFM, got %+v", cls) } diff --git a/internal/classifier/features_iq.go b/internal/classifier/features_iq.go new file mode 100644 index 0000000..274bf4e --- /dev/null +++ b/internal/classifier/features_iq.go @@ -0,0 +1,69 @@ +package classifier + +import ( + "math" +) + +// ExtractTemporalFeatures computes simple time-domain features from IQ. +func ExtractTemporalFeatures(iq []complex64) (envVar float64, zeroCross float64, instFreqStd float64, crest float64) { + if len(iq) == 0 { + return 0, 0, 0, 0 + } + env := make([]float64, len(iq)) + var mean, rms float64 + for i, v := range iq { + a := math.Hypot(float64(real(v)), float64(imag(v))) + env[i] = a + mean += a + rms += a * a + } + mean /= float64(len(iq)) + rms = math.Sqrt(rms / float64(len(iq))) + // env variance + var sumVar float64 + for _, v := range env { + d := v - mean + sumVar += d * d + } + envVar = sumVar / float64(len(iq)) + if rms > 0 { + crest = maxFloat(env) / rms + } + // zero-crossing on real part + zc := 0 + for i := 1; i < len(iq); i++ { + p := real(iq[i-1]) + c := real(iq[i]) + if (p >= 0 && c < 0) || (p < 0 && c >= 0) { + zc++ + } + } + zeroCross = float64(zc) / float64(len(iq)) + // instantaneous frequency std + if len(iq) > 1 { + var sum, sumSq float64 + for i := 1; i < len(iq); i++ { + p := iq[i-1] + c := iq[i] + num := float64(real(p))*float64(imag(c)) - float64(imag(p))*float64(real(c)) + den := float64(real(p))*float64(real(c)) + float64(imag(p))*float64(imag(c)) + v := math.Atan2(num, den) + sum += v + sumSq += v * v + } + n := float64(len(iq) - 1) + mean := sum / n + instFreqStd = math.Sqrt(sumSq/n - mean*mean) + } + return +} + +func maxFloat(vals []float64) float64 { + m := vals[0] + for _, v := range vals { + if v > m { + m = v + } + } + return m +} diff --git a/internal/classifier/types.go b/internal/classifier/types.go index eccc905..0cf6abc 100644 --- a/internal/classifier/types.go +++ b/internal/classifier/types.go @@ -24,6 +24,11 @@ type Features struct { Symmetry float64 `json:"symmetry"` RolloffLeft float64 `json:"rolloff_left_db_khz"` RolloffRight float64 `json:"rolloff_right_db_khz"` + // Temporal + EnvVariance float64 `json:"env_variance"` + ZeroCross float64 `json:"zero_cross_rate"` + InstFreqStd float64 `json:"inst_freq_std"` + CrestFactor float64 `json:"crest_factor"` } // Classification is the classifier output attached to signals/events. diff --git a/internal/detector/detector.go b/internal/detector/detector.go index e305bb7..5fa8cee 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -122,7 +122,7 @@ func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise centerFreq := centerHz + (centerBin-float64(d.nbins)/2.0)*d.binWidth bw := float64(last-first+1) * d.binWidth snr := peak - noise - cls := classifier.Classify(classifier.SignalInput{FirstBin: first, LastBin: last, SNRDb: snr}, spectrum, d.sampleRate, d.nbins) + cls := classifier.Classify(classifier.SignalInput{FirstBin: first, LastBin: last, SNRDb: snr}, spectrum, d.sampleRate, d.nbins, nil) return Signal{ FirstBin: first, LastBin: last,