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
+236 -6
View File
@@ -22,6 +22,7 @@ import {
shiftSceneDistance,
} from "/apps/acoustic-camera/model.js";
import { validateResult } from "/packages/contracts/validation.js";
import { computeLocalizationMap } from "/packages/acoustics-core/localization.js";
import { sourcesForObservedBand } from "/apps/acoustic-camera/source-selection.js";
function assertNear(actual, expected, tolerance, label) {
@@ -319,6 +320,49 @@ test("Camera workload counts unique overlapping FFT bins", () => {
assertEqual(gccWorkload.scanOperationCount, 320, "GCC pair-band scan lookups");
assertEqual(gccWorkload.scanOperationKind, "reference-pair lookup", "GCC operation kind");
assertEqual(gccWorkload.complexMacCount, 0, "GCC is not mislabeled as DAS complex MAC");
const musicExperiment = structuredClone(experiment);
musicExperiment.frequencyBands = [
{ id: "single-bin", minHz: 120, maxHz: 130 },
];
musicExperiment.beamforming = {
algorithm: "music",
musicSourceCountMethod: "fixed",
musicSourceCount: 1,
};
const musicWorkload = estimateExperimentWorkload(
arrayDefinition,
scene,
musicExperiment,
);
assertEqual(musicWorkload.algorithmLabel, "MUSIC", "MUSIC algorithm label");
assertEqual(
musicWorkload.musicCovarianceComplexAccumulations,
372,
"MUSIC covariance accumulation count",
);
assertEqual(
musicWorkload.musicJacobiWorstCasePairUpdates,
384,
"MUSIC 32-sweep Jacobi upper bound",
);
assertEqual(
musicWorkload.musicProjectionComplexMacs,
480,
"MUSIC fixed-K projection count",
);
assertEqual(musicWorkload.scanOperationCount, 1236, "MUSIC disclosed total work");
assertEqual(
musicWorkload.scanOperationKind,
"covariance + Jacobi upper bound + projection",
"MUSIC work is not mislabeled as DAS",
);
assertNear(
musicWorkload.distanceMiB,
(80 * 3 * 8) / 1024 ** 2,
1e-15,
"MUSIC Float64 distance table size",
);
});
test("Camera comparison signature groups array, algorithm, and backend variants only", () => {
@@ -520,8 +564,8 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
};
const experiment = {
id: "test-experiment",
arrayId: arrayDefinition.id,
sceneId: scene.id,
arrayId: "declared-array-provenance",
sceneId: "declared-scene-provenance",
scan: { rows: 2, columns: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2 },
beamforming: { algorithm: "das-phat", backend: "auto" },
stft: { fftSize: 256, hopSize: 128, window: "hann" },
@@ -643,6 +687,18 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
});
assertEqual(result.schemaVersion, "1.1.0", "sampling-aware Result contract version");
assertEqual(result.arrayId, arrayDefinition.id, "Result records the Array actually used");
assertEqual(result.sceneId, scene.id, "Result records the Scene actually used");
assertEqual(
experiment.arrayId,
"declared-array-provenance",
"Result creation preserves the Experiment Array provenance label",
);
assertEqual(
experiment.sceneId,
"declared-scene-provenance",
"Result creation preserves the Experiment Scene provenance label",
);
assert(Array.isArray(result.bands[0].rawMap.values), "typed raw map becomes JSON array");
assertEqual(result.algorithm, "das-phat", "actual algorithm is exported");
assertEqual(result.timingMs.localization, 10, "generic localization timing is exported");
@@ -710,7 +766,11 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
assertEqual(history.algorithm, "das-phat", "history records the actual algorithm");
assertEqual(history.localizationMs, 10, "history records localization time");
const issues = await validateResult(result, { experiment, scene });
const issues = await validateResult(result, {
experiment,
scene,
arrayDefinition,
});
assertEqual(issues.length, 0, `generated result validation: ${JSON.stringify(issues)}`);
assertEqual(
(await validateResult(result)).length,
@@ -777,7 +837,177 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
"validator rejects incomplete Result band sets",
);
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");
const wrongArrayResult = structuredClone(result);
wrongArrayResult.arrayId = "another-array";
let linkageIssues = await validateResult(wrongArrayResult, {
experiment,
scene,
arrayDefinition,
});
assert(
linkageIssues.some(({ path }) => path === "$.arrayId"),
"validator catches an actual Array snapshot linkage mismatch",
);
const wrongSceneResult = structuredClone(result);
wrongSceneResult.sceneId = "another-scene";
linkageIssues = await validateResult(wrongSceneResult, {
experiment,
scene,
arrayDefinition,
});
assert(
linkageIssues.some(({ path }) => path === "$.sceneId"),
"validator catches an actual Scene snapshot linkage mismatch",
);
});
test("MUSIC Result preserves its physical model and numerical audit", async () => {
const sampleRate = 8192;
const fftSize = 128;
const sampleCount = 256;
const arrayDefinition = {
id: "music-result-array",
microphones: [
{ id: "m0", position: [-0.1, 0.1, 0] },
{ id: "m1", position: [0.1, 0.1, 0] },
{ id: "m2", position: [-0.1, -0.1, 0] },
{ id: "m3", position: [0.1, -0.1, 0] },
],
};
const channels = arrayDefinition.microphones.map((_, microphoneIndex) =>
Float64Array.from(
{ length: sampleCount },
(_, sampleIndex) =>
Math.cos(
2 * Math.PI * 16 * sampleIndex / fftSize +
microphoneIndex * 0.21,
),
));
const scene = {
id: "music-result-scene",
sampleRate,
duration: sampleCount / sampleRate,
speedOfSound: 343,
sources: [],
};
const experiment = {
id: "music-result-experiment",
arrayId: arrayDefinition.id,
sceneId: scene.id,
scan: {
rows: 2,
columns: 2,
xMin: -0.2,
xMax: 0.2,
yMin: -0.2,
yMax: 0.2,
z: 1,
},
frequencyBands: [
{ id: "single-bin", minHz: 1000, maxHz: 1050, color: "#65f2bf" },
],
stft: { fftSize, hopSize: 64, window: "hann" },
beamforming: {
algorithm: "music",
backend: "cpu",
minimumDistance: 0.01,
normalization: "none",
compensateFrequencyResponse: true,
musicSourceCountMethod: "fixed",
musicSourceCount: 1,
},
};
const localization = await computeLocalizationMap({
channels,
sampleRate,
arrayDefinition,
scene,
experiment,
});
const run = {
...localization,
signalStats: {
preNormalizationPeak: 1,
appliedScale: 1,
clippedSampleCount: 0,
sampling: {
model: "ideal-real-sampler-no-antialias-filter",
sampleRate,
nyquistHz: sampleRate / 2,
toneObservations: [],
},
},
timingMs: {
total: 1,
signalGeneration: 0,
localization: 1,
beamforming: 1,
},
};
const result = createResultDocument({
arrayDefinition,
scene,
experiment,
run,
evaluations: new Map([[
"single-bin",
{ peaks: [], metrics: { precision: 1, recall: 1 } },
]]),
inputHash: "b".repeat(64),
createdAt: "2026-08-29T00:00:00.000Z",
});
assertEqual(result.bands[0].rawMap.scale, "noise-projection", "raw q scale");
assertEqual(result.bands[0].rawMap.objectiveDirection, "minimum", "q is minimized");
assertEqual(result.musicModel.originalCarrierUsed, true, "configured carrier is used");
assertEqual(
result.musicModel.hypothesisSource,
"experiment-band-not-scene-truth",
"carrier comes from Experiment rather than Scene truth",
);
assertEqual(result.musicModel.diagonalLoading, "none", "no hidden diagonal loading");
assertEqual(result.musicModel.multibandFusion, "none-each-band-is-separate-single-analog-hypothesis", "no band fusion");
assertEqual(result.bands[0].musicDiagnostics.eigenvalues.length, 4, "all eigenvalues exported");
assert(result.bands[0].musicDiagnostics.eigensolver.numericallyVerified, "EVD audit exported");
assertEqual(result.bands[0].musicDiagnostics.sourceCount.value, 1, "fixed K exported");
const resultIssues = await validateResult(result, {
experiment,
scene,
arrayDefinition,
});
assertEqual(
resultIssues.length,
0,
`MUSIC Result passes schema and semantic validation: ${JSON.stringify(resultIssues)}`,
);
const corrupted = structuredClone(result);
corrupted.bands[0].musicDiagnostics.sourceCount.value = 4;
const issues = await validateResult(corrupted, {
experiment,
scene,
arrayDefinition,
});
assert(
issues.some(({ path }) =>
path.endsWith("musicDiagnostics.sourceCount.value")),
"source count cannot equal microphone count",
);
const corruptedHypothesis = structuredClone(result);
corruptedHypothesis.bands[0].musicDiagnostics.frequencyHypothesis
.analogFrequencyHz += sampleRate;
const hypothesisIssues = await validateResult(corruptedHypothesis, {
experiment,
scene,
arrayDefinition,
});
assert(
hypothesisIssues.some(({ path }) =>
path.endsWith(
"musicDiagnostics.frequencyHypothesis.analogFrequencyHz",
)),
"numerical diagnostics cannot claim a different analog carrier",
);
});