Преглед на файлове

Add OS-CFAR detector support

master
Jan Svabenik преди 3 дни
родител
ревизия
434ad279de
променени са 10 файла, в които са добавени 217 реда и са изтрити 10 реда
  1. +3
    -1
      README.md
  2. +17
    -2
      cmd/sdrd/main.go
  3. +5
    -0
      config.yaml
  4. +22
    -1
      internal/config/config.go
  5. +78
    -4
      internal/detector/detector.go
  6. +1
    -1
      internal/detector/detector_test.go
  7. +32
    -0
      internal/runtime/runtime.go
  8. +26
    -1
      internal/runtime/runtime_test.go
  9. +28
    -0
      web/app.js
  10. +5
    -0
      web/index.html

+ 3
- 1
README.md Целия файл

@@ -64,7 +64,9 @@ Edit `config.yaml`:
- `agc`: enable automatic gain control
- `dc_block`: enable DC blocking filter
- `iq_balance`: enable basic IQ imbalance correction
- `detector.threshold_db`: power threshold in dB
- `detector.threshold_db`: power threshold in dB (fallback if CFAR disabled)
- `detector.cfar_enabled`: enable OS-CFAR detection
- `detector.cfar_guard_cells`, `detector.cfar_train_cells`, `detector.cfar_rank`, `detector.cfar_scale_db`: OS-CFAR window + ordered-statistic parameters
- `detector.min_duration_ms`, `detector.hold_ms`: debounce/merge
- `recorder.*`: enable IQ/audio recording, preroll, output_dir, max_disk_mb
- `decoder.*`: external decode commands (use `{iq}`, `{audio}`, `{sr}` placeholders)


+ 17
- 2
cmd/sdrd/main.go Целия файл

@@ -319,7 +319,12 @@ func main() {
cfg.Detector.EmaAlpha,
cfg.Detector.HysteresisDb,
cfg.Detector.MinStableFrames,
time.Duration(cfg.Detector.GapToleranceMs)*time.Millisecond)
time.Duration(cfg.Detector.GapToleranceMs)*time.Millisecond,
cfg.Detector.CFAREnabled,
cfg.Detector.CFARGuardCells,
cfg.Detector.CFARTrainCells,
cfg.Detector.CFARRank,
cfg.Detector.CFARScaleDb)

