import { computeCaCfarThresholdMultiplier, computeOsCfarLogFalseAlarmProbability, computeOsCfarThresholdMultiplier, detectCaCfarFrame, detectCfarFrame, detectOsCfarFrame, } from "/packages/acoustics-core/cfar.js"; import { fftInPlace } from "/packages/acoustics-core/fft.js"; import { hannWindow } from "/packages/acoustics-core/stft.js"; function assertNear(actual, expected, tolerance, label) { assert( Math.abs(actual - expected) <= tolerance, `${label}: expected ${expected} ± ${tolerance}, received ${actual}`, ); } function assertRelativeNear(actual, expected, relativeTolerance, label) { const scale = Math.max(Number.MIN_VALUE, Math.abs(expected)); assertNear(actual, expected, relativeTolerance * scale, label); } function captureError(callback) { try { callback(); } catch (error) { return error; } return null; } function assertError(callback, ErrorType, messageFragment, label) { const error = captureError(callback); assert(error instanceof ErrorType, `${label}: expected ${ErrorType.name}`); assert( error.message.includes(messageFragment), `${label}: expected message containing ${messageFragment}, received ${error.message}`, ); } function createPowerFixture(fftSize, fill = 1) { return new Float64Array(fftSize / 2 + 1).fill(fill); } function createBaseOptions(overrides = {}) { const fftSize = overrides.fftSize ?? 64; return { powerSpectrum: overrides.powerSpectrum ?? createPowerFixture(fftSize), detector: "ca-cfar", sampleRate: 8192, fftSize, window: "hann", frameComplete: true, channelId: "mic-0", frameIndex: 0, frameStartSample: 0, temporalAverageCount: 1, microphoneAverageCount: 1, trainingCellsPerSide: 4, guardCellsPerSide: 2, nominalFalseAlarmProbability: 1e-3, ...overrides, }; } function createHannTonePower({ sampleRate, fftSize, frequencyHz, amplitude = 1, }) { const window = hannWindow(fftSize); const real = new Float64Array(fftSize); const imaginary = new Float64Array(fftSize); for (let sample = 0; sample < fftSize; sample += 1) { real[sample] = amplitude * Math.sin((2 * Math.PI * frequencyHz * sample) / sampleRate) * window[sample]; } fftInPlace(real, imaginary); return Float64Array.from( { length: fftSize / 2 + 1 }, (_, bin) => real[bin] * real[bin] + imaginary[bin] * imaginary[bin], ); } test("CA-CFAR mean-reference multiplier recovers the requested nominal per-CUT Pfa", () => { const trainingCellCount = 16; const nominalFalseAlarmProbability = 1e-3; const multiplier = computeCaCfarThresholdMultiplier({ trainingCellCount, nominalFalseAlarmProbability, }); const recoveredProbability = (1 + multiplier / trainingCellCount) ** -trainingCellCount; assertRelativeNear( recoveredProbability, nominalFalseAlarmProbability, 2e-14, "CA nominal false-alarm probability", ); const nearOneProbability = 1 - 1e-12; const nearOneMultiplier = computeCaCfarThresholdMultiplier({ trainingCellCount: 1024, nominalFalseAlarmProbability: nearOneProbability, }); assert( nearOneMultiplier > 0 && Number.isFinite(nearOneMultiplier), "expm1 form preserves a finite positive multiplier near Pfa=1", ); }); test("OS-CFAR log-product bisection recovers Pfa and the analytic rank-one case", () => { const trainingCellCount = 16; const rank = 12; const nominalFalseAlarmProbability = 1e-4; const multiplier = computeOsCfarThresholdMultiplier({ trainingCellCount, rank, nominalFalseAlarmProbability, }); const recoveredLogProbability = computeOsCfarLogFalseAlarmProbability({ trainingCellCount, rank, thresholdMultiplier: multiplier, }); assertNear( recoveredLogProbability, Math.log(nominalFalseAlarmProbability), 2e-12, "OS log false-alarm probability", ); const rankOneProbability = 0.25; const rankOneMultiplier = computeOsCfarThresholdMultiplier({ trainingCellCount, rank: 1, nominalFalseAlarmProbability: rankOneProbability, }); assertNear( rankOneMultiplier, trainingCellCount * (1 / rankOneProbability - 1), 1e-11, "rank-one OS multiplier", ); assertError( () => computeOsCfarThresholdMultiplier({ trainingCellCount, rank, nominalFalseAlarmProbability, maximumIterations: 1, }), RangeError, "did not converge", "explicit OS bisection iteration cap", ); }); test("CA-CFAR preserves every Hann-frame bin and detects an exact-bin tone", () => { const sampleRate = 8192; const fftSize = 256; const toneBin = 43; const powerSpectrum = createHannTonePower({ sampleRate, fftSize, frequencyHz: toneBin * sampleRate / fftSize, }); const result = detectCaCfarFrame(createBaseOptions({ powerSpectrum, sampleRate, fftSize, trainingCellsPerSide: 12, guardCellsPerSide: 3, })); const tone = result.bins[toneBin]; assertEqual(result.bins.length, fftSize / 2 + 1, "all one-sided bins retained"); assertEqual(tone.status, "detected", "exact-bin Hann tone detected"); assertEqual(tone.cutPower, powerSpectrum[toneBin], "raw CUT power retained"); assert(tone.referencePower >= 0, "raw reference statistic retained"); assert(tone.thresholdPower >= 0, "raw threshold retained"); assert(tone.thresholdRatio > 1, "raw threshold ratio exceeds one"); assertEqual( result.parameters.referenceStatistic, "arithmetic-mean-power", "CA mean normalization is explicit", ); assertEqual( result.calibration.calibratedFalseAlarmProbability, null, "nominal Pfa is not mislabeled as empirical calibration", ); assertEqual(result.processing.clustering, false, "no bin clustering"); assertEqual(result.processing.temporalPersistence, false, "no persistence"); assertEqual(result.processing.detectorFusion, false, "no CA/OS fusion"); }); test("full symmetric references exclude DC, Nyquist, and every insufficient edge bin", () => { const fftSize = 32; const result = detectCaCfarFrame(createBaseOptions({ fftSize, powerSpectrum: createPowerFixture(fftSize), trainingCellsPerSide: 2, guardCellsPerSide: 1, nominalFalseAlarmProbability: 0.1, })); assertEqual(result.bins[0].status, "untested", "DC status"); assertEqual(result.bins[0].reason, "real-only-dc-bin", "DC reason"); assertEqual(result.bins[16].status, "untested", "Nyquist status"); assertEqual( result.bins[16].reason, "real-only-nyquist-bin", "Nyquist reason", ); for (const bin of [1, 2, 3, 13, 14, 15]) { assertEqual(result.bins[bin].status, "untested", `edge bin ${bin}`); assertEqual( result.bins[bin].reason, "insufficient-full-symmetric-reference-window", `edge reason ${bin}`, ); assertEqual(result.bins[bin].cutPower, 1, `edge CUT power ${bin}`); } for (let bin = 4; bin <= 12; bin += 1) { assertEqual(result.bins[bin].status, "not-detected", `tested bin ${bin}`); assertEqual(result.bins[bin].referencePower, 1, `fixed-N mean ${bin}`); } assertEqual(result.parameters.trainingCellCount, 4, "N=2R"); assertEqual(result.parameters.firstTestedBin, 4, "first fully supported bin"); assertEqual(result.parameters.lastTestedBin, 12, "last fully supported bin"); assertEqual(result.parameters.testedBinCount, 9, "tested bin count"); }); test("CA uses a strict threshold comparison and does not promote a tie", () => { const fftSize = 8; const nominalFalseAlarmProbability = (1 + 1 / 2) ** -2; const result = detectCaCfarFrame(createBaseOptions({ fftSize, powerSpectrum: createPowerFixture(fftSize), trainingCellsPerSide: 1, guardCellsPerSide: 0, nominalFalseAlarmProbability, })); assertNear(result.parameters.thresholdMultiplier, 1, 1e-15, "unit alpha"); assertEqual(result.bins[2].cutPower, 1, "tie CUT"); assertNear(result.bins[2].thresholdPower, 1, 1e-15, "tie threshold"); assertEqual(result.bins[2].status, "not-detected", "strict greater-than rule"); }); test("zero thresholds preserve infinite-ratio meaning without non-JSON Infinity", () => { const fftSize = 16; const powerSpectrum = createPowerFixture(fftSize, 0); powerSpectrum[4] = 1; const result = detectCaCfarFrame(createBaseOptions({ fftSize, powerSpectrum, trainingCellsPerSide: 1, guardCellsPerSide: 0, nominalFalseAlarmProbability: 0.1, })); const detected = result.bins[4]; assertEqual(detected.status, "detected", "positive CUT exceeds zero threshold"); assertEqual(detected.thresholdPower, 0, "zero threshold retained"); assertEqual(detected.thresholdRatio, null, "non-finite ratio is JSON-safe null"); assertEqual( detected.thresholdRatioStatus, "positive-infinity-zero-threshold", "positive infinite limit remains explicit", ); assert( JSON.stringify(result).includes('"positive-infinity-zero-threshold"'), "JSON serialization preserves the ratio meaning", ); }); test("OS-CFAR remains a separate detector and resists one high training contaminant", () => { const fftSize = 128; const cutBin = 32; const powerSpectrum = createPowerFixture(fftSize); powerSpectrum[cutBin] = 100; powerSpectrum[23] = 1000; const common = createBaseOptions({ fftSize, powerSpectrum, trainingCellsPerSide: 8, guardCellsPerSide: 1, nominalFalseAlarmProbability: 0.1, }); const ca = detectCaCfarFrame(common); const os = detectOsCfarFrame({ ...common, rank: 12 }); assertEqual(ca.bins[cutBin].status, "not-detected", "CA masking exposed"); assertEqual(os.bins[cutBin].status, "detected", "OS order statistic result"); assertEqual(os.parameters.rank, 12, "explicit OS rank"); assertEqual(os.parameters.rankConvention, "1-based-ascending", "rank convention"); assertEqual(os.parameters.referencePower, undefined, "no invented fused reference"); assertEqual(os.processing.detectorFusion, false, "detectors remain separate"); }); test("a sampled above-Nyquist tone returns only its observed ADC bin", () => { const sampleRate = 8192; const fftSize = 256; const analogFrequencyHz = 6144; const expectedAdcBin = 64; const expectedAdcFrequencyHz = 2048; const powerSpectrum = createHannTonePower({ sampleRate, fftSize, frequencyHz: analogFrequencyHz, }); const result = detectOsCfarFrame(createBaseOptions({ powerSpectrum, sampleRate, fftSize, trainingCellsPerSide: 12, guardCellsPerSide: 3, rank: 18, })); const observed = result.bins[expectedAdcBin]; assertEqual(observed.status, "detected", "sampled alias detected"); assertNear( observed.adcCenterFrequencyHz, expectedAdcFrequencyHz, 0, "observed ADC center frequency", ); assertEqual( result.spectrum.analogFrequencyOrAliasOrderInferred, false, "no analog alias-order inference", ); assertEqual( Object.prototype.hasOwnProperty.call(observed, "analogFrequencyHz"), false, "bin result contains no invented analog frequency", ); assertEqual(result.bands, undefined, "no bins-to-band fusion"); }); test("CFAR rejects nonfinite power, invalid configuration, partial frames, and averaging", () => { const nonfinite = createPowerFixture(64); nonfinite[10] = Number.NaN; assertError( () => detectCaCfarFrame(createBaseOptions({ powerSpectrum: nonfinite })), TypeError, "powerSpectrum[10] must be finite", "NaN power", ); const negative = createPowerFixture(64); negative[10] = -1; assertError( () => detectCaCfarFrame(createBaseOptions({ powerSpectrum: negative })), RangeError, "must be non-negative", "negative power", ); assertError( () => detectCaCfarFrame(createBaseOptions({ powerSpectrum: new Float64Array(32), })), RangeError, "exactly 33", "wrong one-sided shape", ); for (const probability of [0, 1, Number.NaN]) { assertError( () => computeCaCfarThresholdMultiplier({ trainingCellCount: 8, nominalFalseAlarmProbability: probability, }), RangeError, "strictly inside 0..1", `invalid Pfa ${probability}`, ); } assertError( () => detectOsCfarFrame(createBaseOptions({ rank: 0 })), RangeError, "1-based ascending", "zero OS rank", ); assertError( () => detectOsCfarFrame(createBaseOptions({ rank: 9 })), RangeError, "inside 1..8", "OS rank above N", ); assertError( () => detectCaCfarFrame(createBaseOptions({ trainingCellsPerSide: 0 })), TypeError, "trainingCellsPerSide must be a positive safe integer", "zero training cells", ); assertError( () => detectCaCfarFrame(createBaseOptions({ guardCellsPerSide: -1 })), TypeError, "guardCellsPerSide must be a non-negative safe integer", "negative guard cells", ); assertError( () => detectCaCfarFrame(createBaseOptions({ trainingCellsPerSide: 16, guardCellsPerSide: 0, })), RangeError, "leave no interior bin", "reference window larger than the usable spectrum", ); assertError( () => detectCfarFrame(createBaseOptions({ window: "rectangular" })), RangeError, 'window must be "hann"', "non-Hann window", ); assertError( () => detectCaCfarFrame(createBaseOptions({ frameComplete: false })), RangeError, "partial frames are not tested", "partial frame", ); assertError( () => detectCaCfarFrame(createBaseOptions({ temporalAverageCount: 2 })), RangeError, "temporalAverageCount must equal one", "temporal averaging", ); assertError( () => detectCaCfarFrame(createBaseOptions({ microphoneAverageCount: 2 })), RangeError, "microphoneAverageCount must equal one", "microphone averaging", ); }); test("OS-CFAR is deterministic and sorting never mutates the caller spectrum", () => { const fftSize = 128; const powerSpectrum = Float64Array.from( { length: fftSize / 2 + 1 }, (_, bin) => 1 + ((bin * 17) % 23) / 11, ); powerSpectrum[28] = 40; powerSpectrum[41] = 75; const before = Float64Array.from(powerSpectrum); const options = createBaseOptions({ fftSize, powerSpectrum, trainingCellsPerSide: 8, guardCellsPerSide: 2, rank: 12, }); const first = detectOsCfarFrame(options); const second = detectOsCfarFrame(options); assertEqual(JSON.stringify(second), JSON.stringify(first), "repeatable result"); assertEqual(powerSpectrum.length, before.length, "input length retained"); for (let bin = 0; bin < before.length; bin += 1) { assertEqual(powerSpectrum[bin], before[bin], `input power ${bin}`); } });