fix: expose physical aliasing and delayed arrivals

This commit is contained in:
2026-08-29 10:21:33 +09:00
parent e048ccbadc
commit de0053eae3
38 changed files with 3086 additions and 214 deletions
+194 -2
View File
@@ -22,6 +22,7 @@ import {
shiftSceneDistance,
} from "/apps/acoustic-camera/model.js";
import { validateResult } from "/packages/contracts/validation.js";
import { sourcesForObservedBand } from "/apps/acoustic-camera/source-selection.js";
function assertNear(actual, expected, tolerance, label) {
assert(
@@ -56,6 +57,56 @@ test("Camera detects moving source trajectories", () => {
}), true, "moving scene");
});
test("Camera live truth uses synthesized path arrivals instead of emission midpoint", () => {
const scene = {
sampleRate: 8_000,
duration: 1,
sources: [
{
id: "ended-tone-tail",
startTime: 0,
duration: 0.1,
signal: { type: "tone", frequency: 1_000 },
},
{
id: "ended-noise-tail",
startTime: 0,
duration: 0.1,
signal: { type: "noise", color: "white" },
},
{
id: "wrong-band-tone",
startTime: 0,
duration: 0.1,
signal: { type: "tone", frequency: 2_000 },
},
],
};
const band = { minHz: 900, maxHz: 1_100 };
const fallback = sourcesForObservedBand(scene, band, 256, { time: 0.5 });
assertEqual(fallback.length, 0, "legacy emission-time fallback sees no active source");
assertEqual(
sourcesForObservedBand(scene, band, 256, { time: 0.1 }).length,
0,
"emission-time fallback treats the exact end boundary as half-open",
);
const arrived = sourcesForObservedBand(scene, band, 256, {
time: 0.5,
arrivingSourceIds: ["ended-tone-tail", "ended-noise-tail", "wrong-band-tone"],
});
assertEqual(
arrived.map(({ id }) => id).join(","),
"ended-tone-tail,ended-noise-tail",
"path-arrival ids override time while band selection remains independent",
);
assertEqual(
sourcesForObservedBand(scene, band, 256, { arrivingSourceIds: [] }).length,
0,
"an explicit empty arrival set does not fall back to emission activity",
);
});
test("Camera analysis plan covers a scene and respects its frame budget", () => {
const scene = { duration: 2, sampleRate: 1000, sources: [] };
const options = { fftSize: 100, targetMapHz: 4, maxFrames: 3 };
@@ -460,7 +511,12 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
};
const scene = {
id: "test-scene",
sources: [{ id: "source-1", position: [0, 0, 2] }],
sampleRate: 8_000,
sources: [{
id: "source-1",
position: [0, 0, 2],
signal: { type: "tone", frequency: 5_000, phaseDeg: 0 },
}],
};
const experiment = {
id: "test-experiment",
@@ -468,7 +524,18 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
sceneId: scene.id,
scan: { rows: 2, columns: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2 },
beamforming: { algorithm: "das-phat", backend: "auto" },
frequencyBands: [{ id: "band-1", color: "#65f2bf" }],
stft: { fftSize: 256, hopSize: 128, window: "hann" },
frequencyBands: [{
id: "band-1",
minHz: 4_900,
maxHz: 5_100,
color: "#65f2bf",
}, {
id: "band-2",
minHz: 2_900,
maxHz: 3_100,
color: "#58c7ff",
}],
};
const run = {
algorithm: "das-phat",
@@ -481,6 +548,23 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
acousticModel: "shoebox",
acousticPathCount: 38,
acousticPathCounts: { direct: 2, reflection: 12, scattering: 12, diffuse: 12 },
sampling: {
model: "ideal-real-sampler-no-antialias-filter",
sampleRate: 8_000,
nyquistHz: 4_000,
toneObservations: [{
scope: "source",
id: "source-1",
analogFrequencyHz: 5_000,
adcFrequencyHz: 3_000,
signedAdcFrequencyHz: -3_000,
aliased: true,
spectralInversion: true,
dcEdge: false,
nyquistEdge: false,
phaseDegenerate: false,
}],
},
},
timingMs: { total: 12.5, signalGeneration: 2.5, beamforming: 10 },
fallback: { code: "webgpu-unavailable", message: "CPU fallback" },
@@ -491,6 +575,13 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
rawMap: Float32Array.of(0.1, 0.2, 0.3, 1),
displayMap: Float32Array.of(0.1, 0.2, 0.3, 1),
displayScale: "normalized",
}, {
id: "band-2",
color: "#58c7ff",
rawScale: "score",
rawMap: Float32Array.of(0.1, 0.2, 0.3, 1),
displayMap: Float32Array.of(0.1, 0.2, 0.3, 1),
displayScale: "normalized",
}],
};
const evaluations = new Map([["band-1", {
@@ -539,6 +630,7 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
maximumDetections: 128,
},
}]]);
evaluations.set("band-2", evaluations.get("band-1"));
const result = createResultDocument({
arrayDefinition,
@@ -550,6 +642,7 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
createdAt: "2026-08-28T00:00:00.000Z",
});
assertEqual(result.schemaVersion, "1.1.0", "sampling-aware Result contract version");
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");
@@ -570,6 +663,41 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
assertEqual(result.signalStats.acousticModel, "shoebox", "acoustic model is exported");
assertEqual(result.signalStats.acousticPathCount, 38, "acoustic path count is exported");
assertEqual(result.signalStats.acousticPathCounts.diffuse, 12, "path kinds are exported");
assertEqual(
result.signalStats.sampling.toneObservations[0].adcFrequencyHz,
3_000,
"ADC alias is exported",
);
assertEqual(
result.bands[0].frequencyObservation.adcBins.join(","),
"93,94,95,96,97,98,99",
"requested analog band exports every folded ADC bin",
);
assertEqual(
result.bands[0].frequencyObservation.equivalentBandIds.join(","),
"band-2",
"alias-equivalent band is disclosed",
);
assertEqual(
result.bands[1].frequencyObservation.equivalentBandIds.join(","),
"band-1",
"equivalence is symmetric",
);
assertEqual(
result.bands[0].frequencyObservation.localizationModel,
"spherical-steering-at-positive-adc-bin-frequency",
"actual DAS-family localization model is explicit",
);
assertEqual(
result.bands[0].frequencyObservation.originalCarrierUsed,
false,
"result does not imply hidden original-carrier steering",
);
assertEqual(
result.bands[0].evaluation.truthSelection,
"tone-analog-interval-or-nearest-adc-bin-and-all-nontone-sources",
"truth-selection policy is explicit",
);
const history = createHistorySummary({
arrayDefinition,
@@ -584,6 +712,70 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
const issues = await validateResult(result, { experiment, scene });
assertEqual(issues.length, 0, `generated result validation: ${JSON.stringify(issues)}`);
assertEqual(
(await validateResult(result)).length,
0,
"generated Result is self-consistent without external context",
);
const standaloneCorruption = structuredClone(result);
standaloneCorruption.signalStats.sampling.nyquistHz += 1;
standaloneCorruption.signalStats.sampling.toneObservations.push(
structuredClone(standaloneCorruption.signalStats.sampling.toneObservations[0]),
);
standaloneCorruption.bands[0].frequencyObservation.adcBins.push(
standaloneCorruption.bands[0].frequencyObservation.adcBins[0],
);
standaloneCorruption.bands[0].frequencyObservation.equivalentBandIds.push("band-2");
standaloneCorruption.bands[0].frequencyObservation.localizationModel =
"band-limited-gcc-phat-geometric-lag";
const standaloneIssues = await validateResult(standaloneCorruption);
assert(
standaloneIssues.some(({ path }) => path === "$.signalStats.sampling.nyquistHz"),
"standalone validator checks Nyquist self-consistency",
);
assert(
standaloneIssues.some(({ message }) => message.includes("duplicate tone observation")),
"standalone validator rejects duplicate tone provenance",
);
assert(
standaloneIssues.some(({ path }) => path.endsWith("frequencyObservation.adcBins")),
"standalone validator recomputes unique ADC bins",
);
assert(
standaloneIssues.some(({ path }) =>
path.endsWith("frequencyObservation.equivalentBandIds")),
"standalone validator recomputes equivalent band IDs",
);
assert(
standaloneIssues.some(({ path }) =>
path.endsWith("frequencyObservation.localizationModel")),
"standalone validator links localization model to actual algorithm",
);
result.signalStats.sampling.toneObservations[0].signedAdcFrequencyHz += 1;
let semanticIssues = await validateResult(result, { experiment, scene });
assert(
semanticIssues.some(({ path }) => path.endsWith("signedAdcFrequencyHz")),
"validator recomputes the temporal alias",
);
result.signalStats.sampling.toneObservations[0].signedAdcFrequencyHz -= 1;
result.bands[0].frequencyObservation.adcBins[0] += 1;
semanticIssues = await validateResult(result, { experiment, scene });
assert(
semanticIssues.some(({ path }) => path.endsWith("frequencyObservation.adcBins")),
"validator recomputes folded ADC bins",
);
result.bands[0].frequencyObservation.adcBins[0] -= 1;
const missingBandResult = structuredClone(result);
missingBandResult.bands.pop();
semanticIssues = await validateResult(missingBandResult, { experiment, scene });
assert(
semanticIssues.some(({ message }) => message.includes("missing Experiment band result")),
"validator rejects incomplete Result band sets",
);
const wrongExperiment = { ...experiment, arrayId: "another-array" };
const linkageIssues = await validateResult(result, { experiment: wrongExperiment, scene });