| @@ -33,6 +33,8 @@ type DetectorConfig struct { | |||
| EdgeMarginDb float64 `yaml:"edge_margin_db" json:"edge_margin_db"` | |||
| MaxSignalBwHz float64 `yaml:"max_signal_bw_hz" json:"max_signal_bw_hz"` | |||
| MergeGapHz float64 `yaml:"merge_gap_hz" json:"merge_gap_hz"` | |||
| ClassHistorySize int `yaml:"class_history_size" json:"class_history_size"` | |||
| ClassSwitchRatio float64 `yaml:"class_switch_ratio" json:"class_switch_ratio"` | |||
| // Deprecated (backward compatibility) | |||
| CFAREnabled *bool `yaml:"cfar_enabled,omitempty" json:"cfar_enabled,omitempty"` | |||
| @@ -117,6 +119,8 @@ func Default() Config { | |||
| EdgeMarginDb: 3.0, | |||
| MaxSignalBwHz: 150000, | |||
| MergeGapHz: 5000, | |||
| ClassHistorySize: 10, | |||
| ClassSwitchRatio: 0.6, | |||
| }, | |||
| Recorder: RecorderConfig{ | |||
| Enabled: false, | |||
| @@ -218,6 +222,12 @@ func applyDefaults(cfg Config) Config { | |||
| if cfg.Detector.MergeGapHz <= 0 { | |||
| cfg.Detector.MergeGapHz = 5000 | |||
| } | |||
| if cfg.Detector.ClassHistorySize <= 0 { | |||
| cfg.Detector.ClassHistorySize = 10 | |||
| } | |||
| if cfg.Detector.ClassSwitchRatio <= 0 || cfg.Detector.ClassSwitchRatio > 1 { | |||
| cfg.Detector.ClassSwitchRatio = 0.6 | |||
| } | |||
| if cfg.FrameRate <= 0 { | |||
| cfg.FrameRate = 15 | |||
| } | |||
| @@ -35,6 +35,8 @@ type Detector struct { | |||
| EdgeMarginDb float64 | |||
| MaxSignalBwHz float64 | |||
| MergeGapHz float64 | |||
| classHistorySize int | |||
| classSwitchRatio float64 | |||
| binWidth float64 | |||
| nbins int | |||
| sampleRate int | |||
| @@ -57,8 +59,10 @@ type activeEvent struct { | |||
| snrDb float64 | |||
| firstBin int | |||
| lastBin int | |||
| class *classifier.Classification | |||
| stableHits int | |||
| class *classifier.Classification | |||
| stableHits int | |||
| classHistory []classifier.SignalClass | |||
| classIdx int | |||
| } | |||
| type Signal struct { | |||
| @@ -96,6 +100,8 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { | |||
| edgeMarginDb := detCfg.EdgeMarginDb | |||
| maxSignalBwHz := detCfg.MaxSignalBwHz | |||
| mergeGapHz := detCfg.MergeGapHz | |||
| classHistorySize := detCfg.ClassHistorySize | |||
| classSwitchRatio := detCfg.ClassSwitchRatio | |||
| if minDur <= 0 { | |||
| minDur = 250 * time.Millisecond | |||
| @@ -127,6 +133,12 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { | |||
| if mergeGapHz <= 0 { | |||
| mergeGapHz = 5000 | |||
| } | |||
| if classHistorySize <= 0 { | |||
| classHistorySize = 10 | |||
| } | |||
| if classSwitchRatio <= 0 || classSwitchRatio > 1 { | |||
| classSwitchRatio = 0.6 | |||
| } | |||
| if cfarRank <= 0 || cfarRank > 2*cfarTrain { | |||
| cfarRank = int(math.Round(0.75 * float64(2*cfarTrain))) | |||
| if cfarRank <= 0 { | |||
| @@ -156,6 +168,8 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { | |||
| EdgeMarginDb: edgeMarginDb, | |||
| MaxSignalBwHz: maxSignalBwHz, | |||
| MergeGapHz: mergeGapHz, | |||
| classHistorySize: classHistorySize, | |||
| classSwitchRatio: classSwitchRatio, | |||
| binWidth: float64(sampleRate) / float64(fftSize), | |||
| nbins: fftSize, | |||
| sampleRate: sampleRate, | |||
| @@ -183,14 +197,68 @@ func (d *Detector) LastNoiseFloor() float64 { | |||
| return d.lastNoiseFloor | |||
| } | |||
| func (ev *activeEvent) updateClass(newCls *classifier.Classification, historySize int, switchRatio float64) { | |||
| if newCls == nil { | |||
| return | |||
| } | |||
| if historySize <= 0 { | |||
| historySize = 10 | |||
| } | |||
| if switchRatio <= 0 || switchRatio > 1 { | |||
| switchRatio = 0.6 | |||
| } | |||
| if len(ev.classHistory) != historySize { | |||
| ev.classHistory = make([]classifier.SignalClass, historySize) | |||
| ev.classIdx = 0 | |||
| } | |||
| ev.classHistory[ev.classIdx%len(ev.classHistory)] = newCls.ModType | |||
| ev.classIdx++ | |||
| if ev.class == nil { | |||
| clone := *newCls | |||
| ev.class = &clone | |||
| return | |||
| } | |||
| counts := map[classifier.SignalClass]int{} | |||
| filled := ev.classIdx | |||
| if filled > len(ev.classHistory) { | |||
| filled = len(ev.classHistory) | |||
| } | |||
| for i := 0; i < filled; i++ { | |||
| c := ev.classHistory[i] | |||
| if c != "" { | |||
| counts[c]++ | |||
| } | |||
| } | |||
| var majority classifier.SignalClass | |||
| majorityCount := 0 | |||
| for c, n := range counts { | |||
| if n > majorityCount { | |||
| majority = c | |||
| majorityCount = n | |||
| } | |||
| } | |||
| threshold := int(math.Ceil(float64(filled) * switchRatio)) | |||
| if threshold < 1 { | |||
| threshold = 1 | |||
| } | |||
| if majorityCount >= threshold && majority != ev.class.ModType { | |||
| clone := *newCls | |||
| clone.ModType = majority | |||
| ev.class = &clone | |||
| } else if majority == ev.class.ModType && newCls.Confidence > ev.class.Confidence { | |||
| ev.class.Confidence = newCls.Confidence | |||
| ev.class.Features = newCls.Features | |||
| ev.class.SecondBest = newCls.SecondBest | |||
| ev.class.Scores = newCls.Scores | |||
| } | |||
| } | |||
| func (d *Detector) UpdateClasses(signals []Signal) { | |||
| for _, s := range signals { | |||
| for _, ev := range d.active { | |||
| if overlapHz(s.CenterHz, s.BWHz, ev.centerHz, ev.bwHz) && math.Abs(s.CenterHz-ev.centerHz) < (s.BWHz+ev.bwHz)/2.0 { | |||
| if s.Class != nil { | |||
| if ev.class == nil || s.Class.Confidence >= ev.class.Confidence { | |||
| ev.class = s.Class | |||
| } | |||
| ev.updateClass(s.Class, d.classHistorySize, d.classSwitchRatio) | |||
| } | |||
| } | |||
| } | |||
| @@ -498,8 +566,11 @@ func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event { | |||
| best.lastSeen = now | |||
| best.stableHits++ | |||
| best.centerHz = (best.centerHz + s.CenterHz) / 2.0 | |||
| if s.BWHz > best.bwHz { | |||
| if best.bwHz <= 0 { | |||
| best.bwHz = s.BWHz | |||
| } else { | |||
| const alpha = 0.15 | |||
| best.bwHz = alpha*s.BWHz + (1-alpha)*best.bwHz | |||
| } | |||
| if s.PeakDb > best.peakDb { | |||
| best.peakDb = s.PeakDb | |||
| @@ -514,9 +585,7 @@ func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event { | |||
| best.lastBin = s.LastBin | |||
| } | |||
| if s.Class != nil { | |||
| if best.class == nil || s.Class.Confidence >= best.class.Confidence { | |||
| best.class = s.Class | |||
| } | |||
| best.updateClass(s.Class, d.classHistorySize, d.classSwitchRatio) | |||
| } | |||
| } | |||
| @@ -4,6 +4,7 @@ import ( | |||
| "testing" | |||
| "time" | |||
| "sdr-visual-suite/internal/classifier" | |||
| "sdr-visual-suite/internal/config" | |||
| ) | |||
| @@ -174,6 +175,42 @@ func TestGuardTrainHzScaling(t *testing.T) { | |||
| } | |||
| } | |||
| func TestClassStabilization(t *testing.T) { | |||
| ev := &activeEvent{} | |||
| histSize := 5 | |||
| switchRatio := 0.6 | |||
| wfm := &classifier.Classification{ModType: classifier.ClassWFM, Confidence: 0.8} | |||
| nfm := &classifier.Classification{ModType: classifier.ClassNFM, Confidence: 0.7} | |||
| ev.updateClass(wfm, histSize, switchRatio) | |||
| if ev.class.ModType != classifier.ClassWFM { | |||
| t.Fatalf("first class should be WFM, got %s", ev.class.ModType) | |||
| } | |||
| ev.updateClass(nfm, histSize, switchRatio) | |||
| if ev.class.ModType != classifier.ClassWFM { | |||
| t.Fatalf("should stay WFM after 1 NFM, got %s", ev.class.ModType) | |||
| } | |||
| ev.updateClass(nfm, histSize, switchRatio) | |||
| if ev.class.ModType != classifier.ClassNFM { | |||
| t.Fatalf("should switch to NFM after 2/3 majority, got %s", ev.class.ModType) | |||
| } | |||
| for i := 0; i < 5; i++ { | |||
| ev.updateClass(wfm, histSize, switchRatio) | |||
| } | |||
| if ev.class.ModType != classifier.ClassWFM { | |||
| t.Fatalf("should be WFM after 5 consecutive, got %s", ev.class.ModType) | |||
| } | |||
| ev.updateClass(nfm, histSize, switchRatio) | |||
| if ev.class.ModType != classifier.ClassWFM { | |||
| t.Fatalf("single outlier should not flip class, got %s", ev.class.ModType) | |||
| } | |||
| } | |||
| func makeSignalSpectrum(fftSize int, sampleRate int, offsetHz float64, bwHz float64, signalDb float64, noiseDb float64) []float64 { | |||
| spectrum := make([]float64, fftSize) | |||
| for i := range spectrum { | |||
| @@ -38,6 +38,8 @@ type DetectorUpdate struct { | |||
| CFARWrapAround *bool `json:"cfar_wrap_around"` | |||
| EdgeMarginDb *float64 `json:"edge_margin_db"` | |||
| MergeGapHz *float64 `json:"merge_gap_hz"` | |||
| ClassHistorySize *int `json:"class_history_size"` | |||
| ClassSwitchRatio *float64 `json:"class_switch_ratio"` | |||
| } | |||
| type SettingsUpdate struct { | |||
| @@ -225,6 +227,19 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { | |||
| } | |||
| next.Detector.MergeGapHz = v | |||
| } | |||
| if update.Detector.ClassHistorySize != nil { | |||
| if *update.Detector.ClassHistorySize < 1 { | |||
| return m.cfg, errors.New("class_history_size must be >= 1") | |||
| } | |||
| next.Detector.ClassHistorySize = *update.Detector.ClassHistorySize | |||
| } | |||
| if update.Detector.ClassSwitchRatio != nil { | |||
| v := *update.Detector.ClassSwitchRatio | |||
| if math.IsNaN(v) || math.IsInf(v, 0) || v < 0.1 || v > 1.0 { | |||
| return m.cfg, errors.New("class_switch_ratio must be between 0.1 and 1.0") | |||
| } | |||
| next.Detector.ClassSwitchRatio = v | |||
| } | |||
| } | |||
| if update.Recorder != nil { | |||
| if update.Recorder.Enabled != nil { | |||
| @@ -44,6 +44,8 @@ const stableFramesInput = qs('stableFramesInput'); | |||
| const gapToleranceInput = qs('gapToleranceInput'); | |||
| const edgeMarginInput = qs('edgeMarginInput'); | |||
| const mergeGapInput = qs('mergeGapInput'); | |||
| const classHistoryInput = qs('classHistoryInput'); | |||
| const classSwitchInput = qs('classSwitchInput'); | |||
| const agcToggle = qs('agcToggle'); | |||
| const dcToggle = qs('dcToggle'); | |||
| const iqToggle = qs('iqToggle'); | |||
| @@ -422,6 +424,8 @@ function applyConfigToUI(cfg) { | |||
| if (gapToleranceInput) gapToleranceInput.value = cfg.detector.gap_tolerance_ms ?? cfg.detector.hold_ms; | |||
| if (edgeMarginInput) edgeMarginInput.value = cfg.detector.edge_margin_db ?? 3.0; | |||
| if (mergeGapInput) mergeGapInput.value = cfg.detector.merge_gap_hz ?? 5000; | |||
| if (classHistoryInput) classHistoryInput.value = cfg.detector.class_history_size ?? 10; | |||
| if (classSwitchInput) classSwitchInput.value = cfg.detector.class_switch_ratio ?? 0.6; | |||
| agcToggle.checked = !!cfg.agc; | |||
| dcToggle.checked = !!cfg.dc_block; | |||
| iqToggle.checked = !!cfg.iq_balance; | |||
| @@ -1429,6 +1433,14 @@ if (mergeGapInput) mergeGapInput.addEventListener('change', () => { | |||
| const v = parseFloat(mergeGapInput.value); | |||
| if (Number.isFinite(v)) queueConfigUpdate({ detector: { merge_gap_hz: v } }); | |||
| }); | |||
| if (classHistoryInput) classHistoryInput.addEventListener('change', () => { | |||
| const v = parseInt(classHistoryInput.value, 10); | |||
| if (Number.isFinite(v) && v >= 1) queueConfigUpdate({ detector: { class_history_size: v } }); | |||
| }); | |||
| if (classSwitchInput) classSwitchInput.addEventListener('change', () => { | |||
| const v = parseFloat(classSwitchInput.value); | |||
| if (Number.isFinite(v) && v >= 0.1 && v <= 1.0) queueConfigUpdate({ detector: { class_switch_ratio: v } }); | |||
| }); | |||
| agcToggle.addEventListener('change', () => queueSettingsUpdate({ agc: agcToggle.checked })); | |||
| dcToggle.addEventListener('change', () => queueSettingsUpdate({ dc_block: dcToggle.checked })); | |||
| @@ -199,6 +199,8 @@ | |||
| <label class="field"><span>Gap Tolerance (ms)</span><input id="gapToleranceInput" type="number" step="50" min="0" /></label> | |||
| <label class="field"><span>Edge Margin (dB)</span><input id="edgeMarginInput" type="number" step="0.5" min="0" /></label> | |||
| <label class="field"><span>Merge Gap (Hz)</span><input id="mergeGapInput" type="number" step="500" min="0" /></label> | |||
| <label class="field"><span>Class History</span><input id="classHistoryInput" type="number" step="1" min="1" max="30" /></label> | |||
| <label class="field"><span>Class Switch Ratio</span><input id="classSwitchInput" type="number" step="0.05" min="0.1" max="1.0" /></label> | |||
| <label class="field"><span>CFAR Mode</span> | |||
| <select id="cfarModeSelect"> | |||
| <option value="OFF">Off</option> | |||