Files
micarrsim/tests/unit/camera.test.js
T
2026-08-28 12:34:11 +09:00

246 lines
8.7 KiB
JavaScript

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}`,
);
}
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,
}],
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].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");
});