import { getFrameCount, binsForBand } from "/packages/acoustics-core/stft.js"; const RESULT_METRIC_KEYS = ["peakToSidelobeDb", "energyInsideTruthRatio", "precision", "recall"]; 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; } function finiteMetricSubset(metrics = {}) { return Object.fromEntries( RESULT_METRIC_KEYS .filter((key) => Number.isFinite(metrics[key])) .map((key) => [key, metrics[key]]), ); } function peakForResult(peak) { const result = { position: peak.position.map(Number), value: Number(peak.value), }; if (peak.sourceId) result.sourceId = peak.sourceId; if (Number.isFinite(peak.localizationErrorM)) result.localizationErrorM = peak.localizationErrorM; 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, }; 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: {} }; return { 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), }; }), }; } 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)}`, 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; }