fix: expose physical aliasing and delayed arrivals
This commit is contained in:
+194
-2
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user