diff --git a/frontend/src/lib/sdr.test.ts b/frontend/src/lib/sdr.test.ts new file mode 100644 index 0000000..728c2fa --- /dev/null +++ b/frontend/src/lib/sdr.test.ts @@ -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() + } + }) +})