Files

612 lines
21 KiB
JavaScript

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;
}