test: add coverage for SpectrumAnomalyDetector
Pure rolling-floor/spike-detection logic in sdr.ts had zero tests, unlike its EVP/EMF detector siblings. No production code changed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013PphXq1s43DNRj1uWKGXof
This commit is contained in:
168
frontend/src/lib/sdr.test.ts
Normal file
168
frontend/src/lib/sdr.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SpectrumAnomalyDetector } from './sdr'
|
||||
|
||||
const SAMPLE_RATE = 2_000_000
|
||||
const BINS = 8
|
||||
const CENTER_HZ = 100_000_000
|
||||
const BIN_HZ = SAMPLE_RATE / 2 / BINS // 125,000 Hz per bin
|
||||
|
||||
function flatSpectrum(db = -80, bins = BINS): Float64Array {
|
||||
return new Float64Array(bins).fill(db)
|
||||
}
|
||||
|
||||
function spikedSpectrum(bin: number, spikeDb: number, baseDb = -80, bins = BINS): Float64Array {
|
||||
const frame = flatSpectrum(baseDb, bins)
|
||||
frame[bin] = spikeDb
|
||||
return frame
|
||||
}
|
||||
|
||||
function makeDetector(thresholdDb?: number, throttleMs?: number, alpha?: number): SpectrumAnomalyDetector {
|
||||
return new SpectrumAnomalyDetector(thresholdDb, throttleMs, alpha)
|
||||
}
|
||||
|
||||
describe('SpectrumAnomalyDetector', () => {
|
||||
it('seeds the rolling floor from the first sweep and stays silent over a flat spectrum', () => {
|
||||
const d = makeDetector()
|
||||
expect(d.process(CENTER_HZ, SAMPLE_RATE, flatSpectrum(), 0)).toBeNull() // first sweep only seeds
|
||||
|
||||
for (let t = 1; t <= 50; t++) {
|
||||
expect(d.process(CENTER_HZ, SAMPLE_RATE, flatSpectrum(), t * 100)).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('fires exactly one anomaly for a brief spike, with sane values', () => {
|
||||
const d = makeDetector()
|
||||
d.process(CENTER_HZ, SAMPLE_RATE, flatSpectrum(), 0)
|
||||
for (let t = 1; t <= 20; t++) d.process(CENTER_HZ, SAMPLE_RATE, flatSpectrum(), t * 100)
|
||||
|
||||
const bin = 6 // above center: offset = (6 - 4) * 125,000 = +250,000 Hz
|
||||
const anomaly = d.process(CENTER_HZ, SAMPLE_RATE, spikedSpectrum(bin, -50), 5000) // 30 dB over floor
|
||||
expect(anomaly).not.toBeNull()
|
||||
expect(anomaly!.frequency).toBeCloseTo((CENTER_HZ + (bin - BINS / 2) * BIN_HZ) / 1e6, 6)
|
||||
expect(anomaly!.magnitude).toBeCloseTo(30, 6)
|
||||
|
||||
// The spike is gone on the next sweep — no further anomaly.
|
||||
expect(d.process(CENTER_HZ, SAMPLE_RATE, flatSpectrum(), 5100)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not fire on fluctuation that stays below the spike threshold', () => {
|
||||
const d = makeDetector() // default thresholdDb = 10
|
||||
d.process(CENTER_HZ, SAMPLE_RATE, flatSpectrum(), 0)
|
||||
|
||||
for (let t = 1; t <= 20; t++) {
|
||||
const db = -80 + (t % 2 === 0 ? 8 : -8) // +-8 dB ripple, under the 10 dB threshold
|
||||
expect(d.process(CENTER_HZ, SAMPLE_RATE, spikedSpectrum(3, db), t * 100)).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('throttles repeats inside the throttle window and refires after it', () => {
|
||||
const d = makeDetector()
|
||||
d.process(CENTER_HZ, SAMPLE_RATE, flatSpectrum(), 0)
|
||||
const bin = 4
|
||||
|
||||
expect(d.process(CENTER_HZ, SAMPLE_RATE, spikedSpectrum(bin, -60), 1000)).not.toBeNull() // 20 dB, fires
|
||||
// Same spike 500 ms later: swallowed by the 2 s default throttle.
|
||||
expect(d.process(CENTER_HZ, SAMPLE_RATE, spikedSpectrum(bin, -60), 1500)).toBeNull()
|
||||
// Past the window it fires again.
|
||||
expect(d.process(CENTER_HZ, SAMPLE_RATE, spikedSpectrum(bin, -60), 3001)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('fires at most once per throttle window under a sustained spike', () => {
|
||||
const d = makeDetector()
|
||||
d.process(CENTER_HZ, SAMPLE_RATE, flatSpectrum(), 0)
|
||||
const bin = 2
|
||||
|
||||
let fired = 0
|
||||
// Sustained spike for 0.9 s at 100 ms intervals — inside one 2 s window.
|
||||
for (let t = 1000; t <= 1900; t += 100) {
|
||||
if (d.process(CENTER_HZ, SAMPLE_RATE, spikedSpectrum(bin, -60), t)) fired++
|
||||
}
|
||||
expect(fired).toBe(1)
|
||||
})
|
||||
|
||||
it('tracks a slow ramp into the floor instead of firing false anomalies', () => {
|
||||
const d = makeDetector()
|
||||
let level = -80
|
||||
d.process(CENTER_HZ, SAMPLE_RATE, flatSpectrum(level), 0)
|
||||
|
||||
// 5 dB of sustained rise, but gradual (0.05 dB per sweep): the rolling
|
||||
// floor tracks it, so the deviation never crosses the 10 dB threshold.
|
||||
for (let t = 1; t <= 100; t++) {
|
||||
level += 0.05
|
||||
expect(d.process(CENTER_HZ, SAMPLE_RATE, flatSpectrum(level), t * 100)).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('reset() re-arms the floor and the throttle clock', () => {
|
||||
const d = makeDetector()
|
||||
d.process(CENTER_HZ, SAMPLE_RATE, flatSpectrum(), 0)
|
||||
const bin = 5
|
||||
expect(d.process(CENTER_HZ, SAMPLE_RATE, spikedSpectrum(bin, -60), 1000)).not.toBeNull()
|
||||
// Would be throttled without a reset…
|
||||
expect(d.process(CENTER_HZ, SAMPLE_RATE, spikedSpectrum(bin, -60), 1100)).toBeNull()
|
||||
|
||||
d.reset()
|
||||
// …so the next spike fires immediately after the floor re-seeds.
|
||||
expect(d.process(CENTER_HZ, SAMPLE_RATE, flatSpectrum(), 1200)).toBeNull() // re-seeds floor
|
||||
const anomaly = d.process(CENTER_HZ, SAMPLE_RATE, spikedSpectrum(bin, -60), 1300)
|
||||
expect(anomaly).not.toBeNull()
|
||||
expect(anomaly!.magnitude).toBeCloseTo(20, 6)
|
||||
})
|
||||
|
||||
it('respects custom threshold, throttle, and floor-adaptation rate', () => {
|
||||
const d = makeDetector(15, 500, 0.2)
|
||||
d.process(CENTER_HZ, SAMPLE_RATE, flatSpectrum(), 0)
|
||||
const bin = 1
|
||||
|
||||
// 10 dB is below the custom 15 dB threshold.
|
||||
expect(d.process(CENTER_HZ, SAMPLE_RATE, spikedSpectrum(bin, -70), 1000)).toBeNull()
|
||||
// 30 dB clears it and fires.
|
||||
expect(d.process(CENTER_HZ, SAMPLE_RATE, spikedSpectrum(bin, -50), 2000)).not.toBeNull()
|
||||
// 200 ms later: swallowed by the custom 500 ms throttle.
|
||||
expect(d.process(CENTER_HZ, SAMPLE_RATE, spikedSpectrum(bin, -50), 2200)).toBeNull()
|
||||
// Past the custom throttle window it fires again.
|
||||
expect(d.process(CENTER_HZ, SAMPLE_RATE, spikedSpectrum(bin, -50), 2600)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('re-seeds instead of crashing when the spectrum size changes mid-stream', () => {
|
||||
const d = makeDetector()
|
||||
d.process(CENTER_HZ, SAMPLE_RATE, flatSpectrum(-80, 8), 0)
|
||||
d.process(CENTER_HZ, SAMPLE_RATE, spikedSpectrum(4, -50, -80, 8), 1000)
|
||||
|
||||
// FFT size changed (e.g. a sweep restarted with a different fftSize):
|
||||
// must silently re-seed rather than index out of the old, shorter floor.
|
||||
expect(d.process(CENTER_HZ, SAMPLE_RATE, flatSpectrum(-80, 16), 2000)).toBeNull()
|
||||
expect(d.process(CENTER_HZ, SAMPLE_RATE, flatSpectrum(-80, 16), 2100)).toBeNull()
|
||||
})
|
||||
|
||||
it('handles an empty spectrum gracefully', () => {
|
||||
const d = makeDetector()
|
||||
expect(d.process(CENTER_HZ, SAMPLE_RATE, new Float64Array(0), 0)).toBeNull()
|
||||
expect(d.process(CENTER_HZ, SAMPLE_RATE, new Float64Array(0), 100)).toBeNull()
|
||||
})
|
||||
|
||||
it('contains a NaN bin without crashing or blocking spikes elsewhere in the spectrum', () => {
|
||||
const d = makeDetector()
|
||||
d.process(CENTER_HZ, SAMPLE_RATE, flatSpectrum(), 0)
|
||||
|
||||
// A single garbage bin (e.g. a dropped FFT sample) must not throw.
|
||||
expect(d.process(CENTER_HZ, SAMPLE_RATE, spikedSpectrum(1, NaN), 1000)).toBeNull()
|
||||
|
||||
// A genuine spike at a different, clean bin still fires with finite values.
|
||||
const anomaly = d.process(CENTER_HZ, SAMPLE_RATE, spikedSpectrum(6, -50), 4000)
|
||||
expect(anomaly).not.toBeNull()
|
||||
expect(Number.isFinite(anomaly!.frequency)).toBe(true)
|
||||
expect(Number.isFinite(anomaly!.magnitude)).toBe(true)
|
||||
expect(anomaly!.magnitude).toBeCloseTo(30, 6)
|
||||
})
|
||||
|
||||
it('never throws when the whole spectrum is NaN, and stays silent', () => {
|
||||
const d = makeDetector()
|
||||
const nanSpectrum = new Float64Array(BINS).fill(NaN)
|
||||
expect(() => d.process(CENTER_HZ, SAMPLE_RATE, nanSpectrum, 0)).not.toThrow()
|
||||
|
||||
for (let t = 1; t <= 10; t++) {
|
||||
expect(d.process(CENTER_HZ, SAMPLE_RATE, nanSpectrum, t * 1000)).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user