183 lines
6.4 KiB
JavaScript
183 lines
6.4 KiB
JavaScript
import {
|
|
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 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 },
|
|
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 }],
|
|
metrics: {
|
|
peakToSidelobeDb: 10,
|
|
energyInsideTruthRatio: 0.75,
|
|
precision: 1,
|
|
recall: 1,
|
|
mainLobeWidthM: 0.2,
|
|
ignoredNonFinite: Number.NaN,
|
|
},
|
|
}]]);
|
|
|
|
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",
|
|
"only finite schema metrics are exported",
|
|
);
|
|
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");
|
|
});
|