| @@ -33,6 +33,8 @@ type DetectorConfig struct { | |||||
| EdgeMarginDb float64 `yaml:"edge_margin_db" json:"edge_margin_db"` | EdgeMarginDb float64 `yaml:"edge_margin_db" json:"edge_margin_db"` | ||||
| MaxSignalBwHz float64 `yaml:"max_signal_bw_hz" json:"max_signal_bw_hz"` | MaxSignalBwHz float64 `yaml:"max_signal_bw_hz" json:"max_signal_bw_hz"` | ||||
| MergeGapHz float64 `yaml:"merge_gap_hz" json:"merge_gap_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) | // Deprecated (backward compatibility) | ||||
| CFAREnabled *bool `yaml:"cfar_enabled,omitempty" json:"cfar_enabled,omitempty"` | CFAREnabled *bool `yaml:"cfar_enabled,omitempty" json:"cfar_enabled,omitempty"` | ||||
| @@ -117,6 +119,8 @@ func Default() Config { | |||||
| EdgeMarginDb: 3.0, | EdgeMarginDb: 3.0, | ||||
| MaxSignalBwHz: 150000, | MaxSignalBwHz: 150000, | ||||
| MergeGapHz: 5000, | MergeGapHz: 5000, | ||||
| ClassHistorySize: 10, | |||||
| ClassSwitchRatio: 0.6, | |||||
| }, | }, | ||||
| Recorder: RecorderConfig{ | Recorder: RecorderConfig{ | ||||
| Enabled: false, | Enabled: false, | ||||
| @@ -218,6 +222,12 @@ func applyDefaults(cfg Config) Config { | |||||
| if cfg.Detector.MergeGapHz <= 0 { | if cfg.Detector.MergeGapHz <= 0 { | ||||
| cfg.Detector.MergeGapHz = 5000 | 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 { | if cfg.FrameRate <= 0 { | ||||
| cfg.FrameRate = 15 | cfg.FrameRate = 15 | ||||
| } | } | ||||
| @@ -35,6 +35,8 @@ type Detector struct { | |||||
| EdgeMarginDb float64 | EdgeMarginDb float64 | ||||
| MaxSignalBwHz float64 | MaxSignalBwHz float64 | ||||
| MergeGapHz float64 | MergeGapHz float64 | ||||
| classHistorySize int | |||||
| classSwitchRatio float64 | |||||
| binWidth float64 | binWidth float64 | ||||
| nbins int | nbins int | ||||
| sampleRate int | sampleRate int | ||||
| @@ -57,8 +59,10 @@ type activeEvent struct { | |||||
| snrDb float64 | snrDb float64 | ||||
| firstBin int | firstBin int | ||||
| lastBin int | lastBin int | ||||
| class *classifier.Classification | |||||
| stableHits int | |||||
| class *classifier.Classification | |||||
| stableHits int | |||||
| classHistory []classifier.SignalClass | |||||
| classIdx int | |||||
| } | } | ||||
| type Signal struct { | type Signal struct { | ||||
| @@ -96,6 +100,8 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { | |||||
| edgeMarginDb := detCfg.EdgeMarginDb | edgeMarginDb := detCfg.EdgeMarginDb | ||||
| maxSignalBwHz := detCfg.MaxSignalBwHz | maxSignalBwHz := detCfg.MaxSignalBwHz | ||||
| mergeGapHz := detCfg.MergeGapHz | mergeGapHz := detCfg.MergeGapHz | ||||
| classHistorySize := detCfg.ClassHistorySize | |||||
| classSwitchRatio := detCfg.ClassSwitchRatio | |||||
| if minDur <= 0 { | if minDur <= 0 { | ||||
| minDur = 250 * time.Millisecond | minDur = 250 * time.Millisecond | ||||
| @@ -127,6 +133,12 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { | |||||
| if mergeGapHz <= 0 { | if mergeGapHz <= 0 { | ||||
| mergeGapHz = 5000 | mergeGapHz = 5000 | ||||
| } | } | ||||
| if classHistorySize <= 0 { | |||||
| classHistorySize = 10 | |||||
| } | |||||
| if classSwitchRatio <= 0 || classSwitchRatio > 1 { | |||||
| classSwitchRatio = 0.6 | |||||
| } | |||||
| if cfarRank <= 0 || cfarRank > 2*cfarTrain { | if cfarRank <= 0 || cfarRank > 2*cfarTrain { | ||||
| cfarRank = int(math.Round(0.75 * float64(2*cfarTrain))) | cfarRank = int(math.Round(0.75 * float64(2*cfarTrain))) | ||||
| if cfarRank <= 0 { | if cfarRank <= 0 { | ||||
| @@ -156,6 +168,8 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector { | |||||
| EdgeMarginDb: edgeMarginDb, | EdgeMarginDb: edgeMarginDb, | ||||
| MaxSignalBwHz: maxSignalBwHz, | MaxSignalBwHz: maxSignalBwHz, | ||||
| MergeGapHz: mergeGapHz, | MergeGapHz: mergeGapHz, | ||||
| classHistorySize: classHistorySize, | |||||
| classSwitchRatio: classSwitchRatio, | |||||
| binWidth: float64(sampleRate) / float64(fftSize), | binWidth: float64(sampleRate) / float64(fftSize), | ||||
| nbins: fftSize, | nbins: fftSize, | ||||
| sampleRate: sampleRate, | sampleRate: sampleRate, | ||||
| @@ -183,14 +197,68 @@ func (d *Detector) LastNoiseFloor() float64 { | |||||
| return d.lastNoiseFloor | 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) { | func (d *Detector) UpdateClasses(signals []Signal) { | ||||
| for _, s := range signals { | for _, s := range signals { | ||||
| for _, ev := range d.active { | 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 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 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.lastSeen = now | ||||
| best.stableHits++ | best.stableHits++ | ||||
| best.centerHz = (best.centerHz + s.CenterHz) / 2.0 | best.centerHz = (best.centerHz + s.CenterHz) / 2.0 | ||||
| if s.BWHz > best.bwHz { | |||||
| if best.bwHz <= 0 { | |||||
| best.bwHz = s.BWHz | best.bwHz = s.BWHz | ||||
| } else { | |||||
| const alpha = 0.15 | |||||
| best.bwHz = alpha*s.BWHz + (1-alpha)*best.bwHz | |||||
| } | } | ||||
| if s.PeakDb > best.peakDb { | if s.PeakDb > best.peakDb { | ||||
| best.peakDb = s.PeakDb | best.peakDb = s.PeakDb | ||||
| @@ -514,9 +585,7 @@ func (d *Detector) matchSignals(now time.Time, signals []Signal) []Event { | |||||
| best.lastBin = s.LastBin | best.lastBin = s.LastBin | ||||
| } | } | ||||
| if s.Class != nil { | 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" | "testing" | ||||
| "time" | "time" | ||||
| "sdr-visual-suite/internal/classifier" | |||||
| "sdr-visual-suite/internal/config" | "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 { | func makeSignalSpectrum(fftSize int, sampleRate int, offsetHz float64, bwHz float64, signalDb float64, noiseDb float64) []float64 { | ||||
| spectrum := make([]float64, fftSize) | spectrum := make([]float64, fftSize) | ||||
| for i := range spectrum { | for i := range spectrum { | ||||
| @@ -38,6 +38,8 @@ type DetectorUpdate struct { | |||||
| CFARWrapAround *bool `json:"cfar_wrap_around"` | CFARWrapAround *bool `json:"cfar_wrap_around"` | ||||
| EdgeMarginDb *float64 `json:"edge_margin_db"` | EdgeMarginDb *float64 `json:"edge_margin_db"` | ||||
| MergeGapHz *float64 `json:"merge_gap_hz"` | MergeGapHz *float64 `json:"merge_gap_hz"` | ||||
| ClassHistorySize *int `json:"class_history_size"` | |||||
| ClassSwitchRatio *float64 `json:"class_switch_ratio"` | |||||
| } | } | ||||
| type SettingsUpdate struct { | type SettingsUpdate struct { | ||||
| @@ -225,6 +227,19 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { | |||||
| } | } | ||||
| next.Detector.MergeGapHz = v | 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 != nil { | ||||
| if update.Recorder.Enabled != nil { | if update.Recorder.Enabled != nil { | ||||
| @@ -44,6 +44,8 @@ const stableFramesInput = qs('stableFramesInput'); | |||||
| const gapToleranceInput = qs('gapToleranceInput'); | const gapToleranceInput = qs('gapToleranceInput'); | ||||
| const edgeMarginInput = qs('edgeMarginInput'); | const edgeMarginInput = qs('edgeMarginInput'); | ||||
| const mergeGapInput = qs('mergeGapInput'); | const mergeGapInput = qs('mergeGapInput'); | ||||
| const classHistoryInput = qs('classHistoryInput'); | |||||
| const classSwitchInput = qs('classSwitchInput'); | |||||
| const agcToggle = qs('agcToggle'); | const agcToggle = qs('agcToggle'); | ||||
| const dcToggle = qs('dcToggle'); | const dcToggle = qs('dcToggle'); | ||||
| const iqToggle = qs('iqToggle'); | 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 (gapToleranceInput) gapToleranceInput.value = cfg.detector.gap_tolerance_ms ?? cfg.detector.hold_ms; | ||||
| if (edgeMarginInput) edgeMarginInput.value = cfg.detector.edge_margin_db ?? 3.0; | if (edgeMarginInput) edgeMarginInput.value = cfg.detector.edge_margin_db ?? 3.0; | ||||
| if (mergeGapInput) mergeGapInput.value = cfg.detector.merge_gap_hz ?? 5000; | 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; | agcToggle.checked = !!cfg.agc; | ||||
| dcToggle.checked = !!cfg.dc_block; | dcToggle.checked = !!cfg.dc_block; | ||||
| iqToggle.checked = !!cfg.iq_balance; | iqToggle.checked = !!cfg.iq_balance; | ||||
| @@ -1429,6 +1433,14 @@ if (mergeGapInput) mergeGapInput.addEventListener('change', () => { | |||||
| const v = parseFloat(mergeGapInput.value); | const v = parseFloat(mergeGapInput.value); | ||||
| if (Number.isFinite(v)) queueConfigUpdate({ detector: { merge_gap_hz: v } }); | 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 })); | agcToggle.addEventListener('change', () => queueSettingsUpdate({ agc: agcToggle.checked })); | ||||
| dcToggle.addEventListener('change', () => queueSettingsUpdate({ dc_block: dcToggle.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>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>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>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> | <label class="field"><span>CFAR Mode</span> | ||||
| <select id="cfarModeSelect"> | <select id="cfarModeSelect"> | ||||
| <option value="OFF">Off</option> | <option value="OFF">Off</option> | ||||