feat: complete acoustic camera workflow
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import { validateResult } from "/packages/contracts/validation.js";
|
||||
|
||||
async function waitFor(predicate, label, timeoutMs = 30000) {
|
||||
const deadline = performance.now() + timeoutMs;
|
||||
while (performance.now() < deadline) {
|
||||
const value = predicate();
|
||||
if (value) return value;
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
}
|
||||
throw new Error(`${label} 대기 시간이 초과되었습니다.`);
|
||||
}
|
||||
|
||||
function dispatchValue(windowObject, element, value, eventName = "change") {
|
||||
element.value = String(value);
|
||||
element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true }));
|
||||
}
|
||||
|
||||
async function loadJson(path) {
|
||||
const response = await fetch(path);
|
||||
assert(response.ok, `${path} load failed`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function nextPaint(windowObject) {
|
||||
await new Promise((resolve) => windowObject.requestAnimationFrame(() =>
|
||||
windowObject.requestAnimationFrame(resolve)));
|
||||
}
|
||||
|
||||
test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름을 완주한다", async () => {
|
||||
const frame = document.createElement("iframe");
|
||||
frame.src = "/apps/acoustic-camera/?integration-test=1";
|
||||
frame.style.cssText = "position:fixed;left:-20000px;top:0;width:1400px;height:1000px;border:0";
|
||||
document.body.append(frame);
|
||||
await new Promise((resolve, reject) => {
|
||||
frame.addEventListener("load", resolve, { once: true });
|
||||
frame.addEventListener("error", () => reject(new Error("Acoustic Camera iframe load failed")), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
|
||||
const windowObject = frame.contentWindow;
|
||||
const documentObject = frame.contentDocument;
|
||||
const downloads = [];
|
||||
const objectUrls = [];
|
||||
const originalAnchorClick = windowObject.HTMLAnchorElement.prototype.click;
|
||||
const originalRevoke = windowObject.URL.revokeObjectURL.bind(windowObject.URL);
|
||||
windowObject.HTMLAnchorElement.prototype.click = function captureDownload() {
|
||||
downloads.push({ fileName: this.download, url: this.href });
|
||||
};
|
||||
windowObject.URL.revokeObjectURL = (url) => objectUrls.push(url);
|
||||
|
||||
try {
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.appReady === "true",
|
||||
"Acoustic Camera 초기화",
|
||||
);
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.cameraValid === "true",
|
||||
"기본 Camera 입력 검증",
|
||||
);
|
||||
|
||||
const canvas = documentObject.querySelector("#scene-canvas");
|
||||
await waitFor(() => canvas.width > 0 && canvas.height > 0, "scene canvas 초기 렌더");
|
||||
await nextPaint(windowObject);
|
||||
const initialCanvas = canvas.toDataURL();
|
||||
|
||||
dispatchValue(windowObject, documentObject.querySelector("#scan-columns"), 8);
|
||||
dispatchValue(windowObject, documentObject.querySelector("#scan-rows"), 8);
|
||||
dispatchValue(windowObject, documentObject.querySelector("#backend"), "cpu");
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.cameraValid === "true" &&
|
||||
!documentObject.querySelector("#run-button").disabled,
|
||||
"8×8 CPU 입력 검증",
|
||||
);
|
||||
assertEqual(documentObject.querySelector("#grid-preset").value, "custom", "custom grid preset");
|
||||
|
||||
documentObject.querySelector("#run-button").click();
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.runState === "done",
|
||||
"8×8 CPU simulation",
|
||||
30000,
|
||||
);
|
||||
assertEqual(documentObject.documentElement.dataset.runBackend, "cpu", "actual backend");
|
||||
assertEqual(documentObject.documentElement.dataset.resultBandCount, "1", "result band count");
|
||||
assert(documentObject.querySelectorAll("#metric-grid .metric-card").length >= 9, "result metrics rendered");
|
||||
assert(!documentObject.querySelector("#metric-grid").classList.contains("is-hidden"), "metrics visible");
|
||||
assert(documentObject.querySelector("#result-badge").textContent.includes("CPU"), "backend badge rendered");
|
||||
await nextPaint(windowObject);
|
||||
assert(canvas.toDataURL() !== initialCanvas, "result heatmap changed the scene canvas");
|
||||
|
||||
documentObject.querySelector("#export-result-button").click();
|
||||
const resultDownload = await waitFor(
|
||||
() => downloads.find((download) => download.fileName.endsWith(".result.json")),
|
||||
"Result JSON download",
|
||||
);
|
||||
const resultDocument = await fetch(resultDownload.url).then((response) => response.json());
|
||||
const [scene, experiment] = await Promise.all([
|
||||
loadJson("/assets/sample-scenes/single-tone.scene.json"),
|
||||
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
|
||||
]);
|
||||
experiment.scan.columns = 8;
|
||||
experiment.scan.rows = 8;
|
||||
experiment.beamforming.backend = "cpu";
|
||||
const resultIssues = await validateResult(resultDocument, { scene, experiment });
|
||||
assertEqual(resultIssues.length, 0, `downloaded Result validation: ${resultIssues
|
||||
.map((issue) => `${issue.path}: ${issue.message}`)
|
||||
.join(" · ")}`);
|
||||
assertEqual(resultDocument.backend, "cpu", "exported backend");
|
||||
assertEqual(resultDocument.bands[0].rawMap.rows, 8, "exported map rows");
|
||||
assertEqual(resultDocument.bands[0].rawMap.columns, 8, "exported map columns");
|
||||
assertEqual(resultDocument.bands[0].rawMap.values.length, 64, "exported map value count");
|
||||
assert(/^[a-f0-9]{64}$/.test(resultDocument.inputHash), "exported input hash");
|
||||
|
||||
dispatchValue(windowObject, documentObject.querySelector("#dynamic-range"), 24);
|
||||
await waitFor(
|
||||
() => documentObject.querySelector("#result-badge").textContent.includes("이전 설정"),
|
||||
"changed input stale badge",
|
||||
);
|
||||
assert(documentObject.querySelector("#canvas-badge").textContent.includes("이전 설정"), "canvas stale badge");
|
||||
|
||||
dispatchValue(windowObject, documentObject.querySelector("#dynamic-range"), 30);
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.cameraValid === "true" &&
|
||||
!documentObject.querySelector("#run-button").disabled,
|
||||
"original comparison configuration restore",
|
||||
);
|
||||
|
||||
documentObject.querySelector("#run-button").click();
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.runState === "running",
|
||||
"rerun start",
|
||||
);
|
||||
assert(!documentObject.querySelector("#cancel-button").classList.contains("is-hidden"), "cancel available");
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.runState === "done",
|
||||
"8×8 CPU rerun",
|
||||
30000,
|
||||
);
|
||||
assert(!documentObject.querySelector("#result-badge").textContent.includes("이전 설정"), "fresh result badge");
|
||||
assertEqual(documentObject.querySelectorAll("#history-list .history-card").length, 2, "rerun history");
|
||||
} finally {
|
||||
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
|
||||
windowObject.URL.revokeObjectURL = originalRevoke;
|
||||
for (const url of objectUrls) originalRevoke(url);
|
||||
frame.remove();
|
||||
}
|
||||
});
|
||||
@@ -12,10 +12,13 @@ try {
|
||||
await import("/tests/unit/contracts.test.js");
|
||||
await import("/tests/unit/acoustics.test.js");
|
||||
await import("/tests/unit/builder.test.js");
|
||||
await import("/tests/unit/evaluation.test.js");
|
||||
await import("/tests/unit/camera.test.js");
|
||||
await import("/tests/unit/webgpu.test.js");
|
||||
await import("/tests/integration/cpu-baseline.test.js");
|
||||
await import("/tests/integration/webgpu.test.js");
|
||||
await import("/tests/integration/builder-ui.test.js");
|
||||
await import("/tests/integration/camera-ui.test.js");
|
||||
} catch (error) {
|
||||
registeredTests.push({
|
||||
name: "테스트 모듈을 불러온다",
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
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");
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
RESULT_METRIC_KEYS,
|
||||
evaluateDasBand,
|
||||
exportBandEvaluation,
|
||||
findLocalPeaks,
|
||||
measureMainLobe,
|
||||
selectResultMetrics,
|
||||
} from "/packages/acoustics-core/evaluation.js";
|
||||
|
||||
function assertNear(actual, expected, tolerance, label) {
|
||||
assert(
|
||||
Math.abs(actual - expected) <= tolerance,
|
||||
`${label}: expected ${expected} ± ${tolerance}, received ${actual}`,
|
||||
);
|
||||
}
|
||||
|
||||
function captureError(callback) {
|
||||
try {
|
||||
callback();
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
test("evaluation이 row-major 첫 행을 yMax로 해석한다", () => {
|
||||
const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2, columns: 3, rows: 3 };
|
||||
const rawMap = Float32Array.of(
|
||||
4, 0, 0,
|
||||
0, 0, 0,
|
||||
0, 0, 1,
|
||||
);
|
||||
const evaluation = evaluateDasBand({
|
||||
rawMap,
|
||||
scan,
|
||||
sources: [{ id: "top-left", position: [-1, 1, 9] }],
|
||||
truthRadiusM: 0.1,
|
||||
matchToleranceM: 0.1,
|
||||
sidelobeExclusionRadiusM: 0.1,
|
||||
detectionThresholdRatio: 0.5,
|
||||
minimumPeakDistanceM: 0,
|
||||
});
|
||||
|
||||
assertEqual(evaluation.globalPeak.index, 0, "row-major global index");
|
||||
assertEqual(evaluation.globalPeak.position.join(","), "-1,1,2", "first row position");
|
||||
assertEqual(evaluation.sourceMatches[0].sourceId, "top-left", "source match id");
|
||||
assertNear(evaluation.sourceMatches[0].localizationErrorM, 0, 1e-12, "projected localization error");
|
||||
assertNear(evaluation.metrics.energyInsideTruthRatio, 0.8, 1e-12, "truth energy ratio");
|
||||
assertNear(evaluation.metrics.peakToSidelobeDb, 10 * Math.log10(4), 1e-12, "peak-to-sidelobe");
|
||||
assertEqual(evaluation.metrics.precision, 1, "single-source precision");
|
||||
assertEqual(evaluation.metrics.recall, 1, "single-source recall");
|
||||
});
|
||||
|
||||
test("복수 source와 threshold detection을 one-to-one matching한다", () => {
|
||||
const scan = { xMin: -2, xMax: 2, yMin: -2, yMax: 2, z: 1, columns: 5, rows: 5 };
|
||||
const rawMap = new Float32Array(25);
|
||||
rawMap[6] = 10; // (-1, +1)
|
||||
rawMap[18] = 8; // (+1, -1)
|
||||
rawMap[4] = 6; // unmatched (+2, +2)
|
||||
const evaluation = evaluateDasBand({
|
||||
rawMap,
|
||||
scan,
|
||||
sources: [
|
||||
{ id: "a", position: [-1, 1, 1] },
|
||||
{ id: "b", position: [1, -1, 1] },
|
||||
],
|
||||
truthRadiusM: 0.25,
|
||||
matchToleranceM: 0.25,
|
||||
sidelobeExclusionRadiusM: 0.25,
|
||||
detectionThresholdRatio: 0.5,
|
||||
minimumPeakDistanceM: 0,
|
||||
});
|
||||
|
||||
assertEqual(evaluation.detections.length, 3, "threshold detections");
|
||||
assertEqual(evaluation.counts.truePositive, 2, "true positives");
|
||||
assertEqual(evaluation.counts.falsePositive, 1, "false positives");
|
||||
assertEqual(evaluation.counts.falseNegative, 0, "false negatives");
|
||||
assertNear(evaluation.metrics.precision, 2 / 3, 1e-12, "multi-source precision");
|
||||
assertNear(evaluation.metrics.recall, 1, 1e-12, "multi-source recall");
|
||||
assertNear(evaluation.metrics.energyInsideTruthRatio, 18 / 24, 1e-12, "union truth energy");
|
||||
assertNear(evaluation.metrics.peakToSidelobeDb, 10 * Math.log10(10 / 6), 1e-12, "multi-source PSLR");
|
||||
assert(
|
||||
evaluation.sourceMatches.every((match) => match.withinTolerance && match.localizationErrorM === 0),
|
||||
"both sources matched exactly",
|
||||
);
|
||||
});
|
||||
|
||||
test("zero map 평가가 빈 peak와 유한한 metric을 반환한다", () => {
|
||||
const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 1, columns: 2, rows: 2 };
|
||||
const evaluation = evaluateDasBand({
|
||||
rawMap: new Float32Array(4),
|
||||
scan,
|
||||
sources: [{ id: "silent", position: [0, 0, 1] }],
|
||||
});
|
||||
|
||||
assertEqual(evaluation.globalPeak, null, "zero-map global peak");
|
||||
assertEqual(evaluation.localPeaks.length, 0, "zero-map local peaks");
|
||||
assertEqual(evaluation.detections.length, 0, "zero-map detections");
|
||||
assertEqual(evaluation.sourceMatches[0].peak, null, "zero-map source remains unmatched");
|
||||
assertEqual(evaluation.metrics.energyInsideTruthRatio, 0, "zero-map energy ratio");
|
||||
assertEqual(evaluation.metrics.peakToSidelobeDb, 0, "zero-map PSLR");
|
||||
assertEqual(evaluation.metrics.precision, 0, "zero-map precision");
|
||||
assertEqual(evaluation.metrics.recall, 0, "zero-map recall");
|
||||
assertEqual(evaluation.diagnostics.mainLobeWidthM, 0, "zero-map main-lobe width");
|
||||
Object.values(evaluation.metrics).forEach((value) => assert(Number.isFinite(value), "finite zero-map metric"));
|
||||
});
|
||||
|
||||
test("flat plateau와 main-lobe width가 deterministic하다", () => {
|
||||
const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 0, columns: 3, rows: 3 };
|
||||
const rawMap = Float32Array.from({ length: 9 }, () => 1);
|
||||
const peaks = findLocalPeaks(rawMap, scan);
|
||||
assertEqual(peaks.length, 1, "flat plateau represented once");
|
||||
assertEqual(peaks[0].index, 0, "flat plateau uses lowest row-major index");
|
||||
|
||||
const first = evaluateDasBand({
|
||||
rawMap,
|
||||
scan,
|
||||
sources: [{ id: "corner", position: [-1, 1, 0] }],
|
||||
truthRadiusM: 0,
|
||||
matchToleranceM: 0,
|
||||
minimumPeakDistanceM: 0,
|
||||
});
|
||||
const second = evaluateDasBand({
|
||||
rawMap,
|
||||
scan,
|
||||
sources: [{ id: "corner", position: [-1, 1, 0] }],
|
||||
truthRadiusM: 0,
|
||||
matchToleranceM: 0,
|
||||
minimumPeakDistanceM: 0,
|
||||
});
|
||||
assertEqual(JSON.stringify(first), JSON.stringify(second), "repeatable evaluation result");
|
||||
|
||||
const lobe = measureMainLobe(rawMap, scan, 0, 0.5);
|
||||
assertEqual(lobe.pixelCount, 9, "flat half-power component size");
|
||||
assertEqual(lobe.widthXM, 3, "main-lobe x pixel footprint");
|
||||
assertEqual(lobe.widthYM, 3, "main-lobe y pixel footprint");
|
||||
assertNear(lobe.equivalentDiameterM, 6 / Math.sqrt(Math.PI), 1e-12, "equivalent main-lobe diameter");
|
||||
});
|
||||
|
||||
test("result exporter가 schema 허용 metric만 선택하고 내부 필드를 제거한다", () => {
|
||||
const scan = { xMin: 0, xMax: 1, yMin: 0, yMax: 1, z: 1, columns: 2, rows: 2 };
|
||||
const evaluation = evaluateDasBand({
|
||||
rawMap: Float32Array.of(5, 0, 0, 1),
|
||||
scan,
|
||||
sources: [{ id: "truth", position: [0, 1, 3] }],
|
||||
truthRadiusM: 0,
|
||||
matchToleranceM: 0,
|
||||
sidelobeExclusionRadiusM: 0,
|
||||
minimumPeakDistanceM: 0,
|
||||
});
|
||||
const exported = exportBandEvaluation(evaluation, { metricNames: ["precision", "recall"] });
|
||||
|
||||
assertEqual(Object.keys(exported.metrics).join(","), "precision,recall", "selected metric keys");
|
||||
assertEqual(exported.peaks[0].sourceId, "truth", "schema peak source match");
|
||||
assertEqual(exported.peaks[0].localizationErrorM, 0, "schema peak localization error");
|
||||
assert(!("index" in exported.peaks[0]), "internal peak index omitted");
|
||||
assert(!("mainLobeWidthM" in exported.metrics), "diagnostic omitted from schema metrics");
|
||||
assertEqual(
|
||||
Object.keys(selectResultMetrics(evaluation)).join(","),
|
||||
RESULT_METRIC_KEYS.join(","),
|
||||
"default schema metric selection",
|
||||
);
|
||||
|
||||
const unsupported = captureError(() => selectResultMetrics(evaluation, ["mainLobeWidthM"]));
|
||||
assert(unsupported instanceof RangeError, "unsupported result metric is rejected");
|
||||
});
|
||||
|
||||
test("evaluation이 non-finite, negative, shape 오류를 입력 경계에서 거부한다", () => {
|
||||
const scan = { xMin: 0, xMax: 1, yMin: 0, yMax: 1, z: 1, columns: 2, rows: 2 };
|
||||
for (const invalidValue of [Number.NaN, Number.POSITIVE_INFINITY, -1]) {
|
||||
const rawMap = [0, 0, 0, invalidValue];
|
||||
assert(
|
||||
captureError(() => evaluateDasBand({ rawMap, scan, sources: [] })) instanceof RangeError,
|
||||
`invalid power rejected: ${invalidValue}`,
|
||||
);
|
||||
}
|
||||
assert(
|
||||
captureError(() => evaluateDasBand({ rawMap: [0, 0, 0], scan, sources: [] })) instanceof RangeError,
|
||||
"map shape mismatch rejected",
|
||||
);
|
||||
assert(
|
||||
captureError(() => evaluateDasBand({
|
||||
rawMap: [0, 0, 0, 0],
|
||||
scan,
|
||||
sources: [{ id: "bad", position: [0, Number.NaN, 1] }],
|
||||
})) instanceof RangeError,
|
||||
"non-finite source position rejected",
|
||||
);
|
||||
assert(
|
||||
captureError(() => evaluateDasBand({
|
||||
rawMap: { rows: 2, columns: 2, scale: "normalized", values: [0, 0, 0, 0] },
|
||||
scan,
|
||||
sources: [],
|
||||
})) instanceof RangeError,
|
||||
"non-power map rejected",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user