Files
micarrsim/tests/unit/evaluation.test.js
T

529 lines
20 KiB
JavaScript

import {
RESULT_METRIC_KEYS,
evaluateDasBand,
evaluateLocalizationBand,
exportBandEvaluation,
findLocalPeaks,
measureBeamSpread,
measureMainLobe,
normalizedMusicPseudospectrum,
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("connected component와 watershed가 연결된 복수 lobe를 서로 다르게 군집화한다", () => {
const scan = { xMin: 0, xMax: 6, yMin: 0, yMax: 2, z: 1, columns: 7, rows: 3 };
const rawMap = Float32Array.of(
0, 0, 0, 0, 0, 0, 0,
0, 10, 8, 6, 8, 9, 0,
0, 0, 0, 0, 0, 0, 0,
);
const common = {
rawMap,
scan,
sources: [],
detectionThresholdRatio: 0.5,
minimumPeakDistanceM: 0,
};
const local = evaluateDasBand({ ...common, detectionMethod: "local-max" });
const connected = evaluateDasBand({ ...common, detectionMethod: "connected-components" });
const watershed = evaluateDasBand({ ...common, detectionMethod: "watershed" });
assertEqual(local.detections.length, 2, "local maxima keeps both summits");
assertEqual(connected.detections.length, 1, "connected lobe becomes one component");
assertNear(connected.detections[0].position[0], 121 / 41, 1e-12, "component weighted centroid x");
assertNear(connected.detections[0].position[1], 1, 1e-12, "component weighted centroid y");
assertEqual(connected.detections[0].clusterPixelCount, 5, "component pixel count");
assertEqual(watershed.detections.length, 2, "watershed splits the connected lobe at its saddle");
assertNear(watershed.detections[0].position[0], 44 / 24, 1e-12, "strong basin centroid");
assertNear(watershed.detections[1].position[0], 77 / 17, 1e-12, "weak basin centroid");
assertEqual(connected.parameters.detectionMethod, "connected-components", "component method recorded");
assertEqual(watershed.parameters.detectionMethod, "watershed", "watershed method recorded");
assertEqual(
JSON.stringify(evaluateDasBand({ ...common, detectionMethod: "watershed" })),
JSON.stringify(watershed),
"watershed assignment is deterministic",
);
const suppressed = evaluateDasBand({
...common,
detectionMethod: "watershed",
minimumPeakDistanceM: 10,
});
assertEqual(suppressed.detections.length, 1, "common minimum-distance suppression still applies");
assert(
captureError(() => evaluateDasBand({ ...common, detectionMethod: "unknown" })) instanceof RangeError,
"unsupported detection method is rejected",
);
});
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.detectionMethod, "local-max", "detection method 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",
);
});
test("MUSIC noise projection is minimized without rewriting raw q values", () => {
const scan = {
xMin: 0,
xMax: 4,
yMin: 0,
yMax: 4,
z: 1,
columns: 5,
rows: 5,
};
const values = new Float64Array(25);
values.fill(1);
values[6] = 0.1;
values[18] = 0.2;
values[3] = 0.6;
const evaluation = evaluateLocalizationBand({
rawMap: {
rows: 5,
columns: 5,
scale: "noise-projection",
objectiveDirection: "minimum",
values,
},
scan,
sources: [
{ id: "left", position: [1, 3, 1] },
{ id: "right", position: [3, 1, 1] },
],
detectionThresholdRatio: 0.5,
minimumPeakDistanceM: 0,
matchToleranceM: 0,
});
assertEqual(evaluation.globalPeak.index, 6, "smallest q is the global optimum");
assertNear(evaluation.globalPeak.value, 0.1, 1e-12, "global peak retains raw q");
assertEqual(evaluation.detections.length, 2, "two separated minima are detected");
assertNear(evaluation.detections[1].value, 0.2, 1e-12, "second detection retains raw q");
assert(!("beamSpread" in evaluation.detections[0]), "power beam spread is omitted");
assertEqual(
Object.keys(evaluation.metrics).join(","),
"precision,recall",
"only objective-independent metrics are reported",
);
assertEqual(
evaluation.diagnostics.detectionTransform,
"normalized-reciprocal-noise-projection",
"the standard normalized MUSIC pseudospectrum is disclosed",
);
const exported = exportBandEvaluation(evaluation);
assertEqual(exported.evaluation.objectiveDirection, "minimum", "minimum objective is exported");
assertEqual(
exported.evaluation.detectionThresholdDomain,
"normalized-music-pseudospectrum",
"threshold domain is explicit",
);
assertNear(exported.peaks[0].value, 0.1, 1e-12, "exported peak remains raw q");
const withBadOutlier = Float64Array.from(values);
withBadOutlier[0] = 100;
const outlierEvaluation = evaluateLocalizationBand({
rawMap: {
rows: 5,
columns: 5,
scale: "noise-projection",
objectiveDirection: "minimum",
values: withBadOutlier,
},
scan,
sources: [],
detectionThresholdRatio: 0.5,
minimumPeakDistanceM: 0,
});
assertEqual(
outlierEvaluation.detections.map(({ index }) => index).join(","),
evaluation.detections.map(({ index }) => index).join(","),
"a worse scan outlier does not move the reciprocal threshold",
);
const flat = evaluateLocalizationBand({
rawMap: {
rows: 5,
columns: 5,
scale: "noise-projection",
objectiveDirection: "minimum",
values: new Float64Array(25).fill(0.5),
},
scan,
sources: [],
});
const flatDisplay = normalizedMusicPseudospectrum(
new Float64Array(25).fill(0.5),
);
assert(
flatDisplay.values.every((value) => value === 1),
"flat q preserves the standard normalized reciprocal value",
);
assertEqual(flat.detections.length, 0, "flat q does not invent a location");
});