import { binsForBand, getCompleteFrameCount, getFrameCount, } from "/packages/acoustics-core/stft.js"; import { DEFAULT_MAXIMUM_MUSIC_COVARIANCE_COMPLEX_ACCUMULATIONS, DEFAULT_MAXIMUM_MUSIC_JACOBI_PAIR_UPDATES, DEFAULT_MAXIMUM_MUSIC_PROJECTION_COMPLEX_MACS, } from "/packages/acoustics-core/music.js"; import { describeFrequencyAliasing, observeFrequencyBand, } from "/packages/acoustics-core/sampling.js"; import { DEFAULT_LOCALIZATION_ALGORITHM, localizationAlgorithmDefinition, } from "/packages/acoustics-core/localization-algorithms.js"; import { RESULT_SCHEMA_VERSION } from "/packages/contracts/validation.js"; const RESULT_METRIC_KEYS = [ "peakToSidelobeDb", "energyInsideTruthRatio", "precision", "recall", "mainLobeWidthM", ]; function sortForCanonicalJson(value) { if (Array.isArray(value)) return value.map(sortForCanonicalJson); if (value && typeof value === "object") { return Object.fromEntries( Object.keys(value) .filter((key) => value[key] !== undefined) .sort() .map((key) => [key, sortForCanonicalJson(value[key])]), ); } return value; } export function canonicalStringify(value) { return JSON.stringify(sortForCanonicalJson(value)); } export function createComparisonSignature(scene, sourceExperiment) { if (!scene || !sourceExperiment) return ""; const experiment = structuredClone(sourceExperiment); experiment.arrayId = "$array"; experiment.sceneId = "$scene"; if (experiment.beamforming) { experiment.beamforming.algorithm = "$algorithm"; experiment.beamforming.backend = "$backend"; experiment.beamforming.normalization = "$algorithm-specific-normalization"; delete experiment.beamforming.musicSourceCountMethod; delete experiment.beamforming.musicSourceCount; delete experiment.beamforming.phatRelativeFloor; delete experiment.beamforming.phatFloorRatio; } return canonicalStringify({ scene, experiment }); } export async function hashSimulationInputs({ arrayDefinition, scene, experiment }) { const canonical = canonicalStringify({ array: arrayDefinition, experiment, scene }); const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical)); return [...new Uint8Array(digest)] .map((value) => value.toString(16).padStart(2, "0")) .join(""); } export function estimateExperimentWorkload(arrayDefinition, scene, experiment) { const sampleCount = Math.ceil(scene.duration * scene.sampleRate); const algorithm = experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM; const musicSourceCountMethod = experiment.beamforming?.musicSourceCountMethod ?? "fixed"; const frameCount = algorithm === "music" && musicSourceCountMethod !== "fixed" ? getCompleteFrameCount( sampleCount, experiment.stft.fftSize, experiment.stft.hopSize, ) : getFrameCount( sampleCount, experiment.stft.fftSize, experiment.stft.hopSize, ); const bandBins = experiment.frequencyBands.map((band) => binsForBand(band, scene.sampleRate, experiment.stft.fftSize), ); const bandBinSelectionCount = bandBins.reduce((total, bins) => total + bins.length, 0); const selectedBinCount = new Set(bandBins.flat()).size; const binWidthHz = scene.sampleRate / experiment.stft.fftSize; const musicHypothesisCount = new Set( experiment.frequencyBands.map((band, bandIndex) => { const analogBin = Math.ceil(band.minHz / binWidthHz); const adcBin = bandBins[bandIndex]?.[0]; return `${analogBin}|${adcBin}`; }), ).size; const pointCount = experiment.scan.columns * experiment.scan.rows; const microphoneCount = arrayDefinition.microphones.length; const algorithmDefinition = localizationAlgorithmDefinition(algorithm); const referencePairCount = Math.max(0, microphoneCount - 1); const stftTransformCount = frameCount * microphoneCount; const dasComplexMacCount = pointCount * microphoneCount * frameCount * selectedBinCount; const gccCrossSpectrumComplexOps = 2 * referencePairCount * frameCount * bandBinSelectionCount; const gccScanScoreOps = pointCount * referencePairCount * bandBins.length; const inverseFftTransformCount = referencePairCount * bandBins.length; const musicSourceCount = experiment.beamforming?.musicSourceCount ?? 1; if ( algorithm === "music" && musicSourceCountMethod === "fixed" && ( !Number.isInteger(musicSourceCount) || musicSourceCount < 1 || musicSourceCount >= microphoneCount ) ) { throw new RangeError( `MUSIC source count K must be inside 1..${microphoneCount - 1}.`, ); } const musicNoiseSubspaceDimension = musicSourceCountMethod === "fixed" ? microphoneCount - musicSourceCount : microphoneCount; const musicCovarianceComplexAccumulations = musicHypothesisCount * frameCount * microphoneCount * (microphoneCount + 1) / 2; const musicJacobiWorstCasePairUpdates = musicHypothesisCount * 32 * (microphoneCount * (microphoneCount - 1) / 2) * (2 * microphoneCount - 2); const musicProjectionComplexMacs = musicHypothesisCount * pointCount * microphoneCount * musicNoiseSubspaceDimension; const musicTotalOperationCount = musicCovarianceComplexAccumulations + musicJacobiWorstCasePairUpdates + musicProjectionComplexMacs; const scanOperationCount = algorithm === "gcc-phat" ? gccScanScoreOps : algorithm === "music" ? musicTotalOperationCount : dasComplexMacCount; return { algorithm, algorithmLabel: algorithmDefinition?.label ?? algorithm, rawScale: algorithmDefinition?.rawScale ?? "power", pointCount, microphoneCount, frameCount, selectedBinCount, bandBinSelectionCount, minimumBandBinCount: Math.min(...bandBins.map((bins) => bins.length)), bandBins, stftTransformCount, referencePairCount, scanOperationCount, scanOperationKind: algorithm === "gcc-phat" ? "reference-pair lookup" : algorithm === "music" ? "covariance + Jacobi upper bound + projection" : "complex MAC", gccCrossSpectrumComplexOps: algorithm === "gcc-phat" ? gccCrossSpectrumComplexOps : 0, inverseFftTransformCount: algorithm === "gcc-phat" ? inverseFftTransformCount : 0, complexMacCount: algorithm === "gcc-phat" || algorithm === "music" ? 0 : dasComplexMacCount, musicSourceCountMethod, musicSourceCount, musicHypothesisCount, musicNoiseSubspaceDimension, musicCovarianceComplexAccumulations: algorithm === "music" ? musicCovarianceComplexAccumulations : 0, musicJacobiWorstCasePairUpdates: algorithm === "music" ? musicJacobiWorstCasePairUpdates : 0, musicProjectionComplexMacs: algorithm === "music" ? musicProjectionComplexMacs : 0, musicJacobiPairUpdateLimit: DEFAULT_MAXIMUM_MUSIC_JACOBI_PAIR_UPDATES, musicCovarianceAccumulationLimit: DEFAULT_MAXIMUM_MUSIC_COVARIANCE_COMPLEX_ACCUMULATIONS, musicProjectionMacLimit: DEFAULT_MAXIMUM_MUSIC_PROJECTION_COMPLEX_MACS, musicJacobiLimitExceeded: algorithm === "music" && musicJacobiWorstCasePairUpdates > DEFAULT_MAXIMUM_MUSIC_JACOBI_PAIR_UPDATES, scalingVs50: pointCount / 2500, distanceMiB: (pointCount * microphoneCount * (algorithm === "music" ? 8 : 4)) / 1024 ** 2, }; } export function shiftSceneDistance(scene, experiment, nextDistance) { const previousDistance = experiment.scan.z; const delta = nextDistance - previousDistance; experiment.scan.z = nextDistance; for (const source of scene.sources) { source.position[2] += delta; for (const keyframe of source.trajectory ?? []) keyframe.position[2] += delta; } if (scene.image?.plane) scene.image.plane.z += delta; if (scene.acoustics?.room) { scene.acoustics.room.min[2] += delta; scene.acoustics.room.max[2] += delta; } for (const occluder of scene.acoustics?.occluders ?? []) { occluder.min[2] += delta; occluder.max[2] += delta; } } function sourceGeometryPositions(scene) { return (scene.sources ?? []).flatMap((source) => { const candidates = [source.position, ...(source.trajectory ?? []).map(({ position }) => position)]; const uniquePositions = new Map(); for (const position of candidates) { if ( Array.isArray(position) && position.length === 3 && position.every(Number.isFinite) ) { uniquePositions.set(position.join(","), position); } } return [...uniquePositions.values()]; }); } export function alignExperimentToScene(experiment, scene) { const plane = scene.image?.plane; if (plane) { Object.assign(experiment.scan, { z: plane.z, xMin: plane.xMin, xMax: plane.xMax, yMin: plane.yMin, yMax: plane.yMax, }); return "image-plane"; } const positions = sourceGeometryPositions(scene); if (positions.length === 0) return "unchanged"; const paddedBounds = (values) => { const minimum = Math.min(...values); const maximum = Math.max(...values); const padding = Math.max(0.25, (maximum - minimum) * 0.15); return [minimum - padding, maximum + padding]; }; const median = (values) => { const sorted = [...values].sort((left, right) => left - right); const middle = Math.floor(sorted.length / 2); return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]; }; const [xMin, xMax] = paddedBounds(positions.map((position) => position[0])); const [yMin, yMax] = paddedBounds(positions.map((position) => position[1])); Object.assign(experiment.scan, { z: median(positions.map((position) => position[2])), xMin, xMax, yMin, yMax, }); return "source-bounds"; } function finiteMetricSubset(metrics = {}) { return Object.fromEntries( RESULT_METRIC_KEYS .filter((key) => Number.isFinite(metrics[key])) .map((key) => [key, metrics[key]]), ); } function beamSpreadForResult(spread) { if (!spread || typeof spread !== "object") return null; return { calibrated: false, method: spread.method, halfPowerRatio: Number(spread.halfPowerRatio), thresholdValue: Number(spread.thresholdValue), pixelCount: Number(spread.pixelCount), centroid: spread.centroid.map(Number), covarianceM2: { xx: Number(spread.covarianceM2.xx), xy: Number(spread.covarianceM2.xy), yy: Number(spread.covarianceM2.yy), }, ellipse: { axisScale: spread.ellipse.axisScale, angleReference: spread.ellipse.angleReference, majorRmsRadiusM: Number(spread.ellipse.majorRmsRadiusM), minorRmsRadiusM: Number(spread.ellipse.minorRmsRadiusM), angleDeg: Number(spread.ellipse.angleDeg), }, }; } function peakForResult(peak) { const result = { position: peak.position.map(Number), value: Number(peak.value), }; if (peak.sourceId) result.sourceId = peak.sourceId; const beamSpread = beamSpreadForResult(peak.beamSpread); if (beamSpread) result.beamSpread = beamSpread; if (Number.isFinite(peak.localizationErrorM)) result.localizationErrorM = peak.localizationErrorM; if (Number.isFinite(peak.localizationErrorPixels)) { result.localizationErrorPixels = peak.localizationErrorPixels; } return result; } function environmentForResult({ arrayDefinition, experiment, run }) { const environment = { userAgent: navigator.userAgent, requestedBackend: experiment.beamforming.backend, microphoneCount: arrayDefinition.microphones.length, gridPoints: experiment.scan.rows * experiment.scan.columns, }; for (const [key, value] of Object.entries({ gridRows: experiment.scan.rows, gridColumns: experiment.scan.columns, scanXMin: experiment.scan.xMin, scanXMax: experiment.scan.xMax, scanYMin: experiment.scan.yMin, scanYMax: experiment.scan.yMax, scanZ: experiment.scan.z, })) { if (Number.isFinite(value)) environment[key] = value; } if (run.fallback?.code) environment.fallbackCode = run.fallback.code; if (run.fallback?.message) environment.fallbackMessage = run.fallback.message; const adapter = run.gpu?.adapterInfo; if (adapter?.vendor) environment.gpuVendor = adapter.vendor; if (adapter?.architecture) environment.gpuArchitecture = adapter.architecture; if (adapter?.device) environment.gpuDevice = adapter.device; if (run.gpu?.timingKind) environment.gpuTimingKind = run.gpu.timingKind; if (Number.isInteger(run.frameCount) && run.frameCount >= 0) { environment.stftFrameCount = run.frameCount; } const referenceMic = run.referenceMic ?? run.referenceMicrophoneId ?? run.workload?.referenceMic ?? run.workload?.referenceMicrophoneId; if (typeof referenceMic === "string" || Number.isFinite(referenceMic)) { environment.referenceMic = referenceMic; } return environment; } function samplingForResult(sampling) { if (!sampling || typeof sampling !== "object") return null; return { model: sampling.model, sampleRate: sampling.sampleRate, nyquistHz: sampling.nyquistHz, toneObservations: (sampling.toneObservations ?? []).map((observation) => ({ scope: observation.scope, id: observation.id, analogFrequencyHz: observation.analogFrequencyHz, adcFrequencyHz: observation.adcFrequencyHz, signedAdcFrequencyHz: observation.signedAdcFrequencyHz, aliased: observation.aliased, spectralInversion: observation.spectralInversion, dcEdge: observation.dcEdge, nyquistEdge: observation.nyquistEdge, phaseDegenerate: observation.phaseDegenerate, })), }; } function frequencyObservationForResult(scene, experiment, band, algorithm) { if ( !Number.isFinite(scene?.sampleRate) || !Number.isInteger(experiment?.stft?.fftSize) || !Number.isFinite(band?.minHz) || !Number.isFinite(band?.maxHz) ) { return null; } const observation = observeFrequencyBand( band, scene.sampleRate, experiment.stft.fftSize, ); const music = algorithm === "music"; const analogBin = Math.ceil( band.minHz / observation.binWidthHz, ); const configuredAnalogFrequencyHz = analogBin * observation.binWidthHz; const configuredAlias = music ? describeFrequencyAliasing( configuredAnalogFrequencyHz, scene.sampleRate, ) : null; return { model: observation.model, sampleRate: observation.sampleRate, nyquistHz: observation.nyquistHz, fftSize: observation.fftSize, binWidthHz: observation.binWidthHz, requestedMinHz: observation.requestedMinHz, requestedMaxHz: observation.requestedMaxHz, aliased: observation.aliased, crossesFirstNyquist: observation.crossesFirstNyquist, localizationInputModel: observation.localizationInputModel, selectionModel: observation.selectionModel, localizationModel: algorithm === "gcc-phat" ? "band-limited-gcc-phat-geometric-lag" : music ? "near-field-music-at-configured-analog-frequency" : "spherical-steering-at-positive-adc-bin-frequency", originalCarrierUsed: music, aliasOrderResolution: music && observation.aliased ? "configured-single-hypothesis" : observation.aliased ? "not-attempted" : "not-applicable", ...(music ? { configuredAnalogFrequencyHz, signedAdcFrequencyHz: configuredAlias.signedAdcFrequencyHz, spectralInversion: configuredAlias.spectralInversion, frequencyHypothesisCount: 1, frequencyHypothesisSource: "experiment-band-not-scene-truth", } : {}), foldBoundaryCount: observation.foldBoundaryCount, continuousManyToOne: observation.continuousManyToOne, gridManyToOne: observation.gridManyToOne, foldSegments: observation.foldSegments.map((segment) => ({ ...segment })), adcBins: [...observation.bins], adcBinCenterRanges: observation.adcBinCenterRanges.map((range) => ({ ...range })), adcBinSetKey: observation.adcBinSetKey, equivalentBandIds: [], }; } export function createResultDocument(options) { const { arrayDefinition, scene, experiment, run, evaluations, inputHash, createdAt = new Date().toISOString(), } = options; const algorithm = run.algorithm ?? experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM; const rawScale = localizationAlgorithmDefinition(algorithm)?.rawScale ?? "power"; const sampling = samplingForResult(run.signalStats.sampling); const frequencyObservations = new Map(); for (const definition of experiment.frequencyBands) { const observation = frequencyObservationForResult( scene, experiment, definition, algorithm, ); if (observation) frequencyObservations.set(definition.id, observation); } const equivalentGroups = new Map(); for (const [bandId, observation] of frequencyObservations) { const group = equivalentGroups.get(observation.adcBinSetKey) ?? []; group.push(bandId); equivalentGroups.set(observation.adcBinSetKey, group); } for (const [bandId, observation] of frequencyObservations) { observation.equivalentBandIds = equivalentGroups .get(observation.adcBinSetKey) .filter((candidateId) => candidateId !== bandId) .sort(); } return { schemaVersion: RESULT_SCHEMA_VERSION, kind: "micarrsim.result", experimentId: experiment.id, arrayId: arrayDefinition.id, sceneId: scene.id, createdAt, algorithm, backend: run.backend, inputHash, signalStats: { preNormalizationPeak: run.signalStats.preNormalizationPeak, appliedScale: run.signalStats.appliedScale, clippedSampleCount: run.signalStats.clippedSampleCount, ...(run.signalStats.acousticModel ? { acousticModel: run.signalStats.acousticModel } : {}), ...(Number.isInteger(run.signalStats.acousticPathCount) ? { acousticPathCount: run.signalStats.acousticPathCount } : {}), ...(run.signalStats.acousticPathCounts ? { acousticPathCounts: { ...run.signalStats.acousticPathCounts } } : {}), ...(sampling ? { sampling } : {}), }, timingMs: { total: run.timingMs.total, signalGeneration: run.timingMs.signalGeneration, localization: run.timingMs.localization ?? run.timingMs.beamforming, beamforming: run.timingMs.beamforming ?? run.timingMs.localization, }, environment: environmentForResult({ arrayDefinition, experiment, run }), ...(algorithm === "music" && run.model ? { musicModel: sortForCanonicalJson(run.model) } : {}), bands: run.bands.map((band) => { const evaluation = evaluations.get(band.id) ?? { peaks: [], metrics: {} }; const bandRawScale = band.rawScale ?? rawScale; const objectiveDirection = band.objectiveDirection ?? (bandRawScale === "noise-projection" ? "minimum" : "maximum"); const bandResult = { id: band.id, color: band.color, rawMap: { rows: experiment.scan.rows, columns: experiment.scan.columns, scale: bandRawScale, objectiveDirection, values: Array.from(band.rawMap), }, displayMap: { rows: experiment.scan.rows, columns: experiment.scan.columns, scale: band.displayScale, objectiveDirection: band.displayObjectiveDirection ?? objectiveDirection, values: Array.from(band.displayMap), }, peaks: evaluation.peaks.map(peakForResult), metrics: finiteMetricSubset(evaluation.metrics), }; const frequencyObservation = frequencyObservations.get(band.id); if (frequencyObservation) bandResult.frequencyObservation = frequencyObservation; if (algorithm === "music" && band.diagnostics) { bandResult.musicDiagnostics = sortForCanonicalJson(band.diagnostics); } if (evaluation.evaluation) { bandResult.evaluation = { ...evaluation.evaluation, truthSelection: "tone-analog-interval-or-nearest-adc-bin-and-all-nontone-sources", }; } return bandResult; }), }; } export function createHistorySummary({ arrayDefinition, scene, experiment, run, evaluations, inputHash }) { const firstBand = run.bands[0]; const evaluation = evaluations.get(firstBand.id); const firstMatchedPeak = evaluation?.peaks.find((peak) => peak.sourceId) ?? evaluation?.peaks[0]; return { id: `${Date.now()}-${inputHash.slice(0, 8)}`, createdAt: new Date().toISOString(), arrayId: arrayDefinition.id, arrayName: arrayDefinition.name, sceneId: scene.id, experimentId: experiment.id, algorithm: run.algorithm ?? experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM, backend: run.backend, pointCount: experiment.scan.rows * experiment.scan.columns, totalMs: run.timingMs.total, localizationMs: run.timingMs.localization ?? run.timingMs.beamforming, localizationErrorM: firstMatchedPeak?.localizationErrorM ?? null, peakToSidelobeDb: evaluation?.metrics?.peakToSidelobeDb ?? null, inputHash, }; } export function safeFileStem(value, fallback = "micarrsim") { return String(value || fallback) .toLowerCase() .normalize("NFKD") .replace(/[^a-z0-9._-]+/g, "-") .replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, "") || fallback; }