import { getFrameCount, binsForBand } from "/packages/acoustics-core/stft.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 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 frameCount = getFrameCount( Math.ceil(scene.duration * scene.sampleRate), experiment.stft.fftSize, experiment.stft.hopSize, ); const bandBins = experiment.frequencyBands.map((band) => binsForBand(band, scene.sampleRate, experiment.stft.fftSize), ); const selectedBinCount = new Set(bandBins.flat()).size; const pointCount = experiment.scan.columns * experiment.scan.rows; const microphoneCount = arrayDefinition.microphones.length; const complexMacCount = pointCount * microphoneCount * frameCount * selectedBinCount; return { pointCount, microphoneCount, frameCount, selectedBinCount, bandBins, complexMacCount, scalingVs50: pointCount / 2500, distanceMiB: (pointCount * microphoneCount * 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; if (scene.image?.plane) scene.image.plane.z += delta; } 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 = (scene.sources ?? []) .map((source) => source.position) .filter((position) => Array.isArray(position) && position.length === 3 && position.every(Number.isFinite), ); 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; return environment; } export function createResultDocument(options) { const { arrayDefinition, scene, experiment, run, evaluations, inputHash, createdAt = new Date().toISOString(), } = options; return { schemaVersion: "1.0.0", kind: "micarrsim.result", experimentId: experiment.id, arrayId: arrayDefinition.id, sceneId: scene.id, createdAt, backend: run.backend, inputHash, signalStats: { preNormalizationPeak: run.signalStats.preNormalizationPeak, appliedScale: run.signalStats.appliedScale, clippedSampleCount: run.signalStats.clippedSampleCount, }, timingMs: { total: run.timingMs.total, signalGeneration: run.timingMs.signalGeneration, beamforming: run.timingMs.beamforming, }, environment: environmentForResult({ arrayDefinition, experiment, run }), bands: run.bands.map((band) => { const evaluation = evaluations.get(band.id) ?? { peaks: [], metrics: {} }; const bandResult = { id: band.id, color: band.color, rawMap: { rows: experiment.scan.rows, columns: experiment.scan.columns, scale: "power", values: Array.from(band.rawMap), }, displayMap: { rows: experiment.scan.rows, columns: experiment.scan.columns, scale: band.displayScale, values: Array.from(band.displayMap), }, peaks: evaluation.peaks.map(peakForResult), metrics: finiteMetricSubset(evaluation.metrics), }; if (evaluation.evaluation) bandResult.evaluation = { ...evaluation.evaluation }; 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, backend: run.backend, pointCount: experiment.scan.rows * experiment.scan.columns, totalMs: run.timingMs.total, 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; }