Parcourir la source

Add classifier scaffold and attach to signals/events

master
Jan Svabenik il y a 3 jours
Parent
révision
b96434484a
6 fichiers modifiés avec 363 ajouts et 31 suppressions
  1. +11
    -0
      internal/classifier/classifier.go
  2. +21
    -0
      internal/classifier/classifier_test.go
  3. +167
    -0
      internal/classifier/features.go
  4. +74
    -0
      internal/classifier/rules.go
  5. +43
    -0
      internal/classifier/types.go
  6. +47
    -31
      internal/detector/detector.go

+ 11
- 0
internal/classifier/classifier.go Voir le fichier

@@ -0,0 +1,11 @@
package classifier

// Classify builds features and applies the rule-based classifier.
func Classify(input SignalInput, spectrum []float64, sampleRate int, fftSize int) *Classification {
if len(spectrum) == 0 || input.FirstBin < 0 || input.LastBin < 0 {
return nil
}
feat := ExtractFeatures(input, spectrum, sampleRate, fftSize)
cls := RuleClassify(feat)
return &cls
}

+ 21
- 0
internal/classifier/classifier_test.go Voir le fichier

@@ -0,0 +1,21 @@
package classifier

import "testing"

func TestRuleClassifyWFM(t *testing.T) {
sampleRate := 1_000_000
fftSize := 1024
spectrum := make([]float64, fftSize)
for i := range spectrum {
spectrum[i] = -100
}
start := 100
end := 350 // ~244 bins -> ~238 kHz
for i := start; i <= end; i++ {
spectrum[i] = -10
}
cls := Classify(SignalInput{FirstBin: start, LastBin: end}, spectrum, sampleRate, fftSize)
if cls == nil || cls.ModType != ClassWFM {
t.Fatalf("expected WFM, got %+v", cls)
}
}

+ 167
- 0
internal/classifier/features.go Voir le fichier

@@ -0,0 +1,167 @@
package classifier

import (
"math"
)

// ExtractFeatures computes spectral features for a signal slice.
// spectrum is full-band power in dB (length fftSize).
func ExtractFeatures(s SignalInput, spectrum []float64, sampleRate int, fftSize int) Features {
if fftSize <= 0 {
fftSize = len(spectrum)
}
if len(spectrum) == 0 || s.FirstBin < 0 || s.LastBin < s.FirstBin || s.FirstBin >= len(spectrum) {
return Features{}
}
if s.LastBin >= len(spectrum) {
s.LastBin = len(spectrum) - 1
}
binHz := float64(sampleRate) / float64(max(1, fftSize))
// slice
start := s.FirstBin
end := s.LastBin
peakDb := -1e9
peakIdx := start
sumLin := 0.0
geoSum := 0.0
count := 0
for i := start; i <= end; i++ {
db := spectrum[i]
if db > peakDb {
peakDb = db
peakIdx = i
}
p := math.Pow(10, db/10.0)
sumLin += p
if p > 0 {
geoSum += math.Log(p)
}
count++
}
avgLin := 0.0
if count > 0 {
avgLin = sumLin / float64(count)
}
// Peak-to-avg in dB
peakToAvg := 0.0
if avgLin > 0 {
peakToAvg = 10 * math.Log10(math.Pow(10, peakDb/10.0)/avgLin)
}
// Spectral flatness
flat := 0.0
if count > 0 && avgLin > 0 {
geoMean := math.Exp(geoSum / float64(count))
flat = geoMean / avgLin
}
// BW3dB
bw3 := bwAtThreshold(spectrum, start, end, peakDb-3.0) * binHz
// BW90 (90% energy)
bw90 := bwEnergy(spectrum, start, end, 0.90) * binHz
// Symmetry (power left/right of peak)
leftSum, rightSum := 0.0, 0.0
for i := start; i <= end; i++ {
p := math.Pow(10, spectrum[i]/10.0)
if i <= peakIdx {
leftSum += p
} else {
rightSum += p
}
}
sym := 0.0
if leftSum+rightSum > 0 {
sym = (rightSum - leftSum) / (rightSum + leftSum)
}
// Rolloff (dB/kHz) at edges
leftDb := spectrum[start]
rightDb := spectrum[end]
leftHz := math.Max(binHz, float64(peakIdx-start)*binHz)
rightHz := math.Max(binHz, float64(end-peakIdx)*binHz)
rollL := (peakDb - leftDb) / (leftHz / 1e3)
rollR := (peakDb - rightDb) / (rightHz / 1e3)

return Features{
BW3dB: bw3,
BW90: bw90,
SpectralFlat: clamp01(flat),
PeakToAvg: peakToAvg,
Symmetry: sym,
RolloffLeft: rollL,
RolloffRight: rollR,
}
}

