feat: add physical 3d receive pattern core

This commit is contained in:
2026-08-30 10:22:13 +09:00
parent 6804eca6ed
commit dda72739a9
2 changed files with 886 additions and 0 deletions
+479
View File
@@ -1,6 +1,8 @@
import {
computeReceiveBeamPattern,
computeReceiveBeamPatternSphere,
receiveBeamPatternDirection,
receiveBeamPatternSphereDirection,
} from "/packages/acoustics-core/beam-pattern.js";
function assertNear(actual, expected, tolerance, label) {
@@ -413,3 +415,480 @@ test("co-located pattern accepts an exact sample-only evaluation cap", () => {
}
assert(error instanceof RangeError, "strict cap rejects one fewer evaluation");
});
function sphereFlatIndex(pattern, azimuthDeg, elevationDeg) {
let azimuthIndex = -1;
let elevationIndex = -1;
for (let index = 0; index < pattern.azimuthAxisDeg.length; index += 1) {
if (Math.abs(pattern.azimuthAxisDeg[index] - azimuthDeg) < 1e-10) {
azimuthIndex = index;
break;
}
}
for (let index = 0; index < pattern.elevationAxisDeg.length; index += 1) {
if (Math.abs(pattern.elevationAxisDeg[index] - elevationDeg) < 1e-10) {
elevationIndex = index;
break;
}
}
assert(azimuthIndex >= 0, "requested azimuth is present on sphere grid");
assert(elevationIndex >= 0, "requested elevation is present on sphere grid");
return elevationIndex * pattern.azimuthSampleCount + azimuthIndex;
}
test("sphere directions use standard azimuth and elevation coordinates", () => {
const cases = [
[0, 0, [1, 0, 0]],
[90, 0, [0, 1, 0]],
[180, 0, [-1, 0, 0]],
[270, 0, [0, -1, 0]],
[37, 90, [0, 0, 1]],
[241, -90, [0, 0, -1]],
];
for (const [azimuthDeg, elevationDeg, expected] of cases) {
const direction = receiveBeamPatternSphereDirection(
azimuthDeg,
elevationDeg,
);
direction.forEach((value, axis) =>
assertNear(
value,
expected[axis],
1e-12,
"sphere direction axis " + axis,
));
}
const diagonal = receiveBeamPatternSphereDirection(45, 30);
assertNear(diagonal[0], Math.sqrt(6) / 4, 1e-12, "diagonal x");
assertNear(diagonal[1], Math.sqrt(6) / 4, 1e-12, "diagonal y");
assertNear(diagonal[2], 0.5, 1e-12, "diagonal z");
});
test("one microphone returns an unshaped raw full-sphere grid", () => {
const pattern = computeReceiveBeamPatternSphere({
array: arrayFromPositions([[12.5, -7, 3]]),
frequencyHz: 49_125,
speedOfSound: 343,
azimuthSampleCount: 12,
elevationSampleCount: 7,
maximumEvaluations: 84,
});
assertEqual(pattern.steeringAzimuthDeg, 0, "default sphere steering azimuth");
assertEqual(pattern.steeringElevationDeg, 90, "default sphere steering is +Z");
assertEqual(pattern.steeringDirection.join(","), "0,0,1", "default direction");
assertEqual(pattern.gridShape.join(","), "7,12", "top-level grid shape");
assertEqual(
pattern.flatIndexing,
"elevation-major-row-major",
"top-level flat indexing",
);
assertEqual(pattern.grid.shape.join(","), "7,12", "elevation-major shape");
assertEqual(pattern.microphoneCount, 1, "sphere microphone provenance");
assertEqual(pattern.sampleCount, 84, "sphere sample product");
assertEqual(pattern.evaluationCount, 84, "exact sphere evaluation count");
assertEqual(pattern.responsePower.length, 84, "raw output sample count");
assertEqual(pattern.azimuthAxisDeg.at(-1), 330, "360 endpoint is not duplicated");
assertEqual(pattern.elevationAxisDeg[0], -90, "south pole is included");
assertEqual(pattern.elevationAxisDeg.at(-1), 90, "north pole is included");
assertEqual(pattern.grid.interpolation, "none", "no interpolation");
assertEqual(pattern.grid.smoothing, "none", "no smoothing");
assert(
!pattern.grid.sampledMaximumNormalization,
"sampled maximum normalization is disabled",
);
assert(pattern.angularSampling.resolutionCriterionSatisfied, "zero aperture resolves");
for (let index = 0; index < pattern.sampleCount; index += 1) {
assertNear(pattern.responseReal[index], 1, 0, "single-mic raw real");
assertNear(pattern.responseImaginary[index], 0, 0, "single-mic raw imaginary");
assertNear(pattern.responsePower[index], 1, 0, "single-mic raw power");
assertNear(pattern.responseLinear[index], 1, 0, "single-mic raw magnitude");
assertNear(pattern.responseDb[index], 0, 0, "single-mic raw dB");
assertNear(pattern.displayDb[index], 0, 0, "single-mic display dB");
}
});
test("half-wavelength X baseline has the expected three-dimensional null surface", () => {
const speedOfSound = 343;
const frequencyHz = 1_000;
const spacing = speedOfSound / frequencyHz / 2;
const pattern = computeReceiveBeamPatternSphere({
array: arrayFromPositions([[-spacing / 2, 0, 0], [spacing / 2, 0, 0]]),
frequencyHz,
speedOfSound,
azimuthSampleCount: 72,
elevationSampleCount: 37,
});
const positiveX = sphereFlatIndex(pattern, 0, 0);
const positiveY = sphereFlatIndex(pattern, 90, 0);
const negativeX = sphereFlatIndex(pattern, 180, 0);
const northPole = sphereFlatIndex(pattern, 0, 90);
const southPole = sphereFlatIndex(pattern, 0, -90);
assertNear(pattern.responseLinear[positiveX], 0, 1e-12, "+X endfire null");
assertNear(pattern.responseLinear[negativeX], 0, 1e-12, "-X endfire null");
assertNear(pattern.responseLinear[positiveY], 1, 1e-12, "+Y broadside");
assertNear(pattern.responseLinear[northPole], 1, 1e-12, "+Z steering response");
assertNear(pattern.responseLinear[southPole], 1, 1e-12, "-Z front-back tie");
assert(
pattern.angularSampling.resolutionCriterionSatisfied,
"five-degree grid satisfies the disclosed cell-diagonal phase criterion",
);
const expectedDiagonalChord = 2 * Math.hypot(
Math.sin(2.5 * Math.PI / 180),
Math.cos(2.5 * Math.PI / 180) * Math.sin(2.5 * Math.PI / 180),
);
assertNear(
pattern.angularSampling.maximumCellDiagonalPhaseStepRadBound,
Math.PI * expectedDiagonalChord,
1e-12,
"full-aperture cell-diagonal phase bound",
);
for (let azimuthIndex = 1; azimuthIndex < pattern.azimuthSampleCount; azimuthIndex += 1) {
assertNear(
pattern.responsePower[northPole + azimuthIndex],
pattern.responsePower[northPole],
0,
"north-pole azimuth samples are physically coincident",
);
assertNear(
pattern.responsePower[southPole + azimuthIndex],
pattern.responsePower[southPole],
0,
"south-pole azimuth samples are physically coincident",
);
}
});
test("sphere raw complex response uses all three coordinates and the receive sign", () => {
const speedOfSound = 343;
const frequencyHz = 1_000;
const wavelengthM = speedOfSound / frequencyHz;
const positions = [
[0, 0, 0],
[wavelengthM / 4, 0, wavelengthM / 16],
[0, wavelengthM / 8, 3 * wavelengthM / 16],
];
const pattern = computeReceiveBeamPatternSphere({
array: arrayFromPositions(positions),
frequencyHz,
speedOfSound,
steeringAzimuthDeg: 0,
steeringElevationDeg: 90,
azimuthSampleCount: 8,
elevationSampleCount: 7,
});
const targetDirection = receiveBeamPatternSphereDirection(45, 30);
const steeringDirection = [0, 0, 1];
const centroid = [0, 0, 0];
for (const position of positions) {
for (let axis = 0; axis < 3; axis += 1) {
centroid[axis] += position[axis] / positions.length;
}
}
let expectedReal = 0;
let expectedImaginary = 0;
for (const position of positions) {
const centered = position.map((coordinate, axis) => coordinate - centroid[axis]);
const phase = 2 * Math.PI / wavelengthM * (
centered[0] * (targetDirection[0] - steeringDirection[0]) +
centered[1] * (targetDirection[1] - steeringDirection[1]) +
centered[2] * (targetDirection[2] - steeringDirection[2])
);
expectedReal += Math.cos(phase) / positions.length;
expectedImaginary += Math.sin(phase) / positions.length;
}
const targetIndex = sphereFlatIndex(pattern, 45, 30);
assertNear(pattern.responseReal[targetIndex], expectedReal, 1e-12, "3D raw real");
assertNear(
pattern.responseImaginary[targetIndex],
expectedImaginary,
1e-12,
"3D raw imaginary sign",
);
assertNear(
pattern.responsePower[targetIndex],
expectedReal * expectedReal + expectedImaginary * expectedImaginary,
1e-12,
"3D raw power",
);
});
test("sphere samples agree with the existing XZ great-circle contract", () => {
const array = arrayFromPositions([
[-0.12, 0.03, 0.04],
[0.04, -0.08, 0.02],
[0.15, 0.07, -0.11],
]);
const shared = {
array,
frequencyHz: 3_200,
speedOfSound: 343,
};
const cut = computeReceiveBeamPattern({
...shared,
cut: "xz",
steeringAngleDeg: 0,
sampleCount: 360,
});
const sphere = computeReceiveBeamPatternSphere({
...shared,
steeringAzimuthDeg: 0,
steeringElevationDeg: 90,
azimuthSampleCount: 4,
elevationSampleCount: 3,
});
const mappings = [
[0, 0, 90],
[90, 0, 0],
[180, 0, -90],
[270, 180, 0],
];
for (const [cutAngleDeg, azimuthDeg, elevationDeg] of mappings) {
const cutIndex = [...cut.anglesDeg].findIndex(
(angleDeg) => angleDeg === cutAngleDeg,
);
const sphereIndex = sphereFlatIndex(sphere, azimuthDeg, elevationDeg);
assert(cutIndex >= 0, "mapped XZ angle is sampled");
assertNear(
sphere.responseReal[sphereIndex],
cut.responseReal[cutIndex],
1e-12,
"sphere/XZ raw real at " + cutAngleDeg,
);
assertNear(
sphere.responseImaginary[sphereIndex],
cut.responseImaginary[cutIndex],
1e-12,
"sphere/XZ raw imaginary at " + cutAngleDeg,
);
assertNear(
sphere.responsePower[sphereIndex],
cut.responsePower[cutIndex],
1e-12,
"sphere/XZ raw power at " + cutAngleDeg,
);
}
});
test("sphere array factor remains invariant to translating a three-dimensional array", () => {
const basePositions = [
[-0.12, 0.03, 0.04],
[0.04, -0.08, 0.02],
[0.15, 0.07, -0.11],
];
const translation = [10, -20, 5];
const translatedPositions = basePositions.map((position) =>
position.map((coordinate, axis) => coordinate + translation[axis]));
const options = {
frequencyHz: 7_500,
speedOfSound: 343,
steeringAzimuthDeg: 45,
steeringElevationDeg: 30,
azimuthSampleCount: 8,
elevationSampleCount: 7,
};
const base = computeReceiveBeamPatternSphere({
...options,
array: arrayFromPositions(basePositions),
});
const translated = computeReceiveBeamPatternSphere({
...options,
array: arrayFromPositions(translatedPositions),
});
for (let index = 0; index < base.sampleCount; index += 1) {
assertNear(
translated.responseReal[index],
base.responseReal[index],
1e-11,
"translation-invariant sphere real " + index,
);
assertNear(
translated.responseImaginary[index],
base.responseImaginary[index],
1e-11,
"translation-invariant sphere imaginary " + index,
);
}
});
test("sphere response preserves analog frequency and display floor only affects display dB", () => {
const highFrequencyHz = 49_125;
const speedOfSound = 343;
const halfHighWavelength = speedOfSound / (2 * highFrequencyHz);
const array = arrayFromPositions([
[-halfHighWavelength / 2, 0, 0],
[halfHighWavelength / 2, 0, 0],
]);
const shared = {
array,
speedOfSound,
steeringElevationDeg: 90,
azimuthSampleCount: 4,
elevationSampleCount: 3,
};
const highAnalog = computeReceiveBeamPatternSphere({
...shared,
frequencyHz: highFrequencyHz,
dbFloor: -30,
});
const highAnalogDeepFloor = computeReceiveBeamPatternSphere({
...shared,
frequencyHz: highFrequencyHz,
dbFloor: -100,
});
const foldedTemporalAlias = computeReceiveBeamPatternSphere({
...shared,
frequencyHz: 1_125,
});
const positiveX = sphereFlatIndex(highAnalog, 0, 0);
assertEqual(highAnalog.frequencyHz, highFrequencyHz, "analog frequency is preserved");
assertNear(
highAnalog.wavelengthM,
speedOfSound / highFrequencyHz,
1e-15,
"sphere uses analog wavelength",
);
assert(
Math.abs(
highAnalog.responseLinear[positiveX] -
foldedTemporalAlias.responseLinear[positiveX]
) > 0.1,
"sphere response is not computed from a folded ADC alias",
);
for (let index = 0; index < highAnalog.sampleCount; index += 1) {
assertNear(
highAnalog.responsePower[index],
highAnalogDeepFloor.responsePower[index],
0,
"sphere raw power is display-floor independent",
);
}
assertEqual(highAnalog.displayDb[positiveX], -30, "sphere shallow display floor");
assertEqual(
highAnalogDeepFloor.displayDb[positiveX],
-100,
"sphere deep display floor",
);
assert(
highAnalog.responseDb[positiveX] < -300,
"sphere raw null is retained below display floors",
);
});
test("coarse sphere grids expose failed resolution provenance without shaping raw samples", () => {
const pattern = computeReceiveBeamPatternSphere({
array: arrayFromPositions([[-0.5, 0, 0], [0.5, 0, 0]]),
frequencyHz: 100_000,
speedOfSound: 343,
azimuthSampleCount: 12,
elevationSampleCount: 7,
});
assert(
!pattern.angularSampling.resolutionCriterionSatisfied,
"coarse full-sphere phase-step criterion fails",
);
assert(
pattern.angularSampling.maximumCellDiagonalPhaseStepRadBound >
pattern.angularSampling.phaseStepLimitRad,
"failed cell-diagonal phase bound is exposed",
);
assertEqual(pattern.responsePower.length, 84, "unresolved raw sphere is retained");
assertEqual(
pattern.angularSampling.interpretation,
"sampling-bound-only-not-proof-of-complete-lobe-detection",
"resolution claim remains limited",
);
});
test("sphere preflight checks axis, product, baseline, and evaluation caps before output allocation", () => {
const single = arrayFromPositions([[0, 0, 0]]);
const exact = computeReceiveBeamPatternSphere({
array: single,
frequencyHz: 1_000,
azimuthSampleCount: 4,
elevationSampleCount: 3,
maximumEvaluations: 12,
});
assertEqual(exact.evaluationCount, 12, "exact sphere evaluation cap is accepted");
assertEqual(
exact.workload.returnedFloat64Bytes,
(12 * 6 + 4 + 3) * Float64Array.BYTES_PER_ELEMENT,
"returned allocation provenance",
);
let evaluationError = null;
try {
computeReceiveBeamPatternSphere({
array: single,
frequencyHz: 1_000,
azimuthSampleCount: 4,
elevationSampleCount: 3,
maximumEvaluations: 11,
});
} catch (error) {
evaluationError = error;
}
assert(evaluationError instanceof RangeError, "sphere evaluation cap rejects work");
assert(evaluationError.message.includes("12"), "evaluation requirement is explicit");
let productError = null;
try {
computeReceiveBeamPatternSphere({
array: single,
frequencyHz: 1_000,
azimuthSampleCount: 720,
elevationSampleCount: 721,
});
} catch (error) {
productError = error;
}
assert(productError instanceof RangeError, "sphere sample product is bounded");
assert(
productError.message.includes("262144"),
"sphere sample-product limit is explicit",
);
let axisError = null;
try {
computeReceiveBeamPatternSphere({
array: single,
frequencyHz: 1_000,
azimuthSampleCount: 1_441,
elevationSampleCount: 3,
});
} catch (error) {
axisError = error;
}
assert(axisError instanceof RangeError, "sphere azimuth axis is bounded");
let baselineError = null;
try {
computeReceiveBeamPatternSphere({
array: arrayFromPositions([[0, 0, 0], [0.1, 0, 0]]),
frequencyHz: 1_000,
azimuthSampleCount: 4,
elevationSampleCount: 3,
maximumBaselineComparisons: 0,
});
} catch (error) {
baselineError = error;
}
assert(baselineError instanceof RangeError, "sphere baseline work is bounded");
assert(
baselineError.message.includes("1 spatial-baseline"),
"sphere baseline requirement is explicit",
);
let steeringError = null;
try {
computeReceiveBeamPatternSphere({
array: single,
frequencyHz: 1_000,
steeringElevationDeg: 91,
azimuthSampleCount: 4,
elevationSampleCount: 3,
});
} catch (error) {
steeringError = error;
}
assert(steeringError instanceof RangeError, "invalid sphere elevation is rejected");
});