Procházet zdrojové kódy

feat: add hard classification rules and PLL capture

master
Jan Svabenik před 2 dny
rodič
revize
9742bfcb99
9 změnil soubory, kde provedl 326 přidání a 1199 odebrání
  1. +8
    -0
      cmd/sdrd/dsp_loop.go
  2. +4
    -0
      internal/classifier/classifier.go
  3. +70
    -0
      internal/classifier/hard_rules.go
  4. +52
    -0
      internal/classifier/hard_rules.json
  5. +58
    -0
      internal/classifier/hard_rules_test.go
  6. +130
    -0
      internal/classifier/pll.go
  7. +1
    -0
      internal/classifier/types.go
  8. +1
    -0
      internal/detector/detector.go
  9. +2
    -1199
      web/app.js

+ 8
- 0
cmd/sdrd/dsp_loop.go Zobrazit soubor

@@ -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))
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)
}


+ 4
- 0
internal/classifier/classifier.go Zobrazit soubor

@@ -13,6 +13,10 @@ func Classify(input SignalInput, spectrum []float64, sampleRate int, fftSize int
return nil
}
feat := ExtractFeatures(input, spectrum, sampleRate, fftSize)
if hard := TryHardRule(input.CenterHz, feat.BW3dB); hard != nil {
hard.Features = feat
return hard
}
if len(iq) > 0 {
envVar, zc, instStd, crest := ExtractTemporalFeatures(iq)
feat.EnvVariance = envVar


+ 70
- 0
internal/classifier/hard_rules.go Zobrazit soubor

@@ -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
}

+ 52
- 0
internal/classifier/hard_rules.json Zobrazit soubor

@@ -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"
}
]
}

+ 58
- 0
internal/classifier/hard_rules_test.go Zobrazit soubor

@@ -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)
}
}

+ 130
- 0
internal/classifier/pll.go Zobrazit soubor

@@ -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)
}

+ 1
- 0
internal/classifier/types.go Zobrazit soubor

@@ -44,6 +44,7 @@ type Classification struct {
BW3dB float64 `json:"bw_3db_hz"`
Features Features `json:"features,omitempty"`
MathFeatures *MathFeatures `json:"math_features,omitempty"`
PLL *PLLResult `json:"pll,omitempty"`
SecondBest SignalClass `json:"second_best,omitempty"`
Scores map[SignalClass]float64 `json:"scores,omitempty"`
}


+ 1
- 0
internal/detector/detector.go Zobrazit soubor

@@ -74,6 +74,7 @@ type Signal struct {
SNRDb float64 `json:"snr_db"`
NoiseDb float64 `json:"noise_db,omitempty"`
Class *classifier.Classification `json:"class,omitempty"`
PLL *classifier.PLLResult `json:"pll,omitempty"`
}

func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector {


+ 2
- 1199
web/app.js
Diff nebyl zobrazen, protože je příliš veliký
Zobrazit soubor


Načítá se…
Zrušit
Uložit