466 lines
14 KiB
JavaScript
466 lines
14 KiB
JavaScript
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}`);
|
|
}
|
|
});
|