package classifier import "math" func RuleClassify(feat Features, centerHz float64, snrDb float64) Classification { bw := feat.BW3dB flat := feat.SpectralFlat sym := feat.Symmetry p2a := feat.PeakToAvg scores := map[SignalClass]float64{} add := func(c SignalClass, w float64) { if w == 0 { return } scores[c] += w if scores[c] < 0 { scores[c] = 0 } } switch { case bw >= 80e3: add(ClassWFM, 2.2) case bw >= 25e3 && bw < 80e3: add(ClassWFM, 1.4) add(ClassNFM, 0.8) case bw >= 6e3 && bw < 25e3: add(ClassNFM, 1.2) case bw >= 3e3 && bw < 6e3: add(ClassSSBUSB, 0.6) add(ClassSSBLSB, 0.6) if p2a > 2.5 && flat < 0.5 { add(ClassAM, 1.1) } case bw >= 500 && bw < 3e3: add(ClassSSBUSB, 0.8) add(ClassSSBLSB, 0.8) if p2a > 3 && flat < 0.4 { add(ClassAM, 1.0) } case bw >= 150 && bw < 500: add(ClassFSK, 0.5) add(ClassPSK, 0.5) case bw < 150: add(ClassCW, 1.6) } if sym > 0.2 { add(ClassSSBUSB, 1.2) } else if sym < -0.2 { add(ClassSSBLSB, 1.2) } rollAvg := (feat.RolloffLeft + feat.RolloffRight) / 2.0 rollAsym := math.Abs(feat.RolloffLeft - feat.RolloffRight) if rollAvg > 15 && rollAsym < 5 { if bw >= 6000 { add(ClassNFM, 0.4) } if bw >= 80000 { add(ClassWFM, 0.4) } if bw >= 3000 && bw <= 10000 { add(ClassAM, 0.3) } } if rollAsym > 10 && bw >= 2000 && bw <= 4000 { if feat.RolloffLeft > feat.RolloffRight { add(ClassSSBLSB, 0.6) } else { add(ClassSSBUSB, 0.6) } } if feat.EnvVariance < 0.08 && bw >= 10000 && bw <= 14000 && flat > 0.55 { add(ClassDMR, 1.5) } if feat.EnvVariance < 0.08 && bw >= 5000 && bw <= 8000 && flat > 0.55 { add(ClassDStar, 1.3) } if feat.EnvVariance < 0.03 && bw >= 5000 && bw <= 16000 { add(ClassNFM, -0.5) } if feat.EnvVariance < 0.08 && feat.InstFreqStd < 0.7 && bw >= 2000 && bw < 3000 { add(ClassFT8, 1.4) } if feat.EnvVariance < 0.05 && feat.InstFreqStd < 0.5 && bw >= 150 && bw < 500 { add(ClassWSPR, 1.3) } if feat.InstFreqStd > 0.9 { add(ClassFSK, 1.2) } else if feat.InstFreqStd < 0.25 { add(ClassPSK, 1.0) } if p2a > 2.5 && flat < 0.5 { add(ClassAM, 0.8) } if flat > 0.85 && bw > 2e3 { add(ClassNoise, 1.0) } if feat.EnvVariance < 0.08 && feat.InstFreqStd < 0.5 && bw >= 6e3 && bw < 25e3 { add(ClassDMR, 0.7) } addFrequencyContext(add, centerHz, bw) best, _, second, _ := top2(scores) if best == "" { best = ClassUnknown } if second == "" { second = ClassUnknown } conf := softmaxConfidence(scores, best) if best == ClassNFM || best == ClassWFM { conf *= 0.8 + 0.2*clamp01(1-flat) } if best == ClassAM { conf *= 0.7 + 0.3*clamp01(p2a/6.0) } if snrDb < 20 { snrFactor := clamp01((snrDb - 3) / 17.0) conf *= 0.3 + 0.7*snrFactor } if math.IsNaN(conf) || conf <= 0 { conf = 0.1 } if (best == ClassSSBUSB || best == ClassSSBLSB) && second == ClassUnknown { if best == ClassSSBUSB { second = ClassSSBLSB } else { second = ClassSSBUSB } } return Classification{ ModType: best, Confidence: conf, BW3dB: bw, Features: feat, SecondBest: second, Scores: scores, } } func softmaxConfidence(scores map[SignalClass]float64, best SignalClass) float64 { if len(scores) == 0 || best == "" || best == ClassUnknown { return 0.1 } maxScore := math.Inf(-1) for _, v := range scores { if v > maxScore { maxScore = v } } if math.IsInf(maxScore, -1) { return 0.1 } var expSum float64 var expBest float64 for k, v := range scores { e := math.Exp(v - maxScore) expSum += e if k == best { expBest = e } } if expSum <= 0 { return 0.1 } return expBest / expSum } func top2(scores map[SignalClass]float64) (SignalClass, float64, SignalClass, float64) { var best, second SignalClass bestScore := 0.0 secondScore := 0.0 better := func(k SignalClass, v float64, cur SignalClass, curV float64) bool { if v > curV { return true } if v < curV { return false } if cur == "" { return true } return string(k) < string(cur) } for k, v := range scores { if better(k, v, best, bestScore) { second = best secondScore = bestScore best = k bestScore = v continue } if k != best && better(k, v, second, secondScore) { second = k secondScore = v } } return best, bestScore, second, secondScore }