import { RESULT_METRIC_KEYS, evaluateDasBand, evaluateLocalizationBand, exportBandEvaluation, findLocalPeaks, measureBeamSpread, 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("peak 검출 감도를 낮추면 약한 두 번째 음원을 선택한다", () => { const scan = { xMin: -2, xMax: 2, yMin: -2, yMax: 2, z: 1, columns: 5, rows: 5 }; const rawMap = new Float32Array(25); rawMap[6] = 10; rawMap[18] = 2.5; const options = { rawMap, scan, sources: [ { id: "strong", position: [-1, 1, 1] }, { id: "weak", position: [1, -1, 1] }, ], matchToleranceM: 0.25, minimumPeakDistanceM: 0, }; const conservative = evaluateDasBand({ ...options, detectionThresholdRatio: 0.5 }); const recommended = evaluateDasBand({ ...options, detectionThresholdRatio: 0.25 }); assertEqual(conservative.detections.length, 1, "50% threshold omits the weak peak"); assertEqual(conservative.counts.falseNegative, 1, "one source remains undetected"); assertEqual(recommended.detections.length, 2, "25% threshold retains the weak peak"); assertEqual(recommended.counts.truePositive, 2, "both sources match at recommended sensitivity"); assertEqual(recommended.parameters.detectionThresholdRatio, 0.25, "selected ratio is recorded"); }); 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("beam spread가 회전된 비등방성 half-power lobe의 covariance ellipse를 측정한다", () => { const scan = { xMin: -3, xMax: 3, yMin: -3, yMax: 3, z: 2, columns: 7, rows: 7 }; const rawMap = new Float32Array(49); const setPower = (x, y, value) => { rawMap[(3 - y) * scan.columns + x + 3] = value; }; setPower(0, 0, 10); setPower(1, 1, 8); setPower(-1, -1, 8); setPower(2, 2, 6); setPower(-2, -2, 6); setPower(-1, 1, 5.5); setPower(1, -1, 5.5); const spread = measureBeamSpread(rawMap, scan, 24); assertEqual(spread.calibrated, false, "beam spread is explicitly uncalibrated"); assertEqual( spread.method, "connected-half-power-power-weighted-covariance", "beam spread method", ); assertEqual(spread.pixelCount, 7, "connected half-power pixels"); assertNear(spread.thresholdValue, 5, 1e-12, "half-power threshold"); assertNear(spread.centroid[0], 0, 1e-12, "weighted centroid x"); assertNear(spread.centroid[1], 0, 1e-12, "weighted centroid y"); assertEqual(spread.centroid[2], 2, "weighted centroid z"); assertNear(spread.covarianceM2.xx, 75 / 49, 1e-12, "rotated covariance xx"); assertNear(spread.covarianceM2.xy, 53 / 49, 1e-12, "rotated covariance xy"); assertNear(spread.covarianceM2.yy, 75 / 49, 1e-12, "rotated covariance yy"); assertNear(spread.ellipse.majorRmsRadiusM, Math.sqrt(128 / 49), 1e-12, "major RMS radius"); assertNear(spread.ellipse.minorRmsRadiusM, Math.sqrt(22 / 49), 1e-12, "minor RMS radius"); assertNear(spread.ellipse.angleDeg, 45, 1e-12, "major-axis angle"); assertEqual(spread.ellipse.axisScale, "rms", "ellipse scale is explicit"); const evaluation = evaluateDasBand({ rawMap, scan, sources: [], detectionThresholdRatio: 0.5, minimumPeakDistanceM: 0, }); assertEqual(evaluation.detections.length, 1, "single selected detection"); assertEqual( JSON.stringify(evaluation.detections[0].beamSpread), JSON.stringify(spread), "selected detection exposes its beam spread", ); }); test("generic localization 평가는 score map의 척도와 beam spread 방식을 보존한다", () => { const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2, columns: 3, rows: 3 }; const rawMap = { rows: 3, columns: 3, scale: "score", values: Float32Array.of( 0, 1, 0, 1, 4, 1, 0, 1, 0, ), }; const evaluation = evaluateLocalizationBand({ rawMap, scan, sources: [{ id: "center", position: [0, 0, 2] }], matchToleranceM: 0, truthRadiusM: 0, minimumPeakDistanceM: 0, }); assertEqual(evaluation.diagnostics.mapScale, "score", "score scale diagnostic"); assertEqual( evaluation.detections[0].beamSpread.method, "connected-half-maximum-score-weighted-covariance", "score beam-spread method", ); assertEqual(evaluation.sourceMatches[0].localizationErrorM, 0, "score peak localization"); const legacy = evaluateDasBand({ rawMap: rawMap.values, scan, sources: [], minimumPeakDistanceM: 0, }); assertEqual(legacy.diagnostics.mapScale, "power", "legacy DAS defaults to power"); }); test("beam spread가 1-row, 1-column, single-pixel lobe에서 유한하고 결정적이다", () => { const powers = Float32Array.of(5, 8, 10, 8, 5); const rowSpread = measureBeamSpread( powers, { xMin: -2, xMax: 2, yMin: 7, yMax: 7, z: 3, columns: 5, rows: 1 }, 2, ); const columnSpread = measureBeamSpread( powers, { xMin: 4, xMax: 4, yMin: 5, yMax: 9, z: 3, columns: 1, rows: 5 }, 2, ); const pointScan = { xMin: 4, xMax: 4, yMin: 7, yMax: 7, z: 3, columns: 1, rows: 1 }; const pointSpread = measureBeamSpread(Float32Array.of(10), pointScan, 0); const silentSpread = measureBeamSpread(Float32Array.of(0), pointScan, 0); assertEqual(rowSpread.pixelCount, 5, "one-row lobe size"); assertNear(rowSpread.covarianceM2.xx, 14 / 9, 1e-12, "one-row x variance"); assertEqual(rowSpread.covarianceM2.yy, 0, "one-row y variance"); assertNear(rowSpread.ellipse.majorRmsRadiusM, Math.sqrt(14 / 9), 1e-12, "one-row major radius"); assertEqual(rowSpread.ellipse.minorRmsRadiusM, 0, "one-row minor radius"); assertEqual(rowSpread.ellipse.angleDeg, 0, "one-row angle"); assertEqual(columnSpread.covarianceM2.xx, 0, "one-column x variance"); assertNear(columnSpread.covarianceM2.yy, 14 / 9, 1e-12, "one-column y variance"); assertNear(columnSpread.ellipse.majorRmsRadiusM, Math.sqrt(14 / 9), 1e-12, "one-column major radius"); assertEqual(columnSpread.ellipse.minorRmsRadiusM, 0, "one-column minor radius"); assertEqual(columnSpread.ellipse.angleDeg, 90, "one-column angle"); assertEqual(pointSpread.centroid.join(","), "4,7,3", "single-pixel centroid"); assertEqual(pointSpread.pixelCount, 1, "single-pixel lobe size"); assertEqual(pointSpread.ellipse.majorRmsRadiusM, 0, "single-pixel major radius"); assertEqual(pointSpread.ellipse.minorRmsRadiusM, 0, "single-pixel minor radius"); assertEqual(silentSpread.pixelCount, 0, "silent lobe has no pixels"); for (const spread of [rowSpread, columnSpread, pointSpread, silentSpread]) { const numbers = [ spread.halfPowerRatio, spread.thresholdValue, spread.pixelCount, ...spread.centroid, ...Object.values(spread.covarianceM2), spread.ellipse.majorRmsRadiusM, spread.ellipse.minorRmsRadiusM, spread.ellipse.angleDeg, ]; assert(numbers.every(Number.isFinite), "every degenerate beam-spread number is finite"); } assertEqual( JSON.stringify(measureBeamSpread(powers, { xMin: -2, xMax: 2, yMin: 7, yMax: 7, z: 3, columns: 5, rows: 1 }, 2)), JSON.stringify(rowSpread), "degenerate measurement is repeatable", ); }); 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"); assertEqual( JSON.stringify(exported.peaks[0].beamSpread), JSON.stringify(evaluation.detections[0].beamSpread), "schema peak retains beam-spread descriptor", ); assert(exported.peaks[0].beamSpread !== evaluation.detections[0].beamSpread, "beam spread is copied"); assert( exported.peaks[0].beamSpread.covarianceM2 !== evaluation.detections[0].beamSpread.covarianceM2, "beam-spread covariance is copied", ); 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, "display-only map scale rejected", ); });