| @@ -13,10 +13,14 @@ func RuleClassify(feat Features) Classification { | |||||
| conf := 0.3 | conf := 0.3 | ||||
| switch { | switch { | ||||
| case bw > 100e3: | |||||
| case bw >= 80e3: | |||||
| best = ClassWFM | best = ClassWFM | ||||
| conf = 0.9 | conf = 0.9 | ||||
| case bw >= 6e3 && bw <= 16e3: | |||||
| case bw >= 25e3 && bw < 80e3: | |||||
| best = ClassWFM | |||||
| second = ClassNFM | |||||
| conf = 0.65 | |||||
| case bw >= 6e3 && bw < 25e3: | |||||
| best = ClassNFM | best = ClassNFM | ||||
| conf = 0.8 | conf = 0.8 | ||||
| if flat > 0.7 { | if flat > 0.7 { | ||||
| @@ -63,6 +67,12 @@ func RuleClassify(feat Features) Classification { | |||||
| if feat.EnvVariance < 0.4 && feat.InstFreqStd < 0.5 { | if feat.EnvVariance < 0.4 && feat.InstFreqStd < 0.5 { | ||||
| best = ClassWSPR | best = ClassWSPR | ||||
| conf = 0.55 | conf = 0.55 | ||||
| } else if feat.InstFreqStd > 0.9 { | |||||
| best = ClassFSK | |||||
| conf = 0.45 | |||||
| } else if feat.InstFreqStd < 0.25 { | |||||
| best = ClassPSK | |||||
| conf = 0.45 | |||||
| } | } | ||||
| case bw < 150: | case bw < 150: | ||||
| best = ClassCW | best = ClassCW | ||||
| @@ -229,23 +229,75 @@ func (d *Detector) cfarThresholds(spectrum []float64) []float64 { | |||||
| } | } | ||||
| rankIdx := rank - 1 | rankIdx := rank - 1 | ||||
| thresholds := make([]float64, n) | thresholds := make([]float64, n) | ||||
| buf := make([]float64, totalTrain) | |||||
| buf := make([]float64, 0, totalTrain) | |||||
| firstValid := guard + train | |||||
| lastValid := n - guard - train - 1 | |||||
| for i := 0; i < n; i++ { | for i := 0; i < n; i++ { | ||||
| leftStart := i - guard - train | |||||
| rightEnd := i + guard + train | |||||
| if leftStart < 0 || rightEnd >= n { | |||||
| if i < firstValid || i > lastValid { | |||||
| thresholds[i] = math.NaN() | 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 | |||||
| } | |||||
| if firstValid > lastValid { | |||||
| return thresholds | |||||
| } | |||||
| // Build initial sorted window for first valid bin. | |||||
| leftStart := firstValid - guard - train | |||||
| buf = append(buf, spectrum[leftStart:leftStart+train]...) | |||||
| buf = append(buf, spectrum[firstValid+guard+1:firstValid+guard+1+train]...) | |||||
| sort.Float64s(buf) | |||||
| thresholds[firstValid] = buf[rankIdx] + d.CFARScaleDb | |||||
| // Slide window: remove outgoing bins and insert incoming bins (O(train) per step). | |||||
| for i := firstValid + 1; i <= lastValid; i++ { | |||||
| outLeft := spectrum[i-guard-train-1] | |||||
| outRight := spectrum[i+guard] | |||||
| inLeft := spectrum[i-guard-1] | |||||
| inRight := spectrum[i+guard+train] | |||||
| buf = removeValue(buf, outLeft) | |||||
| buf = removeValue(buf, outRight) | |||||
| buf = insertValue(buf, inLeft) | |||||
| buf = insertValue(buf, inRight) | |||||
| thresholds[i] = buf[rankIdx] + d.CFARScaleDb | |||||
| } | } | ||||
| return thresholds | return thresholds | ||||
| } | } | ||||
| func removeValue(sorted []float64, v float64) []float64 { | |||||
| if len(sorted) == 0 { | |||||
| return sorted | |||||
| } | |||||
| idx := sort.SearchFloat64s(sorted, v) | |||||
| if idx < len(sorted) && sorted[idx] == v { | |||||
| return append(sorted[:idx], sorted[idx+1:]...) | |||||
| } | |||||
| for i := idx - 1; i >= 0; i-- { | |||||
| if sorted[i] == v { | |||||
| return append(sorted[:i], sorted[i+1:]...) | |||||
| } | |||||
| if sorted[i] < v { | |||||
| break | |||||
| } | |||||
| } | |||||
| for i := idx + 1; i < len(sorted); i++ { | |||||
| if sorted[i] == v { | |||||
| return append(sorted[:i], sorted[i+1:]...) | |||||
| } | |||||
| if sorted[i] > v { | |||||
| break | |||||
| } | |||||
| } | |||||
| return sorted | |||||
| } | |||||
| func insertValue(sorted []float64, v float64) []float64 { | |||||
| idx := sort.SearchFloat64s(sorted, v) | |||||
| sorted = append(sorted, 0) | |||||
| copy(sorted[idx+1:], sorted[idx:]) | |||||
| sorted[idx] = v | |||||
| return sorted | |||||
| } | |||||
| func (d *Detector) smoothSpectrum(spectrum []float64) []float64 { | func (d *Detector) smoothSpectrum(spectrum []float64) []float64 { | ||||
| if d.ema == nil || len(d.ema) != len(spectrum) { | if d.ema == nil || len(d.ema) != len(spectrum) { | ||||
| d.ema = make([]float64, len(spectrum)) | d.ema = make([]float64, len(spectrum)) | ||||
| @@ -542,6 +542,44 @@ function drawSpectrumGrid(ctx, w, h, startHz, endHz) { | |||||
| } | } | ||||
| } | } | ||||
| function drawCfarEdgeOverlay(ctx, w, h, startHz, endHz) { | |||||
| if (!latest || !currentConfig?.detector?.cfar_enabled) return; | |||||
| const guard = currentConfig.detector.cfar_guard_cells ?? 0; | |||||
| const train = currentConfig.detector.cfar_train_cells ?? 0; | |||||
| const bins = guard + train; | |||||
| if (bins <= 0) return; | |||||
| const fftSize = latest.fft_size || latest.spectrum_db?.length; | |||||
| if (!fftSize || fftSize <= 0) return; | |||||
| const binHz = latest.sample_rate / fftSize; | |||||
| const edgeHz = bins * binHz; | |||||
| const bandStart = latest.center_hz - latest.sample_rate / 2; | |||||
| const bandEnd = latest.center_hz + latest.sample_rate / 2; | |||||
| const leftEdgeEnd = bandStart + edgeHz; | |||||
| const rightEdgeStart = bandEnd - edgeHz; | |||||
| ctx.fillStyle = 'rgba(255, 204, 102, 0.08)'; | |||||
| ctx.strokeStyle = 'rgba(255, 204, 102, 0.18)'; | |||||
| ctx.lineWidth = 1; | |||||
| const leftStart = Math.max(startHz, bandStart); | |||||
| const leftEnd = Math.min(endHz, leftEdgeEnd); | |||||
| if (leftEnd > leftStart) { | |||||
| const x1 = ((leftStart - startHz) / (endHz - startHz)) * w; | |||||
| const x2 = ((leftEnd - startHz) / (endHz - startHz)) * w; | |||||
| ctx.fillRect(x1, 0, Math.max(2, x2 - x1), h); | |||||
| ctx.strokeRect(x1, 0, Math.max(2, x2 - x1), h); | |||||
| } | |||||
| const rightStart = Math.max(startHz, rightEdgeStart); | |||||
| const rightEnd = Math.min(endHz, bandEnd); | |||||
| if (rightEnd > rightStart) { | |||||
| const x1 = ((rightStart - startHz) / (endHz - startHz)) * w; | |||||
| const x2 = ((rightEnd - startHz) / (endHz - startHz)) * w; | |||||
| ctx.fillRect(x1, 0, Math.max(2, x2 - x1), h); | |||||
| ctx.strokeRect(x1, 0, Math.max(2, x2 - x1), h); | |||||
| } | |||||
| } | |||||
| function renderSpectrum() { | function renderSpectrum() { | ||||
| if (!latest) return; | if (!latest) return; | ||||
| const ctx = spectrumCanvas.getContext('2d'); | const ctx = spectrumCanvas.getContext('2d'); | ||||
| @@ -558,6 +596,7 @@ function renderSpectrum() { | |||||
| spanInput.value = (span / 1e6).toFixed(3); | spanInput.value = (span / 1e6).toFixed(3); | ||||
| drawSpectrumGrid(ctx, w, h, startHz, endHz); | drawSpectrumGrid(ctx, w, h, startHz, endHz); | ||||
| drawCfarEdgeOverlay(ctx, w, h, startHz, endHz); | |||||
| const minDb = -120; | const minDb = -120; | ||||
| const maxDb = 0; | const maxDb = 0; | ||||