From 9cc34df59b1cbe63def055c7e5cf1e64948052f6 Mon Sep 17 00:00:00 2001 From: MasterBabol Date: Sat, 29 Aug 2026 14:27:28 +0900 Subject: [PATCH] feat: add physical receive beam pattern core --- packages/acoustics-core/beam-pattern.js | 494 ++++++++++++++++++++++++ packages/acoustics-core/index.js | 1 + tests/test-runner.js | 1 + tests/unit/beam-pattern.test.js | 327 ++++++++++++++++ 4 files changed, 823 insertions(+) create mode 100644 packages/acoustics-core/beam-pattern.js create mode 100644 tests/unit/beam-pattern.test.js diff --git a/packages/acoustics-core/beam-pattern.js b/packages/acoustics-core/beam-pattern.js new file mode 100644 index 0000000..6bba96c --- /dev/null +++ b/packages/acoustics-core/beam-pattern.js @@ -0,0 +1,494 @@ +import { TAU } from "./math.js"; + +export const RECEIVE_BEAM_PATTERN_CUTS = Object.freeze(["xy", "xz", "yz"]); +export const DEFAULT_RECEIVE_BEAM_PATTERN_SAMPLES = 720; +export const DEFAULT_RECEIVE_BEAM_PATTERN_DB_FLOOR = -60; +export const MAXIMUM_RECEIVE_BEAM_PATTERN_EVALUATIONS = 16_000_000; +export const MAXIMUM_RECEIVE_BEAM_PATTERN_SAMPLES = 14_400; +export const MAXIMUM_RECEIVE_BEAM_PATTERN_MICROPHONES = 1_024; +export const RECEIVE_BEAM_PATTERN_METRIC_PHASE_STEP_LIMIT_RAD = Math.PI / 8; + +const HALF_POWER = 0.5; +const RADIANS_PER_DEGREE = Math.PI / 180; +const HPBW_BISECTION_ITERATIONS = 48; + +function requireFinite(value, label) { + const number = Number(value); + if (!Number.isFinite(number)) throw new TypeError(label + " must be finite."); + return number; +} + +function requirePositive(value, label) { + const number = requireFinite(value, label); + if (!(number > 0)) throw new RangeError(label + " must be greater than zero."); + return number; +} + +function requireCut(cut) { + if (!RECEIVE_BEAM_PATTERN_CUTS.includes(cut)) { + throw new RangeError( + "cut must be one of " + RECEIVE_BEAM_PATTERN_CUTS.join(", ") + ".", + ); + } + return cut; +} + +function requireMicrophones(array) { + const microphones = array?.microphones; + if (!Array.isArray(microphones) || microphones.length === 0) { + throw new TypeError("array.microphones must contain at least one microphone."); + } + if (microphones.length > MAXIMUM_RECEIVE_BEAM_PATTERN_MICROPHONES) { + throw new RangeError( + "array.microphones exceeds the receive beam-pattern limit of " + + MAXIMUM_RECEIVE_BEAM_PATTERN_MICROPHONES + ".", + ); + } + return microphones.map((microphone, microphoneIndex) => { + const position = microphone?.position; + if (!Array.isArray(position) || position.length !== 3) { + throw new TypeError( + "array.microphones[" + microphoneIndex + "].position must contain x, y, z.", + ); + } + return position.map((coordinate, axis) => + requireFinite( + coordinate, + "array.microphones[" + microphoneIndex + "].position[" + axis + "]", + )); + }); +} + +export function normalizeBeamPatternAngle(angleDeg) { + const angle = requireFinite(angleDeg, "angleDeg"); + const normalized = ((angle % 360) + 360) % 360; + return Object.is(normalized, -0) ? 0 : normalized; +} + +/** + * Convert a polar angle on an orthogonal great-circle cut to an array-to-source + * unit vector. Zero degrees points along the first named axis and +90 degrees + * points along the second: XY=(+x,+y), XZ=(+z,+x), YZ=(+z,+y). + */ +export function receiveBeamPatternDirection(cut, angleDeg) { + const plane = requireCut(cut); + const angleRadians = requireFinite(angleDeg, "angleDeg") * RADIANS_PER_DEGREE; + const cosine = Math.cos(angleRadians); + const sine = Math.sin(angleRadians); + if (plane === "xy") return [cosine, sine, 0]; + if (plane === "xz") return [sine, 0, cosine]; + return [0, sine, cosine]; +} + +function cutCoordinates(position, cut) { + if (cut === "xy") return [position[0], position[1]]; + if (cut === "xz") return [position[2], position[0]]; + return [position[2], position[1]]; +} + +function bisectHalfPowerBoundary( + insideOffsetDeg, + outsideOffsetDeg, + evaluatePowerAtOffset, +) { + let inside = insideOffsetDeg; + let outside = outsideOffsetDeg; + for (let iteration = 0; iteration < HPBW_BISECTION_ITERATIONS; iteration += 1) { + const middle = (inside + outside) / 2; + if (evaluatePowerAtOffset(middle) >= HALF_POWER) inside = middle; + else outside = middle; + } + return (inside + outside) / 2; +} + +function halfPowerBeamwidth( + responsePower, + steeringIndex, + stepDeg, + steeringAngleDeg, + evaluatePowerAtOffset, +) { + const sampleCount = responsePower.length; + let allAboveHalfPower = true; + for (let index = 0; index < sampleCount; index += 1) { + if (responsePower[index] < HALF_POWER) allAboveHalfPower = false; + } + if (allAboveHalfPower) { + return { + status: "full-circle-above-half-power", + widthDeg: null, + startAngleDeg: null, + endAngleDeg: null, + fullCircleAboveHalfPower: true, + }; + } + + let leftSteps = 0; + while (leftSteps < sampleCount - 1) { + const nextIndex = (steeringIndex - leftSteps - 1 + sampleCount) % sampleCount; + if (responsePower[nextIndex] < HALF_POWER) break; + leftSteps += 1; + } + let rightSteps = 0; + while (rightSteps < sampleCount - leftSteps - 1) { + const nextIndex = (steeringIndex + rightSteps + 1) % sampleCount; + if (responsePower[nextIndex] < HALF_POWER) break; + rightSteps += 1; + } + if (leftSteps + rightSteps + 1 >= sampleCount) { + return { + status: "full-circle-above-half-power", + widthDeg: null, + startAngleDeg: null, + endAngleDeg: null, + fullCircleAboveHalfPower: true, + }; + } + + const leftBoundaryOffset = bisectHalfPowerBoundary( + -leftSteps * stepDeg, + -(leftSteps + 1) * stepDeg, + evaluatePowerAtOffset, + ); + const rightBoundaryOffset = bisectHalfPowerBoundary( + rightSteps * stepDeg, + (rightSteps + 1) * stepDeg, + evaluatePowerAtOffset, + ); + return { + status: "bounded", + widthDeg: rightBoundaryOffset - leftBoundaryOffset, + startAngleDeg: normalizeBeamPatternAngle(steeringAngleDeg + leftBoundaryOffset), + endAngleDeg: normalizeBeamPatternAngle(steeringAngleDeg + rightBoundaryOffset), + fullCircleAboveHalfPower: false, + }; +} + +function sampledLocalMaxima(responseLinear, anglesDeg, offsetsDeg) { + const sampleCount = responseLinear.length; + let minimum = Infinity; + let maximum = -Infinity; + for (const value of responseLinear) { + minimum = Math.min(minimum, value); + maximum = Math.max(maximum, value); + } + const comparisonTolerance = Math.max(1, maximum) * 64 * Number.EPSILON; + if (maximum - minimum <= comparisonTolerance) return []; + + const peaks = []; + for (let index = 0; index < sampleCount; index += 1) { + const previous = responseLinear[(index - 1 + sampleCount) % sampleCount]; + const current = responseLinear[index]; + const next = responseLinear[(index + 1) % sampleCount]; + if ( + current >= previous - comparisonTolerance && + current >= next - comparisonTolerance && + (current > previous + comparisonTolerance || current > next + comparisonTolerance) + ) { + peaks.push({ + sampleIndex: index, + angleDeg: anglesDeg[index], + offsetDeg: offsetsDeg[index], + responseLinear: current, + responseDb: current > 0 ? 20 * Math.log10(current) : -Infinity, + }); + } + } + peaks.sort((left, right) => + right.responseLinear - left.responseLinear || + Math.abs(left.offsetDeg) - Math.abs(right.offsetDeg) || + left.angleDeg - right.angleDeg); + return peaks; +} + +function maximumProjectedBaseline(coordinates) { + let maximum = 0; + for (let left = 0; left < coordinates.length; left += 1) { + for (let right = left + 1; right < coordinates.length; right += 1) { + maximum = Math.max( + maximum, + Math.hypot( + coordinates[left][0] - coordinates[right][0], + coordinates[left][1] - coordinates[right][1], + ), + ); + } + } + return maximum; +} + +function recommendedSampleCount(waveNumber, projectedApertureM) { + if (projectedApertureM === 0) return 36; + const ratio = RECEIVE_BEAM_PATTERN_METRIC_PHASE_STEP_LIMIT_RAD / + (2 * waveNumber * projectedApertureM); + if (ratio >= 1) return 36; + const required = Math.ceil(Math.PI / Math.asin(ratio)); + if (!Number.isSafeInteger(required)) return Number.MAX_SAFE_INTEGER; + return Math.ceil(required / 4) * 4; +} + +/** + * Compute the ideal, calibrated, omnidirectional receive array factor for a + * narrowband far-field plane wave and conventional delay-and-sum steering. + * + * This function intentionally has no sample-rate input. frequencyHz is the + * analog acoustic hypothesis used for k=2πf/c, including frequencies above a + * later ADC Nyquist limit. The result is a great-circle slice, not a full 3-D + * directivity surface and not a probability distribution. + */ +export function computeReceiveBeamPattern(options) { + const array = options?.array; + const positions = requireMicrophones(array); + const frequencyHz = requirePositive(options?.frequencyHz, "frequencyHz"); + const speedOfSound = requirePositive(options?.speedOfSound ?? 343, "speedOfSound"); + const cut = requireCut(options?.cut ?? "xz"); + const steeringAngleDeg = normalizeBeamPatternAngle(options?.steeringAngleDeg ?? 0); + const requestedSampleCount = + options?.sampleCount ?? DEFAULT_RECEIVE_BEAM_PATTERN_SAMPLES; + if ( + requestedSampleCount !== "auto" && + (!Number.isInteger(requestedSampleCount) || + requestedSampleCount < 36 || + requestedSampleCount > MAXIMUM_RECEIVE_BEAM_PATTERN_SAMPLES) + ) { + throw new RangeError( + "sampleCount must be 'auto' or an integer inside 36.." + + MAXIMUM_RECEIVE_BEAM_PATTERN_SAMPLES + ".", + ); + } + const dbFloor = requireFinite( + options?.dbFloor ?? DEFAULT_RECEIVE_BEAM_PATTERN_DB_FLOOR, + "dbFloor", + ); + if (dbFloor < -300 || dbFloor > -1) { + throw new RangeError("dbFloor must lie inside -300..-1 dB."); + } + const maximumEvaluations = options?.maximumEvaluations ?? + MAXIMUM_RECEIVE_BEAM_PATTERN_EVALUATIONS; + if (!Number.isSafeInteger(maximumEvaluations) || maximumEvaluations < 1) { + throw new RangeError("maximumEvaluations must be a positive safe integer."); + } + const microphoneCount = positions.length; + const centroid = [0, 0, 0]; + for (const position of positions) { + centroid[0] += position[0] / microphoneCount; + centroid[1] += position[1] / microphoneCount; + centroid[2] += position[2] / microphoneCount; + } + const centeredCutCoordinates = positions.map((position) => + cutCoordinates([ + position[0] - centroid[0], + position[1] - centroid[1], + position[2] - centroid[2], + ], cut)); + const projectedApertureM = maximumProjectedBaseline(centeredCutCoordinates); + const waveNumber = TAU * frequencyHz / speedOfSound; + if (!Number.isFinite(waveNumber) || !Number.isFinite(waveNumber * projectedApertureM)) { + throw new RangeError("frequency, speed of sound, and array coordinates overflow phase."); + } + const recommendedMetricSampleCount = + recommendedSampleCount(waveNumber, projectedApertureM); + const maximumMetricDirectionEvaluations = projectedApertureM > 0 + ? HPBW_BISECTION_ITERATIONS * 2 + : 0; + const maximumSamplesByWork = Math.floor( + maximumEvaluations / microphoneCount - maximumMetricDirectionEvaluations, + ); + const allowedSampleCount = Math.min( + MAXIMUM_RECEIVE_BEAM_PATTERN_SAMPLES, + maximumSamplesByWork, + ); + if (allowedSampleCount < 36) { + throw new RangeError( + "maximumEvaluations cannot cover the minimum 36-angle receive pattern.", + ); + } + const sampleCount = requestedSampleCount === "auto" + ? Math.min( + allowedSampleCount, + Math.max(DEFAULT_RECEIVE_BEAM_PATTERN_SAMPLES, recommendedMetricSampleCount), + ) + : requestedSampleCount; + const maximumMetricEvaluationCount = + microphoneCount * maximumMetricDirectionEvaluations; + const sampleEvaluationCount = microphoneCount * sampleCount; + const evaluationCount = sampleEvaluationCount + maximumMetricEvaluationCount; + if (!Number.isSafeInteger(evaluationCount) || evaluationCount > maximumEvaluations) { + throw new RangeError( + "receive beam pattern requires at most " + evaluationCount + + " microphone-angle evaluations; limit is " + maximumEvaluations + ".", + ); + } + + const stepDeg = 360 / sampleCount; + const stepRadians = TAU / sampleCount; + const maximumAdjacentPhaseStepRadBound = + 2 * waveNumber * projectedApertureM * Math.sin(stepRadians / 2); + const metricResolutionSatisfied = + maximumAdjacentPhaseStepRadBound <= + RECEIVE_BEAM_PATTERN_METRIC_PHASE_STEP_LIMIT_RAD * + (1 + 64 * Number.EPSILON); + const steeringIndex = Math.floor(sampleCount / 2); + const anglesDeg = new Float64Array(sampleCount); + const offsetsDeg = new Float64Array(sampleCount); + const directionCosines = new Float64Array(sampleCount); + const directionSines = new Float64Array(sampleCount); + for (let index = 0; index < sampleCount; index += 1) { + const offsetDeg = (index - steeringIndex) * stepDeg; + const angleDeg = normalizeBeamPatternAngle(steeringAngleDeg + offsetDeg); + const radians = angleDeg * RADIANS_PER_DEGREE; + offsetsDeg[index] = offsetDeg; + anglesDeg[index] = angleDeg; + directionCosines[index] = Math.cos(radians); + directionSines[index] = Math.sin(radians); + } + + const steeringRadians = steeringAngleDeg * RADIANS_PER_DEGREE; + const steeringCosine = Math.cos(steeringRadians); + const steeringSine = Math.sin(steeringRadians); + const summedReal = new Float64Array(sampleCount); + const summedImaginary = new Float64Array(sampleCount); + for (const [firstCoordinate, secondCoordinate] of centeredCutCoordinates) { + const steeringProjection = + firstCoordinate * steeringCosine + secondCoordinate * steeringSine; + for (let index = 0; index < sampleCount; index += 1) { + const arrivalProjection = + firstCoordinate * directionCosines[index] + + secondCoordinate * directionSines[index]; + const phase = waveNumber * (arrivalProjection - steeringProjection); + summedReal[index] += Math.cos(phase); + summedImaginary[index] += Math.sin(phase); + } + } + + const responseReal = new Float64Array(sampleCount); + const responseImaginary = new Float64Array(sampleCount); + const responsePower = new Float64Array(sampleCount); + const responseLinear = new Float64Array(sampleCount); + const responseDb = new Float64Array(sampleCount); + const displayDb = new Float64Array(sampleCount); + let maximumLinear = 0; + let maximumPower = 0; + for (let index = 0; index < sampleCount; index += 1) { + const real = summedReal[index] / microphoneCount; + const imaginary = summedImaginary[index] / microphoneCount; + const power = real * real + imaginary * imaginary; + const magnitude = Math.sqrt(power); + const decibels = power > 0 ? 10 * Math.log10(power) : -Infinity; + responseReal[index] = real; + responseImaginary[index] = imaginary; + responsePower[index] = power; + responseLinear[index] = magnitude; + responseDb[index] = decibels; + displayDb[index] = Math.max(dbFloor, decibels); + maximumLinear = Math.max(maximumLinear, magnitude); + maximumPower = Math.max(maximumPower, power); + } + + let metricDirectionEvaluationCount = 0; + const evaluatePowerAtOffset = (offsetDeg) => { + metricDirectionEvaluationCount += 1; + const angleRadians = + (steeringAngleDeg + offsetDeg) * RADIANS_PER_DEGREE; + const directionCosine = Math.cos(angleRadians); + const directionSine = Math.sin(angleRadians); + let real = 0; + let imaginary = 0; + for (const [firstCoordinate, secondCoordinate] of centeredCutCoordinates) { + const steeringProjection = + firstCoordinate * steeringCosine + secondCoordinate * steeringSine; + const arrivalProjection = + firstCoordinate * directionCosine + secondCoordinate * directionSine; + const phase = waveNumber * (arrivalProjection - steeringProjection); + real += Math.cos(phase); + imaginary += Math.sin(phase); + } + return (real * real + imaginary * imaginary) / + (microphoneCount * microphoneCount); + }; + const halfPower = metricResolutionSatisfied + ? halfPowerBeamwidth( + responsePower, + steeringIndex, + stepDeg, + steeringAngleDeg, + evaluatePowerAtOffset, + ) + : { + status: "resolution-unresolved", + widthDeg: null, + startAngleDeg: null, + endAngleDeg: null, + fullCircleAboveHalfPower: null, + }; + const peaks = metricResolutionSatisfied + ? sampledLocalMaxima(responseLinear, anglesDeg, offsetsDeg) + : []; + const steeringLinear = responseLinear[steeringIndex]; + return { + model: "far-field-narrowband-conventional-delay-and-sum-receive-array-factor", + assumptions: Object.freeze({ + wavefront: "far-field-plane-wave", + bandwidth: "narrowband", + elements: "ideal-calibrated-omnidirectional-point-microphones", + environment: "free-field", + coordinates: "meters", + }), + cut, + frequencyHz, + speedOfSound, + wavelengthM: speedOfSound / frequencyHz, + waveNumberRadPerM: waveNumber, + steeringAngleDeg, + steeringDirection: receiveBeamPatternDirection(cut, steeringAngleDeg), + sampleCount, + angularResolutionDeg: stepDeg, + microphoneCount, + evaluationCount: sampleEvaluationCount + + metricDirectionEvaluationCount * microphoneCount, + workload: { + projectedBaselineComparisons: + microphoneCount * (microphoneCount - 1) / 2, + sampleEvaluationCount, + maximumMetricEvaluationCount, + maximumEvaluationCount: evaluationCount, + }, + angularSampling: { + mode: requestedSampleCount === "auto" ? "auto" : "fixed", + criterion: "maximum-adjacent-pair-phase-step", + projectedApertureM, + maximumAdjacentPhaseStepRadBound, + metricPhaseStepLimitRad: + RECEIVE_BEAM_PATTERN_METRIC_PHASE_STEP_LIMIT_RAD, + metricResolutionSatisfied, + recommendedSampleCount: recommendedMetricSampleCount, + autoCapped: + requestedSampleCount === "auto" && + sampleCount < Math.max( + DEFAULT_RECEIVE_BEAM_PATTERN_SAMPLES, + recommendedMetricSampleCount, + ), + }, + dbFloor, + anglesDeg, + offsetsDeg, + responseReal, + responseImaginary, + responsePower, + responseLinear, + responseDb, + displayDb, + metrics: { + maximumLinear, + maximumPower, + maximumDb: maximumPower > 0 ? 10 * Math.log10(maximumPower) : -Infinity, + steeringLinear, + steeringDb: steeringLinear > 0 ? 20 * Math.log10(steeringLinear) : -Infinity, + halfPowerBeamwidth: halfPower, + sampledLocalMaxima: { + status: metricResolutionSatisfied ? "sampled" : "resolution-unresolved", + peaks, + }, + }, + }; +} diff --git a/packages/acoustics-core/index.js b/packages/acoustics-core/index.js index 0151532..3c05b97 100644 --- a/packages/acoustics-core/index.js +++ b/packages/acoustics-core/index.js @@ -1,4 +1,5 @@ export * from "./activity-monitor.js"; +export * from "./beam-pattern.js"; export * from "./cfar.js"; export * from "./das.js"; export * from "./evaluation.js"; diff --git a/tests/test-runner.js b/tests/test-runner.js index 8cde63a..108c4aa 100644 --- a/tests/test-runner.js +++ b/tests/test-runner.js @@ -11,6 +11,7 @@ globalThis.assertEqual = (actual, expected, message = "values differ") => { try { await import("/tests/unit/contracts.test.js"); await import("/tests/unit/acoustics.test.js"); + await import("/tests/unit/beam-pattern.test.js"); await import("/tests/unit/cfar.test.js"); await import("/tests/unit/activity-monitor.test.js"); await import("/tests/unit/spectral-flux.test.js"); diff --git a/tests/unit/beam-pattern.test.js b/tests/unit/beam-pattern.test.js new file mode 100644 index 0000000..31117ca --- /dev/null +++ b/tests/unit/beam-pattern.test.js @@ -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", + ); +});