Просмотр исходного кода

feat: merge nearby detector fragments across small gaps

master
Jan Svabenik 2 дней назад
Родитель
Сommit
a3279563d9
6 измененных файлов: 71 добавлений и 1 удалений
  1. +5
    -0
      internal/config/config.go
  2. +15
    -1
      internal/detector/detector.go
  3. +36
    -0
      internal/detector/detector_test.go
  4. +8
    -0
      internal/runtime/runtime.go
  5. +6
    -0
      web/app.js
  6. +1
    -0
      web/index.html

+ 5
- 0
internal/config/config.go Просмотреть файл

@@ -30,6 +30,7 @@ type DetectorConfig struct {
CFARWrapAround bool `yaml:"cfar_wrap_around" json:"cfar_wrap_around"`
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"`

// Deprecated (backward compatibility)
CFAREnabled *bool `yaml:"cfar_enabled,omitempty" json:"cfar_enabled,omitempty"`
@@ -111,6 +112,7 @@ func Default() Config {
CFARWrapAround: true,
EdgeMarginDb: 3.0,
MaxSignalBwHz: 150000,
MergeGapHz: 5000,
},
Recorder: RecorderConfig{
Enabled: false,
@@ -197,6 +199,9 @@ func applyDefaults(cfg Config) Config {
if cfg.Detector.MaxSignalBwHz <= 0 {
cfg.Detector.MaxSignalBwHz = 150000
}
if cfg.Detector.MergeGapHz <= 0 {
cfg.Detector.MergeGapHz = 5000
}
if cfg.FrameRate <= 0 {
cfg.FrameRate = 15
}


+ 15
- 1
internal/detector/detector.go Просмотреть файл

@@ -34,6 +34,7 @@ type Detector struct {
CFARScaleDb float64
EdgeMarginDb float64
MaxSignalBwHz float64
MergeGapHz float64
binWidth float64
nbins int
sampleRate int
@@ -87,6 +88,7 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector {
thresholdDb := detCfg.ThresholdDb
edgeMarginDb := detCfg.EdgeMarginDb
maxSignalBwHz := detCfg.MaxSignalBwHz
mergeGapHz := detCfg.MergeGapHz

if minDur <= 0 {
minDur = 250 * time.Millisecond
@@ -121,6 +123,9 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector {
if maxSignalBwHz <= 0 {
maxSignalBwHz = 150000
}
if mergeGapHz <= 0 {
mergeGapHz = 5000
}
if cfarRank <= 0 || cfarRank > 2*cfarTrain {
cfarRank = int(math.Round(0.75 * float64(2*cfarTrain)))
if cfarRank <= 0 {
@@ -149,6 +154,7 @@ func New(detCfg config.DetectorConfig, sampleRate int, fftSize int) *Detector {
CFARScaleDb: cfarScaleDb,
EdgeMarginDb: edgeMarginDb,
MaxSignalBwHz: maxSignalBwHz,
MergeGapHz: mergeGapHz,
binWidth: float64(sampleRate) / float64(fftSize),
nbins: fftSize,
sampleRate: sampleRate,
@@ -323,6 +329,10 @@ func (d *Detector) mergeOverlapping(signals []Signal, centerHz float64) []Signal
if len(signals) <= 1 {
return signals
}
gapBins := 0
if d.MergeGapHz > 0 && d.binWidth > 0 {
gapBins = int(math.Ceil(d.MergeGapHz / d.binWidth))
}
sort.Slice(signals, func(i, j int) bool {
return signals[i].FirstBin < signals[j].FirstBin
})
@@ -330,7 +340,8 @@ func (d *Detector) mergeOverlapping(signals []Signal, centerHz float64) []Signal
for i := 1; i < len(signals); i++ {
last := &merged[len(merged)-1]
cur := signals[i]
if cur.FirstBin <= last.LastBin+1 {
gap := cur.FirstBin - last.LastBin - 1
if gap <= gapBins {
if cur.LastBin > last.LastBin {
last.LastBin = cur.LastBin
}
@@ -343,6 +354,9 @@ func (d *Detector) mergeOverlapping(signals []Signal, centerHz float64) []Signal
centerBin := float64(last.FirstBin+last.LastBin) / 2.0
last.BWHz = float64(last.LastBin-last.FirstBin+1) * d.binWidth
last.CenterHz = d.centerFreqForBin(centerBin, centerHz)
if cur.NoiseDb < last.NoiseDb || last.NoiseDb == 0 {
last.NoiseDb = cur.NoiseDb
}
} else {
merged = append(merged, cur)
}


+ 36
- 0
internal/detector/detector_test.go Просмотреть файл

@@ -72,6 +72,7 @@ func TestSignalBandwidthExpansion(t *testing.T) {
CFARWrapAround: true,
EdgeMarginDb: 3.0,
MaxSignalBwHz: 150000,
MergeGapHz: 5000,
}
d := New(cfg, sampleRate, fftSize)
spectrum := make([]float64, fftSize)
@@ -101,3 +102,38 @@ func TestSignalBandwidthExpansion(t *testing.T) {
t.Errorf("BW too narrow: got %.0f Hz, want >= %.0f Hz (FirstBin=%d LastBin=%d)", sig.BWHz, expectedMinBW, sig.FirstBin, sig.LastBin)
}
}

func TestMergeGap(t *testing.T) {
sampleRate := 2048000
fftSize := 2048
cfg := config.DetectorConfig{
ThresholdDb: -20,
MinDurationMs: 1,
HoldMs: 10,
EmaAlpha: 1.0,
HysteresisDb: 3,
MinStableFrames: 1,
GapToleranceMs: 10,
CFARMode: "OFF",
MergeGapHz: 5000,
}
d := New(cfg, sampleRate, fftSize)
spectrum := make([]float64, fftSize)
for i := range spectrum {
spectrum[i] = -100
}
for i := 500; i <= 505; i++ {
spectrum[i] = -20
}
for i := 509; i <= 514; i++ {
spectrum[i] = -20
}
now := time.Now()
_, signals := d.Process(now, spectrum, 434e6)
if len(signals) != 1 {
t.Errorf("expected 1 merged signal, got %d", len(signals))
}
if len(signals) == 1 && signals[0].BWHz < 14000 {
t.Errorf("merged BW too narrow: %.0f Hz", signals[0].BWHz)
}
}

+ 8
- 0
internal/runtime/runtime.go Просмотреть файл

@@ -35,6 +35,7 @@ type DetectorUpdate struct {
CFARScaleDb *float64 `json:"cfar_scale_db"`
CFARWrapAround *bool `json:"cfar_wrap_around"`
EdgeMarginDb *float64 `json:"edge_margin_db"`
MergeGapHz *float64 `json:"merge_gap_hz"`
}

type SettingsUpdate struct {
@@ -203,6 +204,13 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) {
}
next.Detector.EdgeMarginDb = v
}
if update.Detector.MergeGapHz != nil {
v := *update.Detector.MergeGapHz
if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 {
return m.cfg, errors.New("merge_gap_hz must be >= 0")
}
next.Detector.MergeGapHz = v
}
}
if update.Recorder != nil {
if update.Recorder.Enabled != nil {


+ 6
- 0
web/app.js Просмотреть файл

@@ -43,6 +43,7 @@ const hysteresisInput = qs('hysteresisInput');
const stableFramesInput = qs('stableFramesInput');
const gapToleranceInput = qs('gapToleranceInput');
const edgeMarginInput = qs('edgeMarginInput');
const mergeGapInput = qs('mergeGapInput');
const agcToggle = qs('agcToggle');
const dcToggle = qs('dcToggle');
const iqToggle = qs('iqToggle');
@@ -420,6 +421,7 @@ function applyConfigToUI(cfg) {
if (stableFramesInput) stableFramesInput.value = cfg.detector.min_stable_frames ?? 3;
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;
agcToggle.checked = !!cfg.agc;
dcToggle.checked = !!cfg.dc_block;
iqToggle.checked = !!cfg.iq_balance;
@@ -1422,6 +1424,10 @@ if (edgeMarginInput) edgeMarginInput.addEventListener('change', () => {
const v = parseFloat(edgeMarginInput.value);
if (Number.isFinite(v)) queueConfigUpdate({ detector: { edge_margin_db: v } });
});
if (mergeGapInput) mergeGapInput.addEventListener('change', () => {
const v = parseFloat(mergeGapInput.value);
if (Number.isFinite(v)) queueConfigUpdate({ detector: { merge_gap_hz: v } });
});

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


+ 1
- 0
web/index.html Просмотреть файл

@@ -198,6 +198,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>
<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>CFAR Mode</span>
<select id="cfarModeSelect">
<option value="OFF">Off</option>


Загрузка…
Отмена
Сохранить