274 lines
8.9 KiB
JavaScript
274 lines
8.9 KiB
JavaScript
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;
|
|
}
|