feat: add physical receive beam pattern core
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
import {
|
||||
computeReceiveBeamPattern,
|
||||
receiveBeamPatternDirection,
|
||||
} from "/packages/acoustics-core/beam-pattern.js";
|
||||
|
||||
function assertNear(actual, expected, tolerance, label) {
|
||||
assert(
|
||||
Math.abs(actual - expected) <= tolerance,
|
||||
label + ": expected " + expected + " ± " + tolerance + ", received " + actual,
|
||||
);
|
||||
}
|
||||
|
||||
function arrayFromPositions(positions) {
|
||||
return {
|
||||
microphones: positions.map((position, index) => ({
|
||||
id: "mic-" + String(index + 1),
|
||||
position,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function sampleAtAngle(pattern, angleDeg) {
|
||||
let bestIndex = 0;
|
||||
let bestDistance = Infinity;
|
||||
for (let index = 0; index < pattern.anglesDeg.length; index += 1) {
|
||||
const delta = Math.abs(pattern.anglesDeg[index] - angleDeg);
|
||||
const circularDistance = Math.min(delta, 360 - delta);
|
||||
if (circularDistance < bestDistance) {
|
||||
bestDistance = circularDistance;
|
||||
bestIndex = index;
|
||||
}
|
||||
}
|
||||
assert(bestDistance < 1e-10, "requested angle is present in sampled cut");
|
||||
return pattern.responseLinear[bestIndex];
|
||||
}
|
||||
|
||||
test("receive beam-pattern cuts use the documented right-handed axes", () => {
|
||||
const cases = [
|
||||
["xy", 0, [1, 0, 0]],
|
||||
["xy", 90, [0, 1, 0]],
|
||||
["xz", 0, [0, 0, 1]],
|
||||
["xz", 90, [1, 0, 0]],
|
||||
["yz", 0, [0, 0, 1]],
|
||||
["yz", 90, [0, 1, 0]],
|
||||
];
|
||||
for (const [cut, angleDeg, expected] of cases) {
|
||||
const actual = receiveBeamPatternDirection(cut, angleDeg);
|
||||
actual.forEach((value, axis) =>
|
||||
assertNear(value, expected[axis], 1e-12, cut + " direction axis " + axis));
|
||||
}
|
||||
});
|
||||
|
||||
test("one ideal point microphone is omnidirectional on every sampled cut", () => {
|
||||
const pattern = computeReceiveBeamPattern({
|
||||
array: arrayFromPositions([[12.5, -7, 3]]),
|
||||
frequencyHz: 49_125,
|
||||
speedOfSound: 343,
|
||||
cut: "yz",
|
||||
steeringAngleDeg: -35,
|
||||
sampleCount: 360,
|
||||
});
|
||||
for (const response of pattern.responseLinear) {
|
||||
assertNear(response, 1, 1e-12, "single-microphone response");
|
||||
}
|
||||
assertEqual(
|
||||
pattern.metrics.halfPowerBeamwidth.status,
|
||||
"full-circle-above-half-power",
|
||||
"flat cut status",
|
||||
);
|
||||
assertEqual(pattern.metrics.halfPowerBeamwidth.widthDeg, null, "flat cut has no HPBW");
|
||||
assert(
|
||||
pattern.metrics.halfPowerBeamwidth.fullCircleAboveHalfPower,
|
||||
"flat cut records the missing crossing",
|
||||
);
|
||||
assertEqual(
|
||||
pattern.metrics.sampledLocalMaxima.peaks.length,
|
||||
0,
|
||||
"flat cut has no unique peaks",
|
||||
);
|
||||
});
|
||||
|
||||
test("half-wavelength two-microphone broadside cut has nulls, 60-degree HPBW, and front-back tie", () => {
|
||||
const speedOfSound = 343;
|
||||
const frequencyHz = 1_000;
|
||||
const spacing = speedOfSound / frequencyHz / 2;
|
||||
const pattern = computeReceiveBeamPattern({
|
||||
array: arrayFromPositions([[-spacing / 2, 0, 0], [spacing / 2, 0, 0]]),
|
||||
frequencyHz,
|
||||
speedOfSound,
|
||||
cut: "xz",
|
||||
steeringAngleDeg: 0,
|
||||
sampleCount: 1_440,
|
||||
});
|
||||
assertNear(sampleAtAngle(pattern, 90), 0, 1e-12, "endfire null at +x");
|
||||
assertNear(sampleAtAngle(pattern, 270), 0, 1e-12, "endfire null at -x");
|
||||
assertNear(sampleAtAngle(pattern, 0), 1, 1e-12, "steered broadside response");
|
||||
assertNear(sampleAtAngle(pattern, 180), 1, 1e-12, "planar front-back response");
|
||||
assertEqual(pattern.metrics.halfPowerBeamwidth.status, "bounded", "HPBW status");
|
||||
assertNear(pattern.metrics.halfPowerBeamwidth.widthDeg, 60, 1e-10, "power HPBW");
|
||||
const zeroDbAngles = pattern.metrics.sampledLocalMaxima.peaks
|
||||
.filter((peak) => Math.abs(peak.responseDb) < 1e-10)
|
||||
.map((peak) => peak.angleDeg)
|
||||
.sort((left, right) => left - right);
|
||||
assertEqual(zeroDbAngles.join(","), "0,180", "front-back maxima remain tied");
|
||||
assertNear(pattern.responseReal[Math.floor(pattern.sampleCount / 2)], 1, 1e-12, "raw real");
|
||||
assertNear(pattern.responseImaginary[Math.floor(pattern.sampleCount / 2)], 0, 1e-12, "raw imaginary");
|
||||
assertNear(pattern.responsePower[Math.floor(pattern.sampleCount / 2)], 1, 1e-12, "raw power");
|
||||
});
|
||||
|
||||
test("asymmetric array preserves the receive steering sign in raw complex response", () => {
|
||||
const speedOfSound = 343;
|
||||
const frequencyHz = 1_000;
|
||||
const quarterWavelength = speedOfSound / frequencyHz / 4;
|
||||
const pattern = computeReceiveBeamPattern({
|
||||
array: arrayFromPositions([
|
||||
[0, 0, 0],
|
||||
[quarterWavelength, 0, 0],
|
||||
[3 * quarterWavelength, 0, 0],
|
||||
]),
|
||||
frequencyHz,
|
||||
speedOfSound,
|
||||
cut: "xz",
|
||||
steeringAngleDeg: 0,
|
||||
sampleCount: 360,
|
||||
});
|
||||
const lookIndex = [...pattern.anglesDeg].findIndex((angle) => angle === 90);
|
||||
assert(lookIndex >= 0, "+x look direction is sampled");
|
||||
assertNear(pattern.responseReal[lookIndex], -1 / 6, 1e-12, "signed real response");
|
||||
assertNear(
|
||||
pattern.responseImaginary[lookIndex],
|
||||
-Math.sqrt(3) / 6,
|
||||
1e-12,
|
||||
"signed imaginary response",
|
||||
);
|
||||
});
|
||||
|
||||
test("display dB floor never changes raw array factor or physical metrics", () => {
|
||||
const array = arrayFromPositions([[-0.08575, 0, 0], [0.08575, 0, 0]]);
|
||||
const shared = {
|
||||
array,
|
||||
frequencyHz: 1_000,
|
||||
speedOfSound: 343,
|
||||
cut: "xz",
|
||||
steeringAngleDeg: 0,
|
||||
sampleCount: 720,
|
||||
};
|
||||
const shallow = computeReceiveBeamPattern({ ...shared, dbFloor: -30 });
|
||||
const deep = computeReceiveBeamPattern({ ...shared, dbFloor: -100 });
|
||||
for (let index = 0; index < shallow.sampleCount; index += 1) {
|
||||
assertNear(
|
||||
shallow.responsePower[index],
|
||||
deep.responsePower[index],
|
||||
0,
|
||||
"raw power is floor-independent at " + index,
|
||||
);
|
||||
}
|
||||
assertNear(
|
||||
shallow.metrics.halfPowerBeamwidth.widthDeg,
|
||||
deep.metrics.halfPowerBeamwidth.widthDeg,
|
||||
0,
|
||||
"HPBW is floor-independent",
|
||||
);
|
||||
const nullIndex = [...shallow.anglesDeg].findIndex((angle) => angle === 90);
|
||||
assertEqual(shallow.displayDb[nullIndex], -30, "shallow display floor");
|
||||
assertEqual(deep.displayDb[nullIndex], -100, "deep display floor");
|
||||
assert(
|
||||
shallow.responseDb[nullIndex] < -300,
|
||||
"raw null remains below both display floors",
|
||||
);
|
||||
});
|
||||
|
||||
test("receive array factor is invariant to translating every microphone", () => {
|
||||
const basePositions = [[-0.12, 0.03, 0], [0.04, -0.08, 0.02], [0.15, 0.07, -0.01]];
|
||||
const translation = [1_000, -2_000, 500];
|
||||
const translatedPositions = basePositions.map((position) =>
|
||||
position.map((coordinate, axis) => coordinate + translation[axis]));
|
||||
const options = {
|
||||
frequencyHz: 7_500,
|
||||
speedOfSound: 343,
|
||||
cut: "xy",
|
||||
steeringAngleDeg: 23,
|
||||
sampleCount: 720,
|
||||
};
|
||||
const base = computeReceiveBeamPattern({ ...options, array: arrayFromPositions(basePositions) });
|
||||
const translated = computeReceiveBeamPattern({
|
||||
...options,
|
||||
array: arrayFromPositions(translatedPositions),
|
||||
});
|
||||
for (let index = 0; index < base.sampleCount; index += 1) {
|
||||
assertNear(
|
||||
translated.responseLinear[index],
|
||||
base.responseLinear[index],
|
||||
1e-11,
|
||||
"translation-invariant sample " + index,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("beam pattern uses analog frequency directly without Nyquist folding", () => {
|
||||
const highFrequencyHz = 49_125;
|
||||
const speedOfSound = 343;
|
||||
const halfHighWavelength = speedOfSound / (2 * highFrequencyHz);
|
||||
const array = arrayFromPositions([[0, 0, 0], [halfHighWavelength, 0, 0]]);
|
||||
const highAnalog = computeReceiveBeamPattern({
|
||||
array,
|
||||
frequencyHz: highFrequencyHz,
|
||||
speedOfSound,
|
||||
cut: "xy",
|
||||
steeringAngleDeg: 90,
|
||||
sampleCount: 360,
|
||||
});
|
||||
const foldedTemporalAlias = computeReceiveBeamPattern({
|
||||
array,
|
||||
frequencyHz: 1_125,
|
||||
speedOfSound,
|
||||
cut: "xy",
|
||||
steeringAngleDeg: 90,
|
||||
sampleCount: 360,
|
||||
});
|
||||
assertEqual(highAnalog.frequencyHz, highFrequencyHz, "analog hypothesis is preserved");
|
||||
assertNear(
|
||||
highAnalog.wavelengthM,
|
||||
speedOfSound / highFrequencyHz,
|
||||
1e-15,
|
||||
"analog wavelength",
|
||||
);
|
||||
assert(
|
||||
Math.abs(sampleAtAngle(highAnalog, 0) - sampleAtAngle(foldedTemporalAlias, 0)) > 0.1,
|
||||
"spatial pattern is not computed from the folded ADC alias",
|
||||
);
|
||||
});
|
||||
|
||||
test("coarse high-frequency cuts preserve raw samples but withhold unresolved metrics", () => {
|
||||
const pattern = computeReceiveBeamPattern({
|
||||
array: arrayFromPositions([[-0.5, 0, 0], [0.5, 0, 0]]),
|
||||
frequencyHz: 100_000,
|
||||
speedOfSound: 343,
|
||||
cut: "xz",
|
||||
steeringAngleDeg: 0,
|
||||
sampleCount: 360,
|
||||
});
|
||||
assert(!pattern.angularSampling.metricResolutionSatisfied, "phase-step audit fails");
|
||||
assert(
|
||||
pattern.angularSampling.maximumAdjacentPhaseStepRadBound >
|
||||
pattern.angularSampling.metricPhaseStepLimitRad,
|
||||
"result exposes the physical phase-step bound",
|
||||
);
|
||||
assertEqual(
|
||||
pattern.metrics.halfPowerBeamwidth.status,
|
||||
"resolution-unresolved",
|
||||
"HPBW is withheld",
|
||||
);
|
||||
assertEqual(
|
||||
pattern.metrics.sampledLocalMaxima.status,
|
||||
"resolution-unresolved",
|
||||
"peak interpretation is withheld",
|
||||
);
|
||||
assertEqual(pattern.responsePower.length, 360, "raw sampled power remains available");
|
||||
});
|
||||
|
||||
test("auto angular sampling increases resolution and reports when the safety cap is insufficient", () => {
|
||||
const moderate = computeReceiveBeamPattern({
|
||||
array: arrayFromPositions([[-0.225, 0, 0], [0.225, 0, 0]]),
|
||||
frequencyHz: 49_125,
|
||||
speedOfSound: 343,
|
||||
cut: "xz",
|
||||
steeringAngleDeg: 0,
|
||||
sampleCount: "auto",
|
||||
});
|
||||
assert(moderate.sampleCount > 720, "auto mode increases angular samples");
|
||||
assert(moderate.angularSampling.metricResolutionSatisfied, "moderate case becomes resolved");
|
||||
assert(!moderate.angularSampling.autoCapped, "moderate case fits the cap");
|
||||
|
||||
const extreme = computeReceiveBeamPattern({
|
||||
array: arrayFromPositions([[-10, 0, 0], [10, 0, 0]]),
|
||||
frequencyHz: 1_000_000,
|
||||
speedOfSound: 343,
|
||||
cut: "xz",
|
||||
steeringAngleDeg: 0,
|
||||
sampleCount: "auto",
|
||||
});
|
||||
assertEqual(extreme.sampleCount, 14_400, "auto mode obeys the angular cap");
|
||||
assert(extreme.angularSampling.autoCapped, "cap is explicit");
|
||||
assert(!extreme.angularSampling.metricResolutionSatisfied, "extreme metric remains unresolved");
|
||||
});
|
||||
|
||||
test("beam-pattern preflight rejects invalid input and excessive work", () => {
|
||||
const array = arrayFromPositions([[0, 0, 0], [0.1, 0, 0]]);
|
||||
let frequencyError = null;
|
||||
try {
|
||||
computeReceiveBeamPattern({ array, frequencyHz: 0 });
|
||||
} catch (error) {
|
||||
frequencyError = error;
|
||||
}
|
||||
assert(frequencyError instanceof RangeError, "zero analog frequency is rejected");
|
||||
|
||||
let workError = null;
|
||||
try {
|
||||
computeReceiveBeamPattern({
|
||||
array,
|
||||
frequencyHz: 1_000,
|
||||
sampleCount: 360,
|
||||
maximumEvaluations: 911,
|
||||
});
|
||||
} catch (error) {
|
||||
workError = error;
|
||||
}
|
||||
assert(workError instanceof RangeError, "work cap is checked before evaluation");
|
||||
assert(workError.message.includes("912"), "error reports maximum evaluation count");
|
||||
|
||||
let microphoneLimitError = null;
|
||||
try {
|
||||
computeReceiveBeamPattern({
|
||||
array: arrayFromPositions(
|
||||
Array.from({ length: 1_025 }, (_, index) => [index / 1000, 0, 0]),
|
||||
),
|
||||
frequencyHz: 1_000,
|
||||
});
|
||||
} catch (error) {
|
||||
microphoneLimitError = error;
|
||||
}
|
||||
assert(microphoneLimitError instanceof RangeError, "microphone cap precedes pairwise audit");
|
||||
assert(
|
||||
microphoneLimitError.message.includes("1024"),
|
||||
"microphone cap is explicit",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user