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"); assertEqual(exported.peaks[0].localizationErrorPixels, 0, "schema peak pixel error"); assert(!("index" in exported.peaks[0]), "internal peak index omitted"); assert(!("mainLobeWidthM" in exported.metrics), "explicit metric selection is respected"); assertEqual(exported.evaluation.version, "1.0.0", "evaluation version exported"); assertEqual(exported.evaluation.matchToleranceM, 0, "match tolerance exported"); assertEqual( Object.keys(selectResultMetrics(evaluation)).join(","), RESULT_METRIC_KEYS.join(","), "default schema metric selection", ); const unsupported = captureError(() => selectResultMetrics(evaluation, ["gridResolutionM"])); 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", ); });