| @@ -185,6 +185,14 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * | |||||
| } | } | ||||
| cls := classifier.Classify(classifier.SignalInput{FirstBin: signals[i].FirstBin, LastBin: signals[i].LastBin, SNRDb: signals[i].SNRDb, CenterHz: signals[i].CenterHz}, spectrum, cfg.SampleRate, cfg.FFTSize, snip, classifier.ClassifierMode(cfg.ClassifierMode)) | cls := classifier.Classify(classifier.SignalInput{FirstBin: signals[i].FirstBin, LastBin: signals[i].LastBin, SNRDb: signals[i].SNRDb, CenterHz: signals[i].CenterHz}, spectrum, cfg.SampleRate, cfg.FFTSize, snip, classifier.ClassifierMode(cfg.ClassifierMode)) | ||||
| signals[i].Class = cls | signals[i].Class = cls | ||||
| if cls != nil && snip != nil && len(snip) > 256 { | |||||
| pll := classifier.EstimateExactFrequency(snip, cfg.SampleRate, signals[i].CenterHz, cls.ModType) | |||||
| cls.PLL = &pll | |||||
| signals[i].PLL = &pll | |||||
| if pll.Locked { | |||||
| signals[i].CenterHz = pll.ExactHz | |||||
| } | |||||
| } | |||||
| } | } | ||||
| det.UpdateClasses(signals) | det.UpdateClasses(signals) | ||||
| } | } | ||||
| @@ -13,6 +13,10 @@ func Classify(input SignalInput, spectrum []float64, sampleRate int, fftSize int | |||||
| return nil | return nil | ||||
| } | } | ||||
| feat := ExtractFeatures(input, spectrum, sampleRate, fftSize) | feat := ExtractFeatures(input, spectrum, sampleRate, fftSize) | ||||
| if hard := TryHardRule(input.CenterHz, feat.BW3dB); hard != nil { | |||||
| hard.Features = feat | |||||
| return hard | |||||
| } | |||||
| if len(iq) > 0 { | if len(iq) > 0 { | ||||
| envVar, zc, instStd, crest := ExtractTemporalFeatures(iq) | envVar, zc, instStd, crest := ExtractTemporalFeatures(iq) | ||||
| feat.EnvVariance = envVar | feat.EnvVariance = envVar | ||||
| @@ -0,0 +1,70 @@ | |||||
| package classifier | |||||
| import ( | |||||
| _ "embed" | |||||
| "encoding/json" | |||||
| "log" | |||||
| ) | |||||
| //go:embed hard_rules.json | |||||
| var hardRulesJSON []byte | |||||
| type hardRule struct { | |||||
| Name string `json:"name"` | |||||
| Match hardMatch `json:"match"` | |||||
| Result hardResult `json:"result"` | |||||
| Note string `json:"note"` | |||||
| } | |||||
| type hardMatch struct { | |||||
| MinMHz float64 `json:"min_mhz,omitempty"` | |||||
| MaxMHz float64 `json:"max_mhz,omitempty"` | |||||
| MinBWHz float64 `json:"min_bw_hz,omitempty"` | |||||
| MaxBWHz float64 `json:"max_bw_hz,omitempty"` | |||||
| } | |||||
| type hardResult struct { | |||||
| ModType string `json:"mod_type"` | |||||
| Confidence float64 `json:"confidence"` | |||||
| } | |||||
| type hardRulesFile struct { | |||||
| Rules []hardRule `json:"rules"` | |||||
| } | |||||
| var loadedHardRules []hardRule | |||||
| func init() { | |||||
| var f hardRulesFile | |||||
| if err := json.Unmarshal(hardRulesJSON, &f); err != nil { | |||||
| log.Printf("classifier: failed to load hard rules: %v", err) | |||||
| return | |||||
| } | |||||
| loadedHardRules = f.Rules | |||||
| } | |||||
| func TryHardRule(centerHz float64, bwHz float64) *Classification { | |||||
| mhz := centerHz / 1e6 | |||||
| for _, r := range loadedHardRules { | |||||
| if r.Match.MinMHz > 0 && mhz < r.Match.MinMHz { | |||||
| continue | |||||
| } | |||||
| if r.Match.MaxMHz > 0 && mhz > r.Match.MaxMHz { | |||||
| continue | |||||
| } | |||||
| if r.Match.MinBWHz > 0 && bwHz < r.Match.MinBWHz { | |||||
| continue | |||||
| } | |||||
| if r.Match.MaxBWHz > 0 && bwHz > r.Match.MaxBWHz { | |||||
| continue | |||||
| } | |||||
| mod := SignalClass(r.Result.ModType) | |||||
| return &Classification{ | |||||
| ModType: mod, | |||||
| Confidence: r.Result.Confidence, | |||||
| BW3dB: bwHz, | |||||
| Scores: map[SignalClass]float64{mod: 10.0}, | |||||
| } | |||||
| } | |||||
| return nil | |||||
| } | |||||
| @@ -0,0 +1,52 @@ | |||||
| { | |||||
| "rules": [ | |||||
| { | |||||
| "name": "fm_broadcast", | |||||
| "match": {"min_mhz": 87.5, "max_mhz": 108.0, "min_bw_hz": 50000}, | |||||
| "result": {"mod_type": "WFM", "confidence": 0.99}, | |||||
| "note": "FM Broadcast: >50 kHz BW im UKW-Band ist immer WFM" | |||||
| }, | |||||
| { | |||||
| "name": "airband_am", | |||||
| "match": {"min_mhz": 118.0, "max_mhz": 137.0, "min_bw_hz": 4000, "max_bw_hz": 12000}, | |||||
| "result": {"mod_type": "AM", "confidence": 0.95}, | |||||
| "note": "Airband ist ausschliesslich AM" | |||||
| }, | |||||
| { | |||||
| "name": "cw_any_band", | |||||
| "match": {"min_bw_hz": 0, "max_bw_hz": 500}, | |||||
| "result": {"mod_type": "CW", "confidence": 0.90}, | |||||
| "note": "Unter 500 Hz BW ist CW, egal welches Band" | |||||
| }, | |||||
| { | |||||
| "name": "ft8_40m", | |||||
| "match": {"min_mhz": 7.072, "max_mhz": 7.076, "min_bw_hz": 1500, "max_bw_hz": 3500}, | |||||
| "result": {"mod_type": "FT8", "confidence": 0.95}, | |||||
| "note": "FT8 Dial-Frequenz 40m" | |||||
| }, | |||||
| { | |||||
| "name": "ft8_20m", | |||||
| "match": {"min_mhz": 14.072, "max_mhz": 14.076, "min_bw_hz": 1500, "max_bw_hz": 3500}, | |||||
| "result": {"mod_type": "FT8", "confidence": 0.95}, | |||||
| "note": "FT8 Dial-Frequenz 20m" | |||||
| }, | |||||
| { | |||||
| "name": "wspr_40m", | |||||
| "match": {"min_mhz": 7.0384, "max_mhz": 7.0388, "min_bw_hz": 100, "max_bw_hz": 500}, | |||||
| "result": {"mod_type": "WSPR", "confidence": 0.95}, | |||||
| "note": "WSPR Dial-Frequenz 40m" | |||||
| }, | |||||
| { | |||||
| "name": "pmr446_nfm", | |||||
| "match": {"min_mhz": 446.0, "max_mhz": 446.2, "min_bw_hz": 5000, "max_bw_hz": 15000}, | |||||
| "result": {"mod_type": "NFM", "confidence": 0.92}, | |||||
| "note": "PMR446 ist immer NFM" | |||||
| }, | |||||
| { | |||||
| "name": "wfm_wide_any", | |||||
| "match": {"min_bw_hz": 100000}, | |||||
| "result": {"mod_type": "WFM", "confidence": 0.95}, | |||||
| "note": "Über 100 kHz BW ist fast immer WFM, unabhängig vom Band" | |||||
| } | |||||
| ] | |||||
| } | |||||
| @@ -0,0 +1,58 @@ | |||||
| package classifier | |||||
| import ( | |||||
| "math" | |||||
| "testing" | |||||
| ) | |||||
| func TestHardRulesFMBroadcast(t *testing.T) { | |||||
| cls := TryHardRule(100.0e6, 120000) | |||||
| if cls == nil { | |||||
| t.Fatal("expected hard rule match for FM broadcast") | |||||
| } | |||||
| if cls.ModType != ClassWFM { | |||||
| t.Errorf("expected WFM, got %s", cls.ModType) | |||||
| } | |||||
| if cls.Confidence < 0.95 { | |||||
| t.Errorf("confidence too low: %.2f", cls.Confidence) | |||||
| } | |||||
| cls2 := TryHardRule(434.0e6, 120000) | |||||
| if cls2 == nil || cls2.ModType != ClassWFM { | |||||
| t.Errorf("expected WFM for >100kHz signal") | |||||
| } | |||||
| } | |||||
| func TestHardRulesAirband(t *testing.T) { | |||||
| cls := TryHardRule(121.5e6, 8000) | |||||
| if cls == nil || cls.ModType != ClassAM { | |||||
| t.Fatalf("expected AM for airband, got %v", cls) | |||||
| } | |||||
| } | |||||
| func TestHardRulesCW(t *testing.T) { | |||||
| cls := TryHardRule(7.020e6, 100) | |||||
| if cls == nil || cls.ModType != ClassCW { | |||||
| t.Fatalf("expected CW for <500Hz, got %v", cls) | |||||
| } | |||||
| } | |||||
| func TestWFMPilotDetection(t *testing.T) { | |||||
| sampleRate := 192000 | |||||
| n := sampleRate * 2 | |||||
| iq := make([]complex64, n) | |||||
| phase := 0.0 | |||||
| for i := range iq { | |||||
| pilot := math.Sin(2 * math.Pi * 19000 * float64(i) / float64(sampleRate)) | |||||
| modulation := pilot * 0.1 | |||||
| freqDev := modulation * 75000 / float64(sampleRate) | |||||
| phase += 2 * math.Pi * freqDev | |||||
| iq[i] = complex(float32(math.Cos(phase)), float32(math.Sin(phase))) | |||||
| } | |||||
| result := EstimateExactFrequency(iq, sampleRate, 102.1e6, ClassWFM) | |||||
| if !result.Locked { | |||||
| t.Fatal("PLL should lock on pilot") | |||||
| } | |||||
| if math.Abs(result.OffsetHz) > 5 { | |||||
| t.Errorf("offset too large: %.1f Hz", result.OffsetHz) | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,130 @@ | |||||
| package classifier | |||||
| import "math" | |||||
| type PLLResult struct { | |||||
| ExactHz float64 `json:"exact_hz"` | |||||
| OffsetHz float64 `json:"offset_hz"` | |||||
| Locked bool `json:"locked"` | |||||
| Method string `json:"method"` | |||||
| PrecisionHz float64 `json:"precision_hz"` | |||||
| } | |||||
| func EstimateExactFrequency(iq []complex64, sampleRate int, detectedHz float64, modType SignalClass) PLLResult { | |||||
| if len(iq) < 256 { | |||||
| return PLLResult{ExactHz: detectedHz} | |||||
| } | |||||
| switch modType { | |||||
| case ClassWFM: | |||||
| return estimateWFMPilot(iq, sampleRate, detectedHz) | |||||
| case ClassAM: | |||||
| return estimateAMCarrier(iq, sampleRate, detectedHz) | |||||
| case ClassNFM: | |||||
| return estimateNFMCarrier(iq, sampleRate, detectedHz) | |||||
| case ClassCW: | |||||
| return estimateCWTone(iq, sampleRate, detectedHz) | |||||
| default: | |||||
| return PLLResult{ExactHz: detectedHz, Method: "none"} | |||||
| } | |||||
| } | |||||
| func estimateWFMPilot(iq []complex64, sampleRate int, detectedHz float64) PLLResult { | |||||
| demod := fmDemod(iq) | |||||
| if len(demod) == 0 { | |||||
| return PLLResult{ExactHz: detectedHz, Method: "pilot"} | |||||
| } | |||||
| pilotFreq := 19000.0 | |||||
| bestFreq := pilotFreq | |||||
| bestMag := goertzelMagnitude(demod, pilotFreq, sampleRate) | |||||
| for offset := -50.0; offset <= 50.0; offset += 1.0 { | |||||
| mag := goertzelMagnitude(demod, pilotFreq+offset, sampleRate) | |||||
| if mag > bestMag { | |||||
| bestMag = mag | |||||
| bestFreq = pilotFreq + offset | |||||
| } | |||||
| } | |||||
| freqError := bestFreq - 19000.0 | |||||
| noiseMag := goertzelMagnitude(demod, 17500, sampleRate) | |||||
| locked := bestMag > noiseMag*5 | |||||
| if !locked { | |||||
| return PLLResult{ExactHz: detectedHz, Method: "pilot", Locked: false} | |||||
| } | |||||
| return PLLResult{ExactHz: detectedHz - freqError, OffsetHz: -freqError, Locked: true, Method: "pilot", PrecisionHz: 1.0} | |||||
| } | |||||
| func estimateAMCarrier(iq []complex64, sampleRate int, detectedHz float64) PLLResult { | |||||
| offset := meanInstFreqHz(iq, sampleRate) | |||||
| return PLLResult{ExactHz: detectedHz + offset, OffsetHz: offset, Locked: true, Method: "carrier", PrecisionHz: 5.0} | |||||
| } | |||||
| func estimateNFMCarrier(iq []complex64, sampleRate int, detectedHz float64) PLLResult { | |||||
| offset := meanInstFreqHz(iq, sampleRate) | |||||
| return PLLResult{ExactHz: detectedHz + offset, OffsetHz: offset, Locked: math.Abs(offset) < 5000, Method: "fm_dc", PrecisionHz: 20.0} | |||||
| } | |||||
| func estimateCWTone(iq []complex64, sampleRate int, detectedHz float64) PLLResult { | |||||
| demod := fmDemod(iq) | |||||
| if len(demod) == 0 { | |||||
| return PLLResult{ExactHz: detectedHz, Method: "tone"} | |||||
| } | |||||
| bestFreq := 700.0 | |||||
| bestMag := 0.0 | |||||
| for f := 300.0; f <= 1200.0; f += 1.0 { | |||||
| mag := goertzelMagnitude(demod, f, sampleRate) | |||||
| if mag > bestMag { | |||||
| bestMag = mag | |||||
| bestFreq = f | |||||
| } | |||||
| } | |||||
| bfoHz := 700.0 | |||||
| toneOffset := bestFreq - bfoHz | |||||
| return PLLResult{ExactHz: detectedHz + toneOffset, OffsetHz: toneOffset, Locked: bestMag > 0, Method: "tone", PrecisionHz: 2.0} | |||||
| } | |||||
| func fmDemod(iq []complex64) []float64 { | |||||
| if len(iq) < 2 { | |||||
| return nil | |||||
| } | |||||
| out := make([]float64, len(iq)-1) | |||||
| 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)) | |||||
| out[i-1] = math.Atan2(num, den) | |||||
| } | |||||
| return out | |||||
| } | |||||
| func goertzelMagnitude(samples []float64, targetHz float64, sampleRate int) float64 { | |||||
| n := len(samples) | |||||
| if n == 0 { | |||||
| return 0 | |||||
| } | |||||
| k := targetHz / (float64(sampleRate) / float64(n)) | |||||
| w := 2.0 * math.Pi * k / float64(n) | |||||
| coeff := 2.0 * math.Cos(w) | |||||
| s1, s2 := 0.0, 0.0 | |||||
| for _, v := range samples { | |||||
| s0 := v + coeff*s1 - s2 | |||||
| s2 = s1 | |||||
| s1 = s0 | |||||
| } | |||||
| return math.Sqrt(s1*s1 + s2*s2 - coeff*s1*s2) | |||||
| } | |||||
| func meanInstFreqHz(iq []complex64, sampleRate int) float64 { | |||||
| if len(iq) < 2 { | |||||
| return 0 | |||||
| } | |||||
| var sum 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)) | |||||
| sum += math.Atan2(num, den) | |||||
| } | |||||
| meanRad := sum / float64(len(iq)-1) | |||||
| return meanRad * float64(sampleRate) / (2.0 * math.Pi) | |||||
| } | |||||
| @@ -44,6 +44,7 @@ type Classification struct { | |||||
| BW3dB float64 `json:"bw_3db_hz"` | BW3dB float64 `json:"bw_3db_hz"` | ||||
| Features Features `json:"features,omitempty"` | Features Features `json:"features,omitempty"` | ||||
| MathFeatures *MathFeatures `json:"math_features,omitempty"` | MathFeatures *MathFeatures `json:"math_features,omitempty"` | ||||
| PLL *PLLResult `json:"pll,omitempty"` | |||||
| SecondBest SignalClass `json:"second_best,omitempty"` | SecondBest SignalClass `json:"second_best,omitempty"` | ||||
| Scores map[SignalClass]float64 `json:"scores,omitempty"` | Scores map[SignalClass]float64 `json:"scores,omitempty"` | ||||
| } | } | ||||
| @@ -74,6 +74,7 @@ type Signal struct { | |||||
| SNRDb float64 `json:"snr_db"` | SNRDb float64 `json:"snr_db"` | ||||
| NoiseDb float64 `json:"noise_db,omitempty"` | NoiseDb float64 `json:"noise_db,omitempty"` | ||||
| Class *classifier.Classification `json:"class,omitempty"` | Class *classifier.Classification `json:"class,omitempty"` | ||||
| PLL *classifier.PLLResult `json:"pll,omitempty"` | |||||
| } | } | ||||
| func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { | func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { | ||||