419 lines
16 KiB
JavaScript
419 lines
16 KiB
JavaScript
import {
|
|
createAnalysisFramePlan,
|
|
playbackFrameAtTime,
|
|
sceneHasTrajectories,
|
|
scenePlaybackState,
|
|
} from "/apps/acoustic-camera/timeline.js";
|
|
|
|
import {
|
|
alignExperimentToScene,
|
|
canonicalStringify,
|
|
createResultDocument,
|
|
estimateExperimentWorkload,
|
|
hashSimulationInputs,
|
|
shiftSceneDistance,
|
|
} from "/apps/acoustic-camera/model.js";
|
|
import { validateResult } from "/packages/contracts/validation.js";
|
|
|
|
function assertNear(actual, expected, tolerance, label) {
|
|
assert(
|
|
Math.abs(actual - expected) <= tolerance,
|
|
`${label}: expected ${expected} ± ${tolerance}, received ${actual}`,
|
|
);
|
|
}
|
|
|
|
function assertThrows(callback, expectedText, label) {
|
|
let error;
|
|
try {
|
|
callback();
|
|
} catch (caught) {
|
|
error = caught;
|
|
}
|
|
assert(error instanceof Error, `${label}: expected an error`);
|
|
assert(error.message.includes(expectedText), `${label}: unexpected message ${error.message}`);
|
|
}
|
|
|
|
test("Camera detects moving source trajectories", () => {
|
|
assertEqual(sceneHasTrajectories(null), false, "missing scene");
|
|
assertEqual(sceneHasTrajectories({ sources: [{ id: "fixed", position: [0, 0, 2] }] }), false, "fixed scene");
|
|
assertEqual(sceneHasTrajectories({
|
|
sources: [{
|
|
id: "moving",
|
|
position: [0, 0, 2],
|
|
trajectory: [
|
|
{ time: 0, position: [0, 0, 2] },
|
|
{ time: 1, position: [1, 0, 2] },
|
|
],
|
|
}],
|
|
}), true, "moving scene");
|
|
});
|
|
|
|
test("Camera analysis plan covers a scene and respects its frame budget", () => {
|
|
const scene = { duration: 2, sampleRate: 1000, sources: [] };
|
|
const options = { fftSize: 100, targetMapHz: 4, maxFrames: 3 };
|
|
const first = createAnalysisFramePlan(scene, options);
|
|
const second = createAnalysisFramePlan(scene, options);
|
|
|
|
assertEqual(first.frameCount, 3, "frame count is capped");
|
|
assertNear(first.minimumWindowDuration, 0.1, 1e-12, "minimum FFT window");
|
|
assertNear(first.windowDuration, 2 / 3, 1e-12, "windows grow to cover capped timeline");
|
|
assertEqual(first.frames[0].startTime, 0, "first frame begins at zero");
|
|
assertEqual(first.frames.at(-1).endTime, 2, "last frame reaches scene end");
|
|
first.frames.forEach((frame, index) => {
|
|
assertEqual(frame.index, index, `frame ${index} index`);
|
|
assertNear(frame.sampleTime, (frame.startTime + frame.endTime) / 2, 1e-12, `frame ${index} midpoint`);
|
|
if (index > 0) {
|
|
assert(frame.startTime <= first.frames[index - 1].endTime, `frame ${index} leaves no timeline gap`);
|
|
}
|
|
});
|
|
assertEqual(JSON.stringify(second), JSON.stringify(first), "frame planning is deterministic");
|
|
});
|
|
|
|
test("Camera analysis windows obey FFT and short-scene constraints", () => {
|
|
const fftLimited = createAnalysisFramePlan(
|
|
{ duration: 1, sampleRate: 1000, sources: [] },
|
|
{ fftSize: 400, targetMapHz: 10, maxFrames: 100 },
|
|
);
|
|
assertEqual(fftLimited.frameCount, 10, "requested timeline frames");
|
|
assertNear(fftLimited.windowDuration, 0.4, 1e-12, "FFT-sized window");
|
|
assertEqual(fftLimited.frames[0].startTime, 0, "overlapping first window");
|
|
assertEqual(fftLimited.frames.at(-1).endTime, 1, "overlapping last window");
|
|
assert(fftLimited.frames.every((frame) => frame.duration >= 0.4 - 1e-12), "no window is shorter than one FFT");
|
|
|
|
const shortScene = createAnalysisFramePlan(
|
|
{ duration: 0.05, sampleRate: 1000, sources: [] },
|
|
{ fftSize: 100, targetMapHz: 100, maxFrames: 50 },
|
|
);
|
|
assertEqual(shortScene.frameCount, 1, "short scene avoids duplicate frames");
|
|
assertEqual(shortScene.windowDuration, 0.05, "window is bounded by scene duration");
|
|
assertEqual(shortScene.zeroPaddingRequired, true, "short scene reports FFT padding requirement");
|
|
assertNear(shortScene.frames[0].sampleTime, 0.025, 1e-12, "short scene midpoint");
|
|
});
|
|
|
|
test("Camera playback selects and interpolates bounded analysis frames", () => {
|
|
const plan = createAnalysisFramePlan(
|
|
{ duration: 1, sampleRate: 1000, sources: [] },
|
|
{ fftSize: 100, targetMapHz: 4, maxFrames: 10 },
|
|
);
|
|
const middle = playbackFrameAtTime(plan, 0.5);
|
|
assertEqual(middle.leftFrameIndex, 1, "middle left frame");
|
|
assertEqual(middle.rightFrameIndex, 2, "middle right frame");
|
|
assertEqual(middle.frameIndex, 2, "nearest frame resolves midpoint ties forward");
|
|
assertNear(middle.progress, 0.5, 1e-12, "middle interpolation progress");
|
|
assertNear(middle.framePosition, 1.5, 1e-12, "fractional frame position");
|
|
|
|
const before = playbackFrameAtTime(plan, -10);
|
|
assertEqual(before.time, 0, "negative playback time is clamped");
|
|
assertEqual(before.frameIndex, 0, "negative playback selects first frame");
|
|
const after = playbackFrameAtTime(plan, 10);
|
|
assertEqual(after.time, 1, "late playback time is clamped");
|
|
assertEqual(after.frameIndex, 3, "late playback selects last frame");
|
|
assertEqual(after.sceneProgress, 1, "late playback progress");
|
|
});
|
|
|
|
test("Camera playback resolves moving positions without mutating the scene", () => {
|
|
const movingSource = {
|
|
id: "moving",
|
|
position: [99, 99, 99],
|
|
trajectory: [
|
|
{ time: 0, position: [0, 0, 2] },
|
|
{ time: 1, position: [4, 2, 2] },
|
|
],
|
|
};
|
|
const fixedSource = { id: "fixed", position: [-1, 1, 3] };
|
|
const scene = { duration: 1, sampleRate: 1000, sources: [movingSource, fixedSource] };
|
|
const plan = createAnalysisFramePlan(scene, { fftSize: 100, targetMapHz: 4 });
|
|
const state = scenePlaybackState(scene, plan, 0.25);
|
|
|
|
assertNear(state.scene.sources[0].position[0], 1, 1e-12, "moving x");
|
|
assertNear(state.scene.sources[0].position[1], 0.5, 1e-12, "moving y");
|
|
assertEqual(state.scene.sources[1].position.join(","), "-1,1,3", "fixed source");
|
|
assertEqual(state.sourcePositions[0].sourceId, "moving", "position metadata source id");
|
|
assertEqual(state.sourcePositions[0].position.join(","), "1,0.5,2", "position metadata value");
|
|
assertEqual(movingSource.position.join(","), "99,99,99", "input source remains unchanged");
|
|
assert(state.scene !== scene && state.scene.sources[0] !== movingSource, "sampled scene is immutable");
|
|
});
|
|
|
|
test("Camera timeline rejects non-finite and invalid planning inputs", () => {
|
|
const scene = { duration: 1, sampleRate: 1000, sources: [] };
|
|
assertThrows(
|
|
() => createAnalysisFramePlan({ ...scene, duration: Number.NaN }, { fftSize: 100 }),
|
|
"scene.duration",
|
|
"NaN duration",
|
|
);
|
|
assertThrows(
|
|
() => createAnalysisFramePlan(scene, { fftSize: Number.POSITIVE_INFINITY }),
|
|
"fftSize",
|
|
"infinite FFT size",
|
|
);
|
|
assertThrows(
|
|
() => createAnalysisFramePlan(scene, { fftSize: 100, targetMapHz: 0 }),
|
|
"targetMapHz",
|
|
"zero map rate",
|
|
);
|
|
assertThrows(
|
|
() => createAnalysisFramePlan(scene, { fftSize: 100, maxFrames: 1.5 }),
|
|
"maxFrames",
|
|
"fractional frame limit",
|
|
);
|
|
assertThrows(
|
|
() => createAnalysisFramePlan(scene, { fftSize: 100, windowDuration: Number.NaN }),
|
|
"windowDuration",
|
|
"NaN analysis window",
|
|
);
|
|
const plan = createAnalysisFramePlan(scene, { fftSize: 100 });
|
|
assertThrows(() => playbackFrameAtTime(plan, Number.NaN), "time", "NaN playback time");
|
|
assertThrows(
|
|
() => scenePlaybackState({ ...scene, duration: 2 }, plan, 0.5),
|
|
"scene.duration",
|
|
"scene and plan mismatch",
|
|
);
|
|
});
|
|
|
|
test("Camera input hash is canonical across object key order", async () => {
|
|
const first = {
|
|
arrayDefinition: {
|
|
id: "array",
|
|
microphones: [
|
|
{ id: "mic-1", position: [0, 0, 0] },
|
|
{ id: "mic-2", position: [0.1, 0, 0] },
|
|
],
|
|
},
|
|
scene: {
|
|
id: "scene",
|
|
sampleRate: 8000,
|
|
sources: [{ id: "source", position: [0, 0, 2], signal: { type: "tone", frequency: 1000 } }],
|
|
},
|
|
experiment: {
|
|
id: "experiment",
|
|
scan: { z: 2, columns: 8, rows: 8 },
|
|
frequencyBands: [{ id: "band", minHz: 900, maxHz: 1100 }],
|
|
},
|
|
};
|
|
const reordered = {
|
|
experiment: {
|
|
frequencyBands: [{ maxHz: 1100, minHz: 900, id: "band" }],
|
|
scan: { rows: 8, columns: 8, z: 2 },
|
|
id: "experiment",
|
|
},
|
|
scene: {
|
|
sources: [{ signal: { frequency: 1000, type: "tone" }, position: [0, 0, 2], id: "source" }],
|
|
sampleRate: 8000,
|
|
id: "scene",
|
|
},
|
|
arrayDefinition: {
|
|
microphones: [
|
|
{ position: [0, 0, 0], id: "mic-1" },
|
|
{ position: [0.1, 0, 0], id: "mic-2" },
|
|
],
|
|
id: "array",
|
|
},
|
|
};
|
|
|
|
assertEqual(canonicalStringify({ z: undefined, b: 2, a: { d: 4, c: 3 } }), '{"a":{"c":3,"d":4},"b":2}', "canonical JSON");
|
|
const firstHash = await hashSimulationInputs(first);
|
|
const reorderedHash = await hashSimulationInputs(reordered);
|
|
assertEqual(firstHash, reorderedHash, "key ordering does not alter hash");
|
|
assert(/^[a-f0-9]{64}$/.test(firstHash), "hash is lowercase SHA-256");
|
|
|
|
reordered.scene.sources[0].position[0] = 0.01;
|
|
const changedHash = await hashSimulationInputs(reordered);
|
|
assert(changedHash !== firstHash, "semantic input change alters hash");
|
|
});
|
|
|
|
test("Camera workload counts unique overlapping FFT bins", () => {
|
|
const arrayDefinition = {
|
|
microphones: Array.from({ length: 3 }, (_, index) => ({ id: `mic-${index}`, position: [index, 0, 0] })),
|
|
};
|
|
const scene = { sampleRate: 8000, duration: 1 };
|
|
const experiment = {
|
|
scan: { columns: 10, rows: 8 },
|
|
stft: { fftSize: 256, hopSize: 128 },
|
|
frequencyBands: [
|
|
{ id: "lower", minHz: 100, maxHz: 200 },
|
|
{ id: "upper", minHz: 187.5, maxHz: 250 },
|
|
],
|
|
};
|
|
|
|
const workload = estimateExperimentWorkload(arrayDefinition, scene, experiment);
|
|
assertEqual(workload.pointCount, 80, "scan point count");
|
|
assertEqual(workload.microphoneCount, 3, "microphone count");
|
|
assertEqual(workload.frameCount, 62, "STFT frame count");
|
|
assertEqual(JSON.stringify(workload.bandBins), JSON.stringify([[4, 5, 6], [6, 7, 8]]), "bins per band");
|
|
assertEqual(workload.selectedBinCount, 5, "overlapping bin is counted once");
|
|
assertEqual(workload.complexMacCount, 74_400, "estimated complex MAC count");
|
|
assertNear(workload.scalingVs50, 80 / 2500, 1e-15, "50x50 scaling");
|
|
assertNear(workload.distanceMiB, (80 * 3 * 4) / 1024 ** 2, 1e-15, "distance table size");
|
|
});
|
|
|
|
test("Camera distance shift keeps the scene and scan on one z plane", () => {
|
|
const scene = {
|
|
sources: [
|
|
{ id: "one", position: [0.1, -0.2, 2] },
|
|
{ id: "two", position: [-0.3, 0.4, 2.5] },
|
|
],
|
|
image: { plane: { z: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1 } },
|
|
};
|
|
const experiment = { scan: { z: 2 } };
|
|
|
|
shiftSceneDistance(scene, experiment, 3.25);
|
|
|
|
assertEqual(experiment.scan.z, 3.25, "scan z");
|
|
assertEqual(scene.sources[0].position.join(","), "0.1,-0.2,3.25", "first source z shift");
|
|
assertEqual(scene.sources[1].position.join(","), "-0.3,0.4,3.75", "relative source depth retained");
|
|
assertEqual(scene.image.plane.z, 3.25, "background image plane z shift");
|
|
});
|
|
|
|
test("Camera aligns an experiment to an imported scene without changing grid resolution", () => {
|
|
const experiment = {
|
|
scan: { z: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, columns: 32, rows: 24 },
|
|
};
|
|
const imageScene = {
|
|
sources: [{ id: "one", position: [0.2, -0.1, 4.5] }],
|
|
image: { plane: { z: 4.5, xMin: -2, xMax: 3, yMin: -1.5, yMax: 2.5 } },
|
|
};
|
|
|
|
assertEqual(alignExperimentToScene(experiment, imageScene), "image-plane", "alignment source");
|
|
assertEqual(
|
|
JSON.stringify(experiment.scan),
|
|
JSON.stringify({
|
|
z: 4.5,
|
|
xMin: -2,
|
|
xMax: 3,
|
|
yMin: -1.5,
|
|
yMax: 2.5,
|
|
columns: 32,
|
|
rows: 24,
|
|
}),
|
|
"image plane becomes scan plane while rows and columns stay unchanged",
|
|
);
|
|
|
|
const sourceScene = {
|
|
sources: [
|
|
{ id: "one", position: [-0.5, 0.1, 3] },
|
|
{ id: "two", position: [0.5, 0.2, 5] },
|
|
],
|
|
};
|
|
assertEqual(alignExperimentToScene(experiment, sourceScene), "source-bounds", "source fallback");
|
|
assertEqual(experiment.scan.z, 4, "median source depth");
|
|
assertNear(experiment.scan.xMin, -0.75, 1e-12, "source x minimum with padding");
|
|
assertNear(experiment.scan.xMax, 0.75, 1e-12, "source x maximum with padding");
|
|
assertNear(experiment.scan.yMin, -0.15, 1e-12, "source y minimum with padding");
|
|
assertNear(experiment.scan.yMax, 0.45, 1e-12, "source y maximum with padding");
|
|
assertEqual(experiment.scan.columns, 32, "columns retained after source fallback");
|
|
assertEqual(experiment.scan.rows, 24, "rows retained after source fallback");
|
|
});
|
|
|
|
test("Camera result document is schema-safe and linked to its snapshot", async () => {
|
|
const arrayDefinition = {
|
|
id: "test-array",
|
|
name: "Test array",
|
|
microphones: [
|
|
{ id: "mic-1", position: [0, 0, 0] },
|
|
{ id: "mic-2", position: [0.1, 0, 0] },
|
|
],
|
|
};
|
|
const scene = {
|
|
id: "test-scene",
|
|
sources: [{ id: "source-1", position: [0, 0, 2] }],
|
|
};
|
|
const experiment = {
|
|
id: "test-experiment",
|
|
arrayId: arrayDefinition.id,
|
|
sceneId: scene.id,
|
|
scan: { rows: 2, columns: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2 },
|
|
beamforming: { backend: "auto" },
|
|
frequencyBands: [{ id: "band-1", color: "#65f2bf" }],
|
|
};
|
|
const run = {
|
|
backend: "cpu",
|
|
signalStats: { preNormalizationPeak: 0.75, appliedScale: 1, clippedSampleCount: 0 },
|
|
timingMs: { total: 12.5, signalGeneration: 2.5, beamforming: 10 },
|
|
fallback: { code: "webgpu-unavailable", message: "CPU fallback" },
|
|
bands: [{
|
|
id: "band-1",
|
|
color: "#65f2bf",
|
|
rawMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
|
displayMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
|
displayScale: "normalized",
|
|
}],
|
|
};
|
|
const evaluations = new Map([["band-1", {
|
|
peaks: [{
|
|
index: 3,
|
|
sourceId: "source-1",
|
|
position: [1, -1, 2],
|
|
value: 1,
|
|
localizationErrorM: 0.05,
|
|
localizationErrorPixels: 0.1,
|
|
beamSpread: {
|
|
calibrated: false,
|
|
method: "connected-half-power-power-weighted-covariance",
|
|
halfPowerRatio: 0.5,
|
|
thresholdValue: 0.5,
|
|
pixelCount: 3,
|
|
centroid: [0.9, -0.9, 2],
|
|
covarianceM2: { xx: 0.04, xy: 0.01, yy: 0.02 },
|
|
ellipse: {
|
|
axisScale: "rms",
|
|
angleReference: "counterclockwise-from-positive-x",
|
|
majorRmsRadiusM: 0.21,
|
|
minorRmsRadiusM: 0.12,
|
|
angleDeg: 22.5,
|
|
},
|
|
},
|
|
}],
|
|
metrics: {
|
|
peakToSidelobeDb: 10,
|
|
energyInsideTruthRatio: 0.75,
|
|
precision: 1,
|
|
recall: 1,
|
|
mainLobeWidthM: 0.2,
|
|
ignoredNonFinite: Number.NaN,
|
|
},
|
|
evaluation: {
|
|
version: "1.0.0",
|
|
truthRadiusM: 0.1,
|
|
matchToleranceM: 0.2,
|
|
detectionThresholdRatio: 0.5,
|
|
detectionThresholdValue: 0.25,
|
|
minimumPeakDistanceM: 0.1,
|
|
sidelobeExclusionRadiusM: 0.1,
|
|
halfPowerRatio: 0.5,
|
|
dynamicRangeDb: 30,
|
|
maximumDetections: 128,
|
|
},
|
|
}]]);
|
|
|
|
const result = createResultDocument({
|
|
arrayDefinition,
|
|
scene,
|
|
experiment,
|
|
run,
|
|
evaluations,
|
|
inputHash: "a".repeat(64),
|
|
createdAt: "2026-08-28T00:00:00.000Z",
|
|
});
|
|
|
|
assert(Array.isArray(result.bands[0].rawMap.values), "typed raw map becomes JSON array");
|
|
assert(!("index" in result.bands[0].peaks[0]), "internal peak index is omitted");
|
|
assertEqual(
|
|
Object.keys(result.bands[0].metrics).join(","),
|
|
"peakToSidelobeDb,energyInsideTruthRatio,precision,recall,mainLobeWidthM",
|
|
"only finite schema metrics are exported",
|
|
);
|
|
assertEqual(result.bands[0].peaks[0].localizationErrorPixels, 0.1, "pixel error is exported");
|
|
assertEqual(result.bands[0].peaks[0].beamSpread.pixelCount, 3, "beam spread is exported");
|
|
assertEqual(result.bands[0].peaks[0].beamSpread.calibrated, false, "beam spread is not probability");
|
|
assertEqual(result.bands[0].evaluation.matchToleranceM, 0.2, "evaluation settings are exported");
|
|
assertEqual(result.environment.scanXMin, -1, "scan bounds are recorded");
|
|
assertEqual(result.environment.fallbackCode, "webgpu-unavailable", "fallback is recorded");
|
|
|
|
const issues = await validateResult(result, { experiment, scene });
|
|
assertEqual(issues.length, 0, `generated result validation: ${JSON.stringify(issues)}`);
|
|
|
|
const wrongExperiment = { ...experiment, arrayId: "another-array" };
|
|
const linkageIssues = await validateResult(result, { experiment: wrongExperiment, scene });
|
|
assert(linkageIssues.some(({ path }) => path === "$.arrayId"), "validator catches snapshot linkage mismatch");
|
|
});
|