window := fftutil.Hann(cfg.FFTSize)
h := newHub()
@@ -439,6 +444,11 @@ func main() {
prev.Detector.HysteresisDb != next.Detector.HysteresisDb ||
prev.Detector.MinStableFrames != next.Detector.MinStableFrames ||
prev.Detector.GapToleranceMs != next.Detector.GapToleranceMs ||
prev.Detector.CFAREnabled != next.Detector.CFAREnabled ||
prev.Detector.CFARGuardCells != next.Detector.CFARGuardCells ||
prev.Detector.CFARTrainCells != next.Detector.CFARTrainCells ||
prev.Detector.CFARRank != next.Detector.CFARRank ||
prev.Detector.CFARScaleDb != next.Detector.CFARScaleDb ||
prev.SampleRate != next.SampleRate ||
prev.FFTSize != next.FFTSize
windowChanged := prev.FFTSize != next.FFTSize
@@ -451,7 +461,12 @@ func main() {
next.Detector.EmaAlpha,
next.Detector.HysteresisDb,
next.Detector.MinStableFrames,
time.Duration(next.Detector.GapToleranceMs)*time.Millisecond)
time.Duration(next.Detector.GapToleranceMs)*time.Millisecond,
next.Detector.CFAREnabled,
next.Detector.CFARGuardCells,
next.Detector.CFARTrainCells,
next.Detector.CFARRank,
next.Detector.CFARScaleDb)
}
if windowChanged {
newWindow = fftutil.Hann(next.FFTSize)


+ 5
- 0
config.yaml Целия файл

@@ -19,6 +19,11 @@ detector:
hysteresis_db: 3
min_stable_frames: 3
gap_tolerance_ms: 500
cfar_enabled: true
cfar_guard_cells: 2
cfar_train_cells: 16
cfar_rank: 24
cfar_scale_db: 6
recorder:
enabled: true
min_snr_db: 10


+ 22
- 1
internal/config/config.go Целия файл

@@ -1,6 +1,7 @@
package config

import (
"math"
"os"
"time"

@@ -21,6 +22,11 @@ type DetectorConfig struct {
HysteresisDb float64 `yaml:"hysteresis_db" json:"hysteresis_db"`
MinStableFrames int `yaml:"min_stable_frames" json:"min_stable_frames"`
GapToleranceMs int `yaml:"gap_tolerance_ms" json:"gap_tolerance_ms"`
CFAREnabled bool `yaml:"cfar_enabled" json:"cfar_enabled"`
CFARGuardCells int `yaml:"cfar_guard_cells" json:"cfar_guard_cells"`
CFARTrainCells int `yaml:"cfar_train_cells" json:"cfar_train_cells"`
CFARRank int `yaml:"cfar_rank" json:"cfar_rank"`
CFARScaleDb float64 `yaml:"cfar_scale_db" json:"cfar_scale_db"`
}

type RecorderConfig struct {
@@ -83,7 +89,7 @@ func Default() Config {
AGC: false,
DCBlock: false,
IQBalance: false,
Detector: DetectorConfig{ThresholdDb: -20, MinDurationMs: 250, HoldMs: 500, EmaAlpha: 0.2, HysteresisDb: 3, MinStableFrames: 3, GapToleranceMs: 500},
Detector: DetectorConfig{ThresholdDb: -20, MinDurationMs: 250, HoldMs: 500, EmaAlpha: 0.2, HysteresisDb: 3, MinStableFrames: 3, GapToleranceMs: 500, CFAREnabled: true, CFARGuardCells: 2, CFARTrainCells: 16, CFARRank: 24, CFARScaleDb: 6},
Recorder: RecorderConfig{
Enabled: false,
MinSNRDb: 10,
@@ -128,6 +134,21 @@ func Load(path string) (Config, error) {
if cfg.Detector.GapToleranceMs <= 0 {
cfg.Detector.GapToleranceMs = cfg.Detector.HoldMs
}
if cfg.Detector.CFARGuardCells <= 0 {
cfg.Detector.CFARGuardCells = 2
}
if cfg.Detector.CFARTrainCells <= 0 {
cfg.Detector.CFARTrainCells = 16
}
if cfg.Detector.CFARRank <= 0 || cfg.Detector.CFARRank > 2*cfg.Detector.CFARTrainCells {
cfg.Detector.CFARRank = int(math.Round(0.75 * float64(2*cfg.Detector.CFARTrainCells)))
if cfg.Detector.CFARRank <= 0 {
cfg.Detector.CFARRank = 1
}
}
if cfg.Detector.CFARScaleDb <= 0 {
cfg.Detector.CFARScaleDb = 6
}
if cfg.FrameRate <= 0 {
cfg.FrameRate = 15
}


+ 78
- 4
internal/detector/detector.go Целия файл

@@ -29,6 +29,11 @@ type Detector struct {
HysteresisDb float64
MinStableFrames int
GapTolerance time.Duration
CFAREnabled bool
CFARGuardCells int
CFARTrainCells int
CFARRank int
CFARScaleDb float64

binWidth float64
nbins int
@@ -63,7 +68,7 @@ type Signal struct {
Class *classifier.Classification `json:"class,omitempty"`
}

func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Duration, emaAlpha, hysteresis float64, minStable int, gapTolerance time.Duration) *Detector {
func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Duration, emaAlpha, hysteresis float64, minStable int, gapTolerance time.Duration, cfarEnabled bool, cfarGuard, cfarTrain, cfarRank int, cfarScaleDb float64) *Detector {
if minDur <= 0 {
minDur = 250 * time.Millisecond
}
@@ -82,6 +87,21 @@ func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Dur
if gapTolerance <= 0 {
gapTolerance = hold
}
if cfarGuard < 0 {
cfarGuard = 2
}
if cfarTrain <= 0 {
cfarTrain = 16
}
if cfarScaleDb <= 0 {
cfarScaleDb = 6
}
if cfarRank <= 0 || cfarRank > 2*cfarTrain {
cfarRank = int(math.Round(0.75 * float64(2*cfarTrain)))
if cfarRank <= 0 {
cfarRank = 1
}
}
return &Detector{
ThresholdDb: thresholdDb,
MinDuration: minDur,
@@ -90,6 +110,11 @@ func New(thresholdDb float64, sampleRate int, fftSize int, minDur, hold time.Dur
HysteresisDb: hysteresis,
MinStableFrames: minStable,
GapTolerance: gapTolerance,
CFAREnabled: cfarEnabled,
CFARGuardCells: cfarGuard,
CFARTrainCells: cfarTrain,
CFARRank: cfarRank,
CFARScaleDb: cfarScaleDb,
binWidth: float64(sampleRate) / float64(fftSize),
nbins: fftSize,
sampleRate: sampleRate,
@@ -126,9 +151,8 @@ func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal
return nil
}
smooth := d.smoothSpectrum(spectrum)
thresholdOn := d.ThresholdDb
thresholdOff := d.ThresholdDb - d.HysteresisDb
noise := median(smooth)
thresholds := d.cfarThresholds(smooth)
noiseGlobal := median(smooth)
var signals []Signal
in := false
start := 0
@@ -136,6 +160,11 @@ func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal
peakBin := 0
for i := 0; i < n; i++ {
v := smooth[i]
thresholdOn := d.ThresholdDb
if thresholds != nil && !math.IsNaN(thresholds[i]) {
thresholdOn = thresholds[i]
}
thresholdOff := thresholdOn - d.HysteresisDb
if v >= thresholdOn {
if !in {
in = true
@@ -147,11 +176,19 @@ func (d *Detector) detectSignals(spectrum []float64, centerHz float64) []Signal
peakBin = i
}
} else if in && v < thresholdOff {
noise := noiseGlobal
if thresholds != nil && peakBin >= 0 && peakBin < len(thresholds) && !math.IsNaN(thresholds[peakBin]) {
noise = thresholds[peakBin] - d.CFARScaleDb
}
signals = append(signals, d.makeSignal(start, i-1, peak, peakBin, noise, centerHz, smooth))
in = false
}
}
if in {
noise := noiseGlobal
if thresholds != nil && peakBin >= 0 && peakBin < len(thresholds) && !math.IsNaN(thresholds[peakBin]) {
noise = thresholds[peakBin] - d.CFARScaleDb
}
signals = append(signals, d.makeSignal(start, n-1, peak, peakBin, noise, centerHz, smooth))
}
return signals
@@ -172,6 +209,43 @@ func (d *Detector) makeSignal(first, last int, peak float64, peakBin int, noise
}
}

func (d *Detector) cfarThresholds(spectrum []float64) []float64 {
if !d.CFAREnabled || d.CFARTrainCells <= 0 {
return nil
}
n := len(spectrum)
train := d.CFARTrainCells
guard := d.CFARGuardCells
totalTrain := 2 * train
if totalTrain <= 0 {
return nil
}
rank := d.CFARRank
if rank <= 0 || rank > totalTrain {
rank = int(math.Round(0.75 * float64(totalTrain)))
if rank <= 0 {
rank = 1
}
}
rankIdx := rank - 1
thresholds := make([]float64, n)
buf := make([]float64, totalTrain)
for i := 0; i < n; i++ {
leftStart := i - guard - train
rightEnd := i + guard + train
if leftStart < 0 || rightEnd >= n {
thresholds[i] = math.NaN()
continue
}
copy(buf[:train], spectrum[leftStart:leftStart+train])
copy(buf[train:], spectrum[i+guard+1:i+guard+1+train])
sort.Float64s(buf)
noise := buf[rankIdx]
thresholds[i] = noise + d.CFARScaleDb
}
return thresholds
}

func (d *Detector) smoothSpectrum(spectrum []float64) []float64 {
if d.ema == nil || len(d.ema) != len(spectrum) {
d.ema = make([]float64, len(spectrum))


+ 1
- 1
internal/detector/detector_test.go Целия файл

@@ -6,7 +6,7 @@ import (
)

func TestDetectorCreatesEvent(t *testing.T) {
d := New(-10, 1000, 10, 1*time.Millisecond, 10*time.Millisecond, 0.2, 3, 1, 10*time.Millisecond)
d := New(-10, 1000, 10, 1*time.Millisecond, 10*time.Millisecond, 0.2, 3, 1, 10*time.Millisecond, false, 2, 16, 24, 6)
center := 0.0
spectrum := []float64{-30, -30, -30, -5, -5, -30, -30, -30, -30, -30}
now := time.Now()


+ 32
- 0
internal/runtime/runtime.go Целия файл

@@ -26,6 +26,11 @@ type DetectorUpdate struct {
HysteresisDb *float64 `json:"hysteresis_db"`
MinStableFrames *int `json:"min_stable_frames"`
GapToleranceMs *int `json:"gap_tolerance_ms"`
CFAREnabled *bool `json:"cfar_enabled"`
CFARGuardCells *int `json:"cfar_guard_cells"`
CFARTrainCells *int `json:"cfar_train_cells"`
CFARRank *int `json:"cfar_rank"`
CFARScaleDb *float64 `json:"cfar_scale_db"`
}

type SettingsUpdate struct {
@@ -137,6 +142,33 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) {
if update.Detector.GapToleranceMs != nil {
next.Detector.GapToleranceMs = *update.Detector.GapToleranceMs
}
if update.Detector.CFAREnabled != nil {
next.Detector.CFAREnabled = *update.Detector.CFAREnabled
}
if update.Detector.CFARGuardCells != nil {
if *update.Detector.CFARGuardCells < 0 {
return m.cfg, errors.New("cfar_guard_cells must be >= 0")
}
next.Detector.CFARGuardCells = *update.Detector.CFARGuardCells
}
if update.Detector.CFARTrainCells != nil {
if *update.Detector.CFARTrainCells <= 0 {
return m.cfg, errors.New("cfar_train_cells must be > 0")
}
next.Detector.CFARTrainCells = *update.Detector.CFARTrainCells
}
if update.Detector.CFARRank != nil {
if *update.Detector.CFARRank <= 0 {
return m.cfg, errors.New("cfar_rank must be > 0")
}
if next.Detector.CFARTrainCells > 0 && *update.Detector.CFARRank > 2*next.Detector.CFARTrainCells {
return m.cfg, errors.New("cfar_rank must be <= 2 * cfar_train_cells")
}
next.Detector.CFARRank = *update.Detector.CFARRank
}
if update.Detector.CFARScaleDb != nil {
next.Detector.CFARScaleDb = *update.Detector.CFARScaleDb
}
}
if update.Recorder != nil {
if update.Recorder.Enabled != nil {


+ 26
- 1
internal/runtime/runtime_test.go Целия файл

@@ -15,6 +15,11 @@ func TestApplyConfigUpdate(t *testing.T) {
fftSize := 4096
threshold := -35.0
bw := 1536
cfarEnabled := true
cfarGuard := 2
cfarTrain := 12
cfarRank := 18
cfarScale := 5.5

updated, err := mgr.ApplyConfig(ConfigUpdate{
CenterHz: &center,
@@ -22,7 +27,12 @@ func TestApplyConfigUpdate(t *testing.T) {
FFTSize: &fftSize,
TunerBwKHz: &bw,
Detector: &DetectorUpdate{
ThresholdDb: &threshold,
ThresholdDb: &threshold,
CFAREnabled: &cfarEnabled,
CFARGuardCells: &cfarGuard,
CFARTrainCells: &cfarTrain,
CFARRank: &cfarRank,
CFARScaleDb: &cfarScale,
},
})
if err != nil {
@@ -40,6 +50,21 @@ func TestApplyConfigUpdate(t *testing.T) {
if updated.Detector.ThresholdDb != threshold {
t.Fatalf("threshold: %v", updated.Detector.ThresholdDb)
}
if updated.Detector.CFAREnabled != cfarEnabled {
t.Fatalf("cfar enabled: %v", updated.Detector.CFAREnabled)
}
if updated.Detector.CFARGuardCells != cfarGuard {
t.Fatalf("cfar guard: %v", updated.Detector.CFARGuardCells)
}
if updated.Detector.CFARTrainCells != cfarTrain {
t.Fatalf("cfar train: %v", updated.Detector.CFARTrainCells)
}
if updated.Detector.CFARRank != cfarRank {
t.Fatalf("cfar rank: %v", updated.Detector.CFARRank)
}
if updated.Detector.CFARScaleDb != cfarScale {
t.Fatalf("cfar scale: %v", updated.Detector.CFARScaleDb)
}
if updated.TunerBwKHz != bw {
t.Fatalf("tuner bw: %v", updated.TunerBwKHz)
}


+ 28
- 0
web/app.js Целия файл

@@ -29,6 +29,11 @@ const gainRange = qs('gainRange');
const gainInput = qs('gainInput');
const thresholdRange = qs('thresholdRange');
const thresholdInput = qs('thresholdInput');
const cfarToggle = qs('cfarToggle');
const cfarGuardInput = qs('cfarGuardInput');
const cfarTrainInput = qs('cfarTrainInput');
const cfarRankInput = qs('cfarRankInput');
const cfarScaleInput = qs('cfarScaleInput');
const emaAlphaInput = qs('emaAlphaInput');
const hysteresisInput = qs('hysteresisInput');
const stableFramesInput = qs('stableFramesInput');
@@ -282,6 +287,11 @@ function applyConfigToUI(cfg) {
gainInput.value = uiGain;
thresholdRange.value = cfg.detector.threshold_db;
thresholdInput.value = cfg.detector.threshold_db;
if (cfarToggle) cfarToggle.checked = !!cfg.detector.cfar_enabled;
if (cfarGuardInput) cfarGuardInput.value = cfg.detector.cfar_guard_cells ?? 2;
if (cfarTrainInput) cfarTrainInput.value = cfg.detector.cfar_train_cells ?? 16;
if (cfarRankInput) cfarRankInput.value = cfg.detector.cfar_rank ?? 24;
if (cfarScaleInput) cfarScaleInput.value = cfg.detector.cfar_scale_db ?? 6;
if (minDurationInput) minDurationInput.value = cfg.detector.min_duration_ms;
if (holdInput) holdInput.value = cfg.detector.hold_ms;
if (emaAlphaInput) emaAlphaInput.value = cfg.detector.ema_alpha ?? 0.2;
@@ -1136,6 +1146,24 @@ thresholdInput.addEventListener('change', () => {
}
});

if (cfarToggle) cfarToggle.addEventListener('change', () => queueConfigUpdate({ detector: { cfar_enabled: cfarToggle.checked } }));
if (cfarGuardInput) cfarGuardInput.addEventListener('change', () => {
const v = parseInt(cfarGuardInput.value, 10);
if (Number.isFinite(v)) queueConfigUpdate({ detector: { cfar_guard_cells: v } });
});
if (cfarTrainInput) cfarTrainInput.addEventListener('change', () => {
const v = parseInt(cfarTrainInput.value, 10);
if (Number.isFinite(v)) queueConfigUpdate({ detector: { cfar_train_cells: v } });
});
if (cfarRankInput) cfarRankInput.addEventListener('change', () => {
const v = parseInt(cfarRankInput.value, 10);
if (Number.isFinite(v)) queueConfigUpdate({ detector: { cfar_rank: v } });
});
if (cfarScaleInput) cfarScaleInput.addEventListener('change', () => {
const v = parseFloat(cfarScaleInput.value);
if (Number.isFinite(v)) queueConfigUpdate({ detector: { cfar_scale_db: v } });
});

agcToggle.addEventListener('change', () => queueSettingsUpdate({ agc: agcToggle.checked }));
dcToggle.addEventListener('change', () => queueSettingsUpdate({ dc_block: dcToggle.checked }));
iqToggle.addEventListener('change', () => queueSettingsUpdate({ iq_balance: iqToggle.checked }));


+ 5
- 0
web/index.html Целия файл

@@ -173,6 +173,10 @@
<span>Threshold</span>
<div class="slider-row"><input id="thresholdRange" type="range" min="-120" max="0" step="1" class="range--warn" /><input id="thresholdInput" type="number" min="-120" max="0" step="1" class="slider-num" /><em>dB</em></div>
</div>
<label class="field"><span>CFAR Guard Cells</span><input id="cfarGuardInput" type="number" step="1" min="0" /></label>
<label class="field"><span>CFAR Train Cells</span><input id="cfarTrainInput" type="number" step="1" min="1" /></label>
<label class="field"><span>CFAR Rank</span><input id="cfarRankInput" type="number" step="1" min="1" /></label>
<label class="field"><span>CFAR Scale (dB)</span><input id="cfarScaleInput" type="number" step="0.5" min="0" /></label>
<label class="field"><span>Min Duration (ms)</span><input id="minDurationInput" type="number" step="50" min="50" /></label>
<label class="field"><span>Hold (ms)</span><input id="holdInput" type="number" step="50" min="50" /></label>
<label class="field"><span>Averaging</span>
@@ -186,6 +190,7 @@
<label class="field"><span>Min Stable Frames</span><input id="stableFramesInput" type="number" step="1" min="1" /></label>
<label class="field"><span>Gap Tolerance (ms)</span><input id="gapToleranceInput" type="number" step="50" min="0" /></label>
<div class="toggle-grid">
<label class="pill-toggle"><input id="cfarToggle" type="checkbox" /><span class="pt"><span class="pk"></span></span><span class="pl">CFAR</span></label>
<label class="pill-toggle"><input id="agcToggle" type="checkbox" /><span class="pt"><span class="pk"></span></span><span class="pl">AGC</span></label>
<label class="pill-toggle"><input id="dcToggle" type="checkbox" /><span class="pt"><span class="pk"></span></span><span class="pl">DC Block</span></label>
<label class="pill-toggle"><input id="iqToggle" type="checkbox" /><span class="pt"><span class="pk"></span></span><span class="pl">IQ Bal</span></label>


Loading…
Отказ
Запис