895 lines
29 KiB
JavaScript
895 lines
29 KiB
JavaScript
import {
|
|
computeReceiveBeamPattern,
|
|
computeReceiveBeamPatternSphere,
|
|
receiveBeamPatternDirection,
|
|
receiveBeamPatternSphereDirection,
|
|
} 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,
|
|
"sampled-bracketed-bisection",
|
|
"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");
|
|
assertEqual(
|
|
pattern.metrics.sampledLocalMaxima.status,
|
|
"tied-global-maxima-on-grid",
|
|
"global tie is explicit",
|
|
);
|
|
assertEqual(pattern.metrics.sampledLocalMaxima.selected, null, "tie has no selected peak");
|
|
assertEqual(
|
|
pattern.metrics.sampledLocalMaxima.globalMaximumRegions.length,
|
|
2,
|
|
"both tied regions are preserved",
|
|
);
|
|
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("non-constant cuts without sampled half-power crossings do not claim 360-degree HPBW", () => {
|
|
const pattern = computeReceiveBeamPattern({
|
|
array: arrayFromPositions([[-0.0005, 0, 0], [0.0005, 0, 0]]),
|
|
frequencyHz: 1_000,
|
|
speedOfSound: 343,
|
|
cut: "xz",
|
|
steeringAngleDeg: 0,
|
|
sampleCount: 720,
|
|
});
|
|
assert(pattern.angularSampling.projectedApertureM > 0, "array is not constant on cut");
|
|
assert(
|
|
Math.min(...pattern.responsePower) > 0.5,
|
|
"sampled cut stays above half power",
|
|
);
|
|
assertEqual(
|
|
pattern.metrics.halfPowerBeamwidth.status,
|
|
"no-sampled-half-power-crossing",
|
|
"missing crossing remains unresolved",
|
|
);
|
|
assertEqual(pattern.metrics.halfPowerBeamwidth.widthDeg, null, "no fabricated width");
|
|
assertEqual(
|
|
pattern.metrics.halfPowerBeamwidth.fullCircleAboveHalfPower,
|
|
null,
|
|
"full-circle claim is withheld",
|
|
);
|
|
});
|
|
|
|
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",
|
|
);
|
|
|
|
let baselineError = null;
|
|
try {
|
|
computeReceiveBeamPattern({
|
|
array,
|
|
frequencyHz: 1_000,
|
|
maximumBaselineComparisons: 0,
|
|
});
|
|
} catch (error) {
|
|
baselineError = error;
|
|
}
|
|
assert(baselineError instanceof RangeError, "baseline pair cap is checked");
|
|
assert(baselineError.message.includes("1 projected-baseline"), "pair work is explicit");
|
|
|
|
let frequencyLimitError = null;
|
|
try {
|
|
computeReceiveBeamPattern({ array, frequencyHz: 1_000_001 });
|
|
} catch (error) {
|
|
frequencyLimitError = error;
|
|
}
|
|
assert(frequencyLimitError instanceof RangeError, "extreme analog frequency is bounded");
|
|
assert(frequencyLimitError.message.includes("1000000"), "frequency cap is explicit");
|
|
});
|
|
|
|
test("co-located pattern accepts an exact sample-only evaluation cap", () => {
|
|
const pattern = computeReceiveBeamPattern({
|
|
array: arrayFromPositions([[0, 0, 0]]),
|
|
frequencyHz: 1_000,
|
|
sampleCount: 360,
|
|
maximumEvaluations: 360,
|
|
});
|
|
assertEqual(pattern.evaluationCount, 360, "exact one-microphone sample work");
|
|
assertEqual(pattern.workload.maximumMetricEvaluationCount, 0, "constant cut reserves no HPBW work");
|
|
|
|
let error = null;
|
|
try {
|
|
computeReceiveBeamPattern({
|
|
array: arrayFromPositions([[0, 0, 0]]),
|
|
frequencyHz: 1_000,
|
|
sampleCount: 360,
|
|
maximumEvaluations: 359,
|
|
});
|
|
} catch (caught) {
|
|
error = caught;
|
|
}
|
|
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");
|
|
});
|