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, "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"); });