diff --git a/internal/config/config.go b/internal/config/config.go
index 1971698..5701395 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -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
}
diff --git a/internal/detector/detector.go b/internal/detector/detector.go
index 6d4f354..7e8863c 100644
--- a/internal/detector/detector.go
+++ b/internal/detector/detector.go
@@ -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)
}
}
diff --git a/internal/detector/detector_test.go b/internal/detector/detector_test.go
index c6373c7..27e983f 100644
--- a/internal/detector/detector_test.go
+++ b/internal/detector/detector_test.go
@@ -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 {
diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go
index 3f79848..fe8129c 100644
--- a/internal/runtime/runtime.go
+++ b/internal/runtime/runtime.go
@@ -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 {
diff --git a/web/app.js b/web/app.js
index fcf5345..bb57003 100644
--- a/web/app.js
+++ b/web/app.js
@@ -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 }));
diff --git a/web/index.html b/web/index.html
index 5fcd55e..d4a59e7 100644
--- a/web/index.html
+++ b/web/index.html
@@ -199,6 +199,8 @@
+
+