Files
micarrsim/tests/unit/camera.test.js
T
2026-08-28 16:02:03 +09:00

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