feat: add physically explicit narrowband MUSIC

This commit is contained in:
2026-08-29 12:40:48 +09:00
parent 044be3cf92
commit 11fee76280
40 changed files with 6473 additions and 275 deletions
+100
View File
@@ -6,6 +6,7 @@ import {
findLocalPeaks,
measureBeamSpread,
measureMainLobe,
normalizedMusicPseudospectrum,
selectResultMetrics,
} from "/packages/acoustics-core/evaluation.js";
@@ -426,3 +427,102 @@ test("evaluation이 non-finite, negative, shape 오류를 입력 경계에서
"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");
});