func bwAtThreshold(spectrum []float64, start, end int, threshDb float64) float64 {
left := start
right := end
for i := start; i <= end; i++ {
if spectrum[i] >= threshDb {
left = i
break
}
}
for i := end; i >= start; i-- {
if spectrum[i] >= threshDb {
right = i
break
}
}
if right < left {
return float64(end - start + 1)
}
return float64(right - left + 1)
}

func bwEnergy(spectrum []float64, start, end int, frac float64) float64 {
if frac <= 0 {
return 0
}
if frac > 1 {
frac = 1
}
powers := make([]float64, 0, end-start+1)
sum := 0.0
for i := start; i <= end; i++ {
p := math.Pow(10, spectrum[i]/10.0)
sum += p
powers = append(powers, p)
}
if sum == 0 {
return float64(end - start + 1)
}
// accumulate from center outward
center := (start + end) / 2
l := center
r := center
acc := powers[center-start]
for acc/sum < frac && (l > start || r < end) {
if l > start {
l--
acc += powers[l-start]
}
if acc/sum >= frac {
break
}
if r < end {
r++
acc += powers[r-start]
}
}
return float64(r - l + 1)
}

func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}

func max(a, b int) int {
if a > b {
return a
}
return b
}

+ 74
- 0
internal/classifier/rules.go Voir le fichier

@@ -0,0 +1,74 @@
package classifier

import "math"

func RuleClassify(feat Features) Classification {
bw := feat.BW3dB
flat := feat.SpectralFlat
sym := feat.Symmetry
p2a := feat.PeakToAvg

best := ClassUnknown
second := ClassUnknown
conf := 0.3

switch {
case bw > 100e3:
best = ClassWFM
conf = 0.9
case bw >= 6e3 && bw <= 16e3:
best = ClassNFM
conf = 0.8
if flat > 0.7 {
second = ClassNoise
}
case bw >= 500 && bw < 3e3:
if sym > 0.2 {
best = ClassSSBUSB
conf = 0.7
} else if sym < -0.2 {
best = ClassSSBLSB
conf = 0.7
} else if p2a > 3 && flat < 0.4 {
best = ClassAM
conf = 0.6
}
case bw < 500:
best = ClassCW
conf = 0.7
}

// noise hint
if best == ClassUnknown && flat > 0.85 && bw > 2e3 {
best = ClassNoise
conf = 0.6
}

// edge-case: if symmetry is strong, second best opposite side
if (best == ClassSSBUSB || best == ClassSSBLSB) && second == ClassUnknown {
if best == ClassSSBUSB {
second = ClassSSBLSB
} else {
second = ClassSSBUSB
}
}

// slightly scale confidence by feature strength
if best == ClassNFM || best == ClassWFM {
conf = conf * (0.8 + 0.2*clamp01(1-flat))
}
if best == ClassAM {
conf = conf * (0.7 + 0.3*clamp01(p2a/6.0))
}
if math.IsNaN(conf) || conf <= 0 {
conf = 0.3
}

return Classification{
ModType: best,
Confidence: conf,
BW3dB: bw,
Features: feat,
SecondBest: second,
}
}

+ 43
- 0
internal/classifier/types.go Voir le fichier

@@ -0,0 +1,43 @@
package classifier

// SignalClass is a coarse modulation label.
type SignalClass string

const (
ClassAM SignalClass = "AM"
ClassNFM SignalClass = "NFM"
ClassWFM SignalClass = "WFM"
ClassSSBUSB SignalClass = "USB"
ClassSSBLSB SignalClass = "LSB"
ClassCW SignalClass = "CW"
ClassNoise SignalClass = "NOISE"
ClassUnknown SignalClass = "UNKNOWN"
)

// Features are lightweight spectral features derived from a signal slice.
type Features struct {
// Spectral
BW3dB float64 `json:"bw_3db_hz"`
BW90 float64 `json:"bw_90_hz"`
SpectralFlat float64 `json:"spectral_flat"`
PeakToAvg float64 `json:"peak_to_avg_db"`
Symmetry float64 `json:"symmetry"`
RolloffLeft float64 `json:"rolloff_left_db_khz"`
RolloffRight float64 `json:"rolloff_right_db_khz"`
}

// Classification is the classifier output attached to signals/events.
type Classification struct {
ModType SignalClass `json:"mod_type"`
Confidence float64 `json:"confidence"`
BW3dB float64 `json:"bw_3db_hz"`
Features Features `json:"features,omitempty"`
SecondBest SignalClass `json:"second_best,omitempty"`
}

// SignalInput is the minimal input needed for classification.
type SignalInput struct {
FirstBin int
LastBin int
SNRDb float64
}

+ 47
- 31
internal/detector/detector.go Voir le fichier

@@ -4,18 +4,21 @@ import (
"math"
"sort"
"time"

"sdr-visual-suite/internal/classifier"
)

type Event struct {
ID int64 `json:"id"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
CenterHz float64 `json:"center_hz"`
Bandwidth float64 `json:"bandwidth_hz"`
PeakDb float64 `json:"peak_db"`
SNRDb float64 `json:"snr_db"`
FirstBin int `json:"first_bin"`
LastBin int `json:"last_bin"`
ID int64 `json:"id"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
CenterHz float64 `json:"center_hz"`
Bandwidth float64 `json:"bandwidth_hz"`
PeakDb float64 `json:"peak_db"`
SNRDb float64 `json:"snr_db"`
FirstBin int `json:"first_bin"`
LastBin int `json:"last_bin"`
Class *classifier.Classification `json:"class,omitempty"`
}

type Detector struct {
@@ -23,32 +26,35 @@ type Detector struct {
MinDuration time.Duration
Hold time.Duration

binWidth float64
nbins int
binWidth float64
nbins int
sampleRate int

active map[int64]*activeEvent
nextID int64
active map[int64]*activeEvent
nextID int64
}

type activeEvent struct {
id int64
start time.Time
lastSeen time.Time
centerHz float64
bwHz float64
peakDb float64
snrDb float64
firstBin int
lastBin int
id int64
start time.Time
lastSeen time.Time
centerHz float64
bwHz float64
peakDb float64
snrDb float64
firstBin int
lastBin int
class *classifier.Classification
}

type Signal struct {
FirstBin int `json:"first_bin"`
LastBin int `json:"last_bin"`
CenterHz float64 `json:"center_hz"`
BWHz float64 `json:"bw_hz"`
PeakDb float64 `json:"peak_db"`
SNRDb float64 `json:"snr_db"`
FirstBin int `json:"first_bin"`
LastBin int `json:"last_bin"`
CenterHz float64 `json:"center_hz"`
BWHz float64 `json:"bw_hz"`
PeakDb float64 `json:"peak_db"`
SNRDb float64 `json:"snr_db"`
Class *classifier.Classification `json:"class,omitempty"`
}

func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Duration) *Detector {
@@ -64,6 +70,7 @@ func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Dur
Hold: hold,
binWidth: float64(sampleRate) / float64(fftSize),
nbins: fftSize,
sampleRate: sampleRate,
active: map[int64]*activeEvent{},
nextID: 1,
}
@@ -100,21 +107,22 @@ func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal
peakBin = i
}
} else if in {
signals = append(signals, d.makeSignal(start, i-1, peak, peakBin, noise, centerHz))
signals = append(signals, d.makeSignal(start, i-1, peak, peakBin, noise, centerHz, spectrum))
in = false
}
}
if in {
signals = append(signals, d.makeSignal(start, n-1, peak, peakBin, noise, centerHz))
signals = append(signals, d.makeSignal(start, n-1, peak, peakBin, noise, centerHz, spectrum))
}
return signals
}

func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise float64, centerHz float64) Signal {
func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise float64, centerHz float64, spectrum []float64) Signal {
centerBin := float64(first+last) / 2.0
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)
return Signal{
FirstBin: first,
LastBin: last,
@@ -122,6 +130,7 @@ func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise
BWHz: bw,
PeakDb: peak,
SNRDb: snr,
Class: cls,
}
}

@@ -158,6 +167,7 @@ func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event {
snrDb: s.SNRDb,
firstBin: s.FirstBin,
lastBin: s.LastBin,
class: s.Class,
}
continue
}
@@ -179,6 +189,11 @@ func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event {
if s.LastBin > best.lastBin {
best.lastBin = s.LastBin
}
if s.Class != nil {
if best.class == nil || s.Class.Confidence >= best.class.Confidence {
best.class = s.Class
}
}
}

var finished []Event
@@ -204,6 +219,7 @@ func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event {
SNRDb: ev.snrDb,
FirstBin: ev.firstBin,
LastBin: ev.lastBin,
Class: ev.class,
})
delete(d.active, id)
}


Chargement…
Annuler
Enregistrer