feat(lab): stabilize autonomous TGS playback
This commit is contained in:
@@ -352,6 +352,7 @@ interface RecordedSegmentStreamRuntime {
|
|||||||
target: RecordedSegmentTarget | null;
|
target: RecordedSegmentTarget | null;
|
||||||
fetchAbort: AbortController | null;
|
fetchAbort: AbortController | null;
|
||||||
notifiedRevision: number;
|
notifiedRevision: number;
|
||||||
|
hasPresentedFrame: boolean;
|
||||||
pumping: boolean;
|
pumping: boolean;
|
||||||
disposed: boolean;
|
disposed: boolean;
|
||||||
onTargetBuffered: ((target: RecordedSegmentTarget) => void) | null;
|
onTargetBuffered: ((target: RecordedSegmentTarget) => void) | null;
|
||||||
@@ -944,6 +945,7 @@ export function RecordedFmp4Player({
|
|||||||
target: null,
|
target: null,
|
||||||
fetchAbort: null,
|
fetchAbort: null,
|
||||||
notifiedRevision: 0,
|
notifiedRevision: 0,
|
||||||
|
hasPresentedFrame: false,
|
||||||
pumping: false,
|
pumping: false,
|
||||||
disposed: false,
|
disposed: false,
|
||||||
onTargetBuffered: null,
|
onTargetBuffered: null,
|
||||||
@@ -1090,8 +1092,13 @@ export function RecordedFmp4Player({
|
|||||||
runtime.fetchAbort?.abort();
|
runtime.fetchAbort?.abort();
|
||||||
runtime.target = target;
|
runtime.target = target;
|
||||||
const alreadyBuffered = recordedSegmentTargetBuffered(runtime, target);
|
const alreadyBuffered = recordedSegmentTargetBuffered(runtime, target);
|
||||||
|
const retainForwardFrame = Boolean(
|
||||||
|
runtime.hasPresentedFrame
|
||||||
|
&& previousTarget
|
||||||
|
&& candidateTarget.sequence >= previousTarget.sequence,
|
||||||
|
);
|
||||||
video.pause();
|
video.pause();
|
||||||
if (!alreadyBuffered) {
|
if (!alreadyBuffered && !retainForwardFrame) {
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
setState("loading");
|
setState("loading");
|
||||||
}
|
}
|
||||||
@@ -1119,6 +1126,7 @@ export function RecordedFmp4Player({
|
|||||||
|| runtime.target?.revision !== bufferedTarget.revision
|
|| runtime.target?.revision !== bufferedTarget.revision
|
||||||
) return;
|
) return;
|
||||||
setBufferRevision((revision) => revision + 1);
|
setBufferRevision((revision) => revision + 1);
|
||||||
|
runtime.hasPresentedFrame = true;
|
||||||
setReadyGeneration(runtime.generation);
|
setReadyGeneration(runtime.generation);
|
||||||
setState("ready");
|
setState("ready");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useRef,
|
useRef,
|
||||||
@@ -53,6 +54,12 @@ export interface LaboratoryMetricCellEvidence {
|
|||||||
state: LaboratoryMetricCellState;
|
state: LaboratoryMetricCellState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LaboratoryMetricPackedCellEvidence {
|
||||||
|
centersBodyXyM: Float32Array;
|
||||||
|
zBoundsM: Float32Array;
|
||||||
|
stateCodes: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LaboratoryMetricLegendEntry {
|
export interface LaboratoryMetricLegendEntry {
|
||||||
id: LaboratoryMetricDecision
|
id: LaboratoryMetricDecision
|
||||||
| LaboratoryMetricCellState
|
| LaboratoryMetricCellState
|
||||||
@@ -63,6 +70,13 @@ export interface LaboratoryMetricLegendEntry {
|
|||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LaboratoryMetricRenderStats {
|
||||||
|
frameMs: number;
|
||||||
|
drawCalls: number;
|
||||||
|
triangles: number;
|
||||||
|
pixelRatio: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function laboratoryMetricLegendEntries({
|
export function laboratoryMetricLegendEntries({
|
||||||
pointCloudCount,
|
pointCloudCount,
|
||||||
localSurfaceCount,
|
localSurfaceCount,
|
||||||
@@ -256,6 +270,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
semanticClasses?: readonly RecordedEvidenceSemanticClass[];
|
semanticClasses?: readonly RecordedEvidenceSemanticClass[];
|
||||||
semanticPalette?: readonly RecordedEvidenceSemanticPaletteEntry[];
|
semanticPalette?: readonly RecordedEvidenceSemanticPaletteEntry[];
|
||||||
classifiedCells?: readonly LaboratoryMetricCellEvidence[];
|
classifiedCells?: readonly LaboratoryMetricCellEvidence[];
|
||||||
|
classifiedPackedCells?: LaboratoryMetricPackedCellEvidence;
|
||||||
classifiedCellSizeM?: number;
|
classifiedCellSizeM?: number;
|
||||||
showClassifiedCells?: boolean;
|
showClassifiedCells?: boolean;
|
||||||
}
|
}
|
||||||
@@ -276,6 +291,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
semanticClasses,
|
semanticClasses,
|
||||||
semanticPalette,
|
semanticPalette,
|
||||||
classifiedCells = [],
|
classifiedCells = [],
|
||||||
|
classifiedPackedCells,
|
||||||
classifiedCellSizeM = 0.45,
|
classifiedCellSizeM = 0.45,
|
||||||
showClassifiedCells = true,
|
showClassifiedCells = true,
|
||||||
}, ref) {
|
}, ref) {
|
||||||
@@ -288,10 +304,16 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
const classifiedContentRef = useRef<THREE.Group | null>(null);
|
const classifiedContentRef = useRef<THREE.Group | null>(null);
|
||||||
const localSurfacePointsRef = useRef<THREE.Points | null>(null);
|
const localSurfacePointsRef = useRef<THREE.Points | null>(null);
|
||||||
const currentIncrementPointsRef = useRef<THREE.Points | null>(null);
|
const currentIncrementPointsRef = useRef<THREE.Points | null>(null);
|
||||||
|
const requestRenderRef = useRef<() => void>(() => undefined);
|
||||||
|
const corridorForwardLengthRef = useRef(corridor.forwardLengthM);
|
||||||
|
const modeRef = useRef(mode);
|
||||||
const classifiedMeshesRef = useRef<ReadonlyMap<LaboratoryMetricCellState, THREE.InstancedMesh>>(
|
const classifiedMeshesRef = useRef<ReadonlyMap<LaboratoryMetricCellState, THREE.InstancedMesh>>(
|
||||||
new Map(),
|
new Map(),
|
||||||
);
|
);
|
||||||
const [renderError, setRenderError] = useState<string | null>(null);
|
const [renderError, setRenderError] = useState<string | null>(null);
|
||||||
|
const [renderStats, setRenderStats] = useState<LaboratoryMetricRenderStats | null>(null);
|
||||||
|
corridorForwardLengthRef.current = corridor.forwardLengthM;
|
||||||
|
modeRef.current = mode;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const host = hostRef.current;
|
const host = hostRef.current;
|
||||||
@@ -368,27 +390,51 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
localSurfacePointsRef.current = localSurfacePoints;
|
localSurfacePointsRef.current = localSurfacePoints;
|
||||||
currentIncrementPointsRef.current = currentIncrementPoints;
|
currentIncrementPointsRef.current = currentIncrementPoints;
|
||||||
|
|
||||||
|
let animationFrame: number | null = null;
|
||||||
|
let lastStatsAt = Number.NEGATIVE_INFINITY;
|
||||||
|
const render = () => {
|
||||||
|
animationFrame = null;
|
||||||
|
// OrbitControls emits another change while damping is still settling, so
|
||||||
|
// this remains smooth during interaction without rendering forever while
|
||||||
|
// the evidence scene is idle.
|
||||||
|
controls.update();
|
||||||
|
const startedAt = performance.now();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
const completedAt = performance.now();
|
||||||
|
if (completedAt - lastStatsAt >= 1_000) {
|
||||||
|
lastStatsAt = completedAt;
|
||||||
|
setRenderStats({
|
||||||
|
frameMs: completedAt - startedAt,
|
||||||
|
drawCalls: renderer.info.render.calls,
|
||||||
|
triangles: renderer.info.render.triangles,
|
||||||
|
pixelRatio: renderer.getPixelRatio(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const requestRender = () => {
|
||||||
|
if (animationFrame !== null) return;
|
||||||
|
animationFrame = window.requestAnimationFrame(render);
|
||||||
|
};
|
||||||
|
requestRenderRef.current = requestRender;
|
||||||
|
controls.addEventListener("change", requestRender);
|
||||||
|
|
||||||
const resize = () => {
|
const resize = () => {
|
||||||
const width = Math.max(host.clientWidth, 1);
|
const width = Math.max(host.clientWidth, 1);
|
||||||
const height = Math.max(host.clientHeight, 1);
|
const height = Math.max(host.clientHeight, 1);
|
||||||
camera.aspect = width / height;
|
camera.aspect = width / height;
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
renderer.setSize(width, height, false);
|
renderer.setSize(width, height, false);
|
||||||
|
requestRender();
|
||||||
};
|
};
|
||||||
const observer = new ResizeObserver(resize);
|
const observer = new ResizeObserver(resize);
|
||||||
observer.observe(host);
|
observer.observe(host);
|
||||||
resize();
|
resize();
|
||||||
|
|
||||||
let animationFrame = 0;
|
requestRender();
|
||||||
const render = () => {
|
|
||||||
animationFrame = window.requestAnimationFrame(render);
|
|
||||||
controls.update();
|
|
||||||
renderer.render(scene, camera);
|
|
||||||
};
|
|
||||||
render();
|
|
||||||
return () => {
|
return () => {
|
||||||
window.cancelAnimationFrame(animationFrame);
|
if (animationFrame !== null) window.cancelAnimationFrame(animationFrame);
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
|
controls.removeEventListener("change", requestRender);
|
||||||
controls.dispose();
|
controls.dispose();
|
||||||
scene.traverse(disposeRenderable);
|
scene.traverse(disposeRenderable);
|
||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
@@ -401,6 +447,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
classifiedContentRef.current = null;
|
classifiedContentRef.current = null;
|
||||||
localSurfacePointsRef.current = null;
|
localSurfacePointsRef.current = null;
|
||||||
currentIncrementPointsRef.current = null;
|
currentIncrementPointsRef.current = null;
|
||||||
|
requestRenderRef.current = () => undefined;
|
||||||
classifiedMeshesRef.current = new Map();
|
classifiedMeshesRef.current = new Map();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@@ -415,6 +462,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
if (!points) return;
|
if (!points) return;
|
||||||
points.visible = showLocalSurface && localSurfaceBodyXyzM.length > 0;
|
points.visible = showLocalSurface && localSurfaceBodyXyzM.length > 0;
|
||||||
if (points.visible) updatePointPositions(points.geometry, localSurfaceBodyXyzM);
|
if (points.visible) updatePointPositions(points.geometry, localSurfaceBodyXyzM);
|
||||||
|
requestRenderRef.current();
|
||||||
}, [localSurfaceBodyXyzM, showLocalSurface]);
|
}, [localSurfaceBodyXyzM, showLocalSurface]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -422,7 +470,10 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
const points = currentIncrementPointsRef.current;
|
const points = currentIncrementPointsRef.current;
|
||||||
if (!host || !points) return;
|
if (!host || !points) return;
|
||||||
points.visible = showCurrentIncrement && pointCloudBodyXyzM.length > 0;
|
points.visible = showCurrentIncrement && pointCloudBodyXyzM.length > 0;
|
||||||
if (!points.visible) return;
|
if (!points.visible) {
|
||||||
|
requestRenderRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
updatePointPositions(points.geometry, pointCloudBodyXyzM);
|
updatePointPositions(points.geometry, pointCloudBodyXyzM);
|
||||||
const material = points.material as THREE.PointsMaterial;
|
const material = points.material as THREE.PointsMaterial;
|
||||||
const hasAlignedSemanticClasses =
|
const hasAlignedSemanticClasses =
|
||||||
@@ -436,6 +487,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
material.needsUpdate = true;
|
material.needsUpdate = true;
|
||||||
}
|
}
|
||||||
material.color.copy(tokenColor(host, "--nodedc-text-muted", [147, 151, 159]));
|
material.color.copy(tokenColor(host, "--nodedc-text-muted", [147, 151, 159]));
|
||||||
|
requestRenderRef.current();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const declaredIds = new Set(semanticClasses.map((item) => item.id));
|
const declaredIds = new Set(semanticClasses.map((item) => item.id));
|
||||||
@@ -461,6 +513,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
material.needsUpdate = true;
|
material.needsUpdate = true;
|
||||||
}
|
}
|
||||||
material.color.setRGB(1, 1, 1);
|
material.color.setRGB(1, 1, 1);
|
||||||
|
requestRenderRef.current();
|
||||||
}, [
|
}, [
|
||||||
pointCloudBodyXyzM,
|
pointCloudBodyXyzM,
|
||||||
pointSemanticClassIds,
|
pointSemanticClassIds,
|
||||||
@@ -543,7 +596,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
content.add(centroid);
|
content.add(centroid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
requestRenderRef.current();
|
||||||
}, [
|
}, [
|
||||||
obstacles,
|
obstacles,
|
||||||
occupiedVoxelSizeM,
|
occupiedVoxelSizeM,
|
||||||
@@ -556,10 +609,21 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
const host = hostRef.current;
|
const host = hostRef.current;
|
||||||
const content = classifiedContentRef.current;
|
const content = classifiedContentRef.current;
|
||||||
if (!host || !content) return;
|
if (!host || !content) return;
|
||||||
content.visible = showClassifiedCells && classifiedCells.length > 0;
|
const packedCellCount = classifiedPackedCells?.stateCodes.length ?? 0;
|
||||||
if (!content.visible) return;
|
const packedCellsValid = !classifiedPackedCells || (
|
||||||
|
classifiedPackedCells.centersBodyXyM.length === packedCellCount * 2
|
||||||
|
&& classifiedPackedCells.zBoundsM.length === packedCellCount * 2
|
||||||
|
);
|
||||||
|
const cellCount = packedCellsValid && classifiedPackedCells
|
||||||
|
? packedCellCount
|
||||||
|
: classifiedCells.length;
|
||||||
|
content.visible = showClassifiedCells && cellCount > 0;
|
||||||
|
if (!content.visible) {
|
||||||
|
requestRenderRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const requiredCapacity = classifiedCells.length;
|
const requiredCapacity = cellCount;
|
||||||
const currentMeshes = classifiedMeshesRef.current;
|
const currentMeshes = classifiedMeshesRef.current;
|
||||||
const firstMesh = currentMeshes.values().next().value as THREE.InstancedMesh | undefined;
|
const firstMesh = currentMeshes.values().next().value as THREE.InstancedMesh | undefined;
|
||||||
const needsAllocation = !firstMesh
|
const needsAllocation = !firstMesh
|
||||||
@@ -609,12 +673,16 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
const position = new THREE.Vector3();
|
const position = new THREE.Vector3();
|
||||||
const scale = new THREE.Vector3(1, 1, 1);
|
const scale = new THREE.Vector3(1, 1, 1);
|
||||||
const rotation = new THREE.Quaternion();
|
const rotation = new THREE.Quaternion();
|
||||||
for (const cell of classifiedCells) {
|
const setCell = (
|
||||||
const mesh = meshes.get(cell.state);
|
state: LaboratoryMetricCellState,
|
||||||
if (!mesh) continue;
|
centerX: number,
|
||||||
const index = counts.get(cell.state) ?? 0;
|
centerY: number,
|
||||||
const minimum = cell.zBoundsM[0];
|
minimum: number | null,
|
||||||
const maximum = cell.zBoundsM[1];
|
maximum: number | null,
|
||||||
|
) => {
|
||||||
|
const mesh = meshes.get(state);
|
||||||
|
if (!mesh) return;
|
||||||
|
const index = counts.get(state) ?? 0;
|
||||||
const height = minimum === null || maximum === null
|
const height = minimum === null || maximum === null
|
||||||
? 0.018
|
? 0.018
|
||||||
: Math.max(0.018, maximum - minimum);
|
: Math.max(0.018, maximum - minimum);
|
||||||
@@ -622,21 +690,55 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
? -0.012
|
? -0.012
|
||||||
: (minimum + maximum) / 2;
|
: (minimum + maximum) / 2;
|
||||||
const [sceneX, sceneY, sceneZ] = scenePoint([
|
const [sceneX, sceneY, sceneZ] = scenePoint([
|
||||||
cell.centerBodyXyM[0],
|
centerX,
|
||||||
cell.centerBodyXyM[1],
|
centerY,
|
||||||
centerZ,
|
centerZ,
|
||||||
]);
|
]);
|
||||||
position.set(sceneX, sceneY, sceneZ);
|
position.set(sceneX, sceneY, sceneZ);
|
||||||
scale.set(1, height, 1);
|
scale.set(1, height, 1);
|
||||||
matrix.compose(position, rotation, scale);
|
matrix.compose(position, rotation, scale);
|
||||||
mesh.setMatrixAt(index, matrix);
|
mesh.setMatrixAt(index, matrix);
|
||||||
counts.set(cell.state, index + 1);
|
counts.set(state, index + 1);
|
||||||
|
};
|
||||||
|
if (packedCellsValid && classifiedPackedCells) {
|
||||||
|
for (let cellIndex = 0; cellIndex < packedCellCount; cellIndex += 1) {
|
||||||
|
const code = classifiedPackedCells.stateCodes[cellIndex];
|
||||||
|
const state: LaboratoryMetricCellState = code === 1
|
||||||
|
? "ground-support"
|
||||||
|
: code === 2
|
||||||
|
? "nonground-occupied"
|
||||||
|
: code === 3
|
||||||
|
? "unknown-rejected"
|
||||||
|
: code === 0
|
||||||
|
? "unobserved"
|
||||||
|
: "unknown-rejected";
|
||||||
|
const minimum = classifiedPackedCells.zBoundsM[cellIndex * 2]!;
|
||||||
|
const maximum = classifiedPackedCells.zBoundsM[cellIndex * 2 + 1]!;
|
||||||
|
setCell(
|
||||||
|
state,
|
||||||
|
classifiedPackedCells.centersBodyXyM[cellIndex * 2]!,
|
||||||
|
classifiedPackedCells.centersBodyXyM[cellIndex * 2 + 1]!,
|
||||||
|
Number.isFinite(minimum) ? minimum : null,
|
||||||
|
Number.isFinite(maximum) ? maximum : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const cell of classifiedCells) {
|
||||||
|
setCell(
|
||||||
|
cell.state,
|
||||||
|
cell.centerBodyXyM[0],
|
||||||
|
cell.centerBodyXyM[1],
|
||||||
|
cell.zBoundsM[0],
|
||||||
|
cell.zBoundsM[1],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const [state, mesh] of meshes) {
|
for (const [state, mesh] of meshes) {
|
||||||
mesh.count = counts.get(state) ?? 0;
|
mesh.count = counts.get(state) ?? 0;
|
||||||
mesh.instanceMatrix.needsUpdate = true;
|
mesh.instanceMatrix.needsUpdate = true;
|
||||||
}
|
}
|
||||||
}, [classifiedCellSizeM, classifiedCells, showClassifiedCells]);
|
requestRenderRef.current();
|
||||||
|
}, [classifiedCellSizeM, classifiedCells, classifiedPackedCells, showClassifiedCells]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const host = hostRef.current;
|
const host = hostRef.current;
|
||||||
@@ -702,15 +804,24 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
material.depthWrite = false;
|
material.depthWrite = false;
|
||||||
});
|
});
|
||||||
content.add(grid);
|
content.add(grid);
|
||||||
}, [corridor, rig]);
|
requestRenderRef.current();
|
||||||
|
}, [
|
||||||
|
corridor.forwardLengthM,
|
||||||
|
corridor.halfWidthM,
|
||||||
|
corridor.rearMarginM,
|
||||||
|
rig.lengthM,
|
||||||
|
rig.nominalSensorHeightM,
|
||||||
|
rig.widthM,
|
||||||
|
]);
|
||||||
|
|
||||||
const resetView = () => {
|
const resetView = useCallback(() => {
|
||||||
const camera = cameraRef.current;
|
const camera = cameraRef.current;
|
||||||
const controls = controlsRef.current;
|
const controls = controlsRef.current;
|
||||||
if (!camera || !controls) return;
|
if (!camera || !controls) return;
|
||||||
controls.target.set(corridor.forwardLengthM * 0.35, 0.6, 0);
|
const forwardLengthM = corridorForwardLengthRef.current;
|
||||||
if (mode === "plan") {
|
controls.target.set(forwardLengthM * 0.35, 0.6, 0);
|
||||||
camera.position.set(corridor.forwardLengthM * 0.35, 15, 0.001);
|
if (modeRef.current === "plan") {
|
||||||
|
camera.position.set(forwardLengthM * 0.35, 15, 0.001);
|
||||||
camera.up.set(0, 0, -1);
|
camera.up.set(0, 0, -1);
|
||||||
} else {
|
} else {
|
||||||
camera.position.set(-4.5, 4.8, 8.5);
|
camera.position.set(-4.5, 4.8, 8.5);
|
||||||
@@ -718,10 +829,13 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
}
|
}
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
controls.update();
|
controls.update();
|
||||||
};
|
requestRenderRef.current();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(resetView, [corridor.forwardLengthM, mode]);
|
// Playback data and rig object identities must never reset an operator's
|
||||||
useImperativeHandle(ref, () => ({ resetView }));
|
// orbit. Only a deliberate 3D/PLAN mode transition chooses a default view.
|
||||||
|
useEffect(() => resetView(), [mode, resetView]);
|
||||||
|
useImperativeHandle(ref, () => ({ resetView }), [resetView]);
|
||||||
|
|
||||||
const semanticLegendEntries = (() => {
|
const semanticLegendEntries = (() => {
|
||||||
if (
|
if (
|
||||||
@@ -755,8 +869,23 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
showLowStep,
|
showLowStep,
|
||||||
});
|
});
|
||||||
const classifiedLegendEntries = (() => {
|
const classifiedLegendEntries = (() => {
|
||||||
if (!showClassifiedCells || !classifiedCells.length) return [];
|
const states = new Set<LaboratoryMetricCellState>();
|
||||||
const states = new Set(classifiedCells.map((cell) => cell.state));
|
if (classifiedPackedCells) {
|
||||||
|
for (const code of classifiedPackedCells.stateCodes) {
|
||||||
|
states.add(code === 1
|
||||||
|
? "ground-support"
|
||||||
|
: code === 2
|
||||||
|
? "nonground-occupied"
|
||||||
|
: code === 3
|
||||||
|
? "unknown-rejected"
|
||||||
|
: code === 0
|
||||||
|
? "unobserved"
|
||||||
|
: "unknown-rejected");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
classifiedCells.forEach((cell) => states.add(cell.state));
|
||||||
|
}
|
||||||
|
if (!showClassifiedCells || !states.size) return [];
|
||||||
return [
|
return [
|
||||||
states.has("ground-support")
|
states.has("ground-support")
|
||||||
? { id: "ground-support" as const, label: "Ground support" }
|
? { id: "ground-support" as const, label: "Ground support" }
|
||||||
@@ -783,6 +912,16 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
{renderError ? <p>{renderError}</p> : null}
|
{renderError ? <p>{renderError}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="laboratory-metric-evidence-scene__legend">
|
<div className="laboratory-metric-evidence-scene__legend">
|
||||||
|
{renderStats ? (
|
||||||
|
<span
|
||||||
|
data-decision="performance"
|
||||||
|
title="CPU-время отправки последнего WebGL render pass; это не GPU timer. В покое сцена не перерисовывается."
|
||||||
|
>
|
||||||
|
3D {renderStats.frameMs.toFixed(1)} ms · {renderStats.drawCalls} draw · {renderStats.triangles.toLocaleString("ru-RU")} tri · {(
|
||||||
|
classifiedPackedCells?.stateCodes.length ?? classifiedCells.length
|
||||||
|
).toLocaleString("ru-RU")} cells · {renderStats.pixelRatio.toFixed(1)}×
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{metricLegendEntries.map((entry) => (
|
{metricLegendEntries.map((entry) => (
|
||||||
<span key={entry.id} data-decision={entry.id}>{entry.label}</span>
|
<span key={entry.id} data-decision={entry.id}>{entry.label}</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,671 @@
|
|||||||
|
import type { LaboratoryFetch } from "./advancedResults";
|
||||||
|
|
||||||
|
const RESULT_ID = /^m49-physical-safety-playback-[a-f0-9]{64}$/;
|
||||||
|
const SOURCE_RESULT_ID = /^m49-tgs-full-shadow-[a-f0-9]{64}$/;
|
||||||
|
const SCHEMA = "missioncore.m49-physical-safety-playback/v1";
|
||||||
|
const CHUNK_MAGIC = "MCPSCH01";
|
||||||
|
const CHUNK_HEADER_BYTES = 28;
|
||||||
|
const ENDPOINT_ROOT = "/api/v1/laboratory/m49/physical-safety-playback";
|
||||||
|
|
||||||
|
export interface M49PhysicalSafetyPlaybackFrameMetadata {
|
||||||
|
sourceSequence: number;
|
||||||
|
sourceFrameIndex: number;
|
||||||
|
sessionSeconds: number;
|
||||||
|
sampleAvailable: boolean;
|
||||||
|
metrics: Readonly<Record<string, number>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArtifactDescriptor {
|
||||||
|
url: string;
|
||||||
|
mediaType: string;
|
||||||
|
byteLength: number;
|
||||||
|
sha256: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChunkDescriptor extends ArtifactDescriptor {
|
||||||
|
index: number;
|
||||||
|
start: number;
|
||||||
|
count: number;
|
||||||
|
headerBytes: 28;
|
||||||
|
format: "mcpsch01-states-u8-aligned-z-bounds-f32le";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M49PhysicalSafetyPlaybackManifest {
|
||||||
|
resultId: string;
|
||||||
|
createdAtUtc: string;
|
||||||
|
sourceResultId: string;
|
||||||
|
frameCount: number;
|
||||||
|
cellCount: number;
|
||||||
|
cellSizeM: number;
|
||||||
|
radiusM: number;
|
||||||
|
sourcePaceHz: number;
|
||||||
|
stateCodes: Readonly<Record<string, number>>;
|
||||||
|
chunkFrameCount: 32;
|
||||||
|
startupPrebufferChunkCount: 2;
|
||||||
|
residentChunkCountMax: 3;
|
||||||
|
forwardPrefetchChunkCount: 1;
|
||||||
|
centers: ArtifactDescriptor;
|
||||||
|
frames: ArtifactDescriptor;
|
||||||
|
chunks: readonly ChunkDescriptor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResidentChunk {
|
||||||
|
descriptor: ChunkDescriptor;
|
||||||
|
states: Uint8Array;
|
||||||
|
zBoundsM: Float32Array;
|
||||||
|
byteLength: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M49PhysicalSafetyPlaybackFrame {
|
||||||
|
metadata: M49PhysicalSafetyPlaybackFrameMetadata;
|
||||||
|
centersXyM: Float32Array;
|
||||||
|
states: Uint8Array;
|
||||||
|
zBoundsM: Float32Array;
|
||||||
|
cellSizeM: number;
|
||||||
|
radiusM: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M49PhysicalSafetyPlaybackProgress {
|
||||||
|
phase: "manifest" | "static" | "prebuffer" | "ready" | "seek";
|
||||||
|
residentChunkIndexes: readonly number[];
|
||||||
|
loadedBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class M49PhysicalSafetyPlaybackContractError extends Error {}
|
||||||
|
|
||||||
|
function objectValue(value: unknown, label: string): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError(`${label}: ожидался объект.`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown, label: string): string {
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError(`${label}: ожидалась строка.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteNumber(value: unknown, label: string): number {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError(`${label}: ожидалось число.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function integer(value: unknown, label: string): number {
|
||||||
|
const parsed = finiteNumber(value, label);
|
||||||
|
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError(`${label}: ожидалось целое число.`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function booleanValue(value: unknown, label: string): boolean {
|
||||||
|
if (typeof value !== "boolean") {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError(`${label}: ожидался boolean.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exact<T extends string | number | boolean>(value: unknown, expected: T, label: string): T {
|
||||||
|
if (value !== expected) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError(`${label}: нарушен контракт.`);
|
||||||
|
}
|
||||||
|
return expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(value: unknown, label: string): string {
|
||||||
|
const parsed = text(value, label);
|
||||||
|
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError(`${label}: SHA-256 недопустим.`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localUrl(value: unknown, expected: string, label: string): string {
|
||||||
|
const parsed = text(value, label);
|
||||||
|
if (parsed !== expected || parsed.includes("worker")) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError(`${label}: разрешён только sealed local API.`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function descriptor(
|
||||||
|
value: unknown,
|
||||||
|
expectedUrl: string,
|
||||||
|
label: string,
|
||||||
|
): ArtifactDescriptor {
|
||||||
|
const row = objectValue(value, label);
|
||||||
|
return {
|
||||||
|
url: localUrl(row.url, expectedUrl, `${label}.url`),
|
||||||
|
mediaType: text(row.media_type, `${label}.media_type`),
|
||||||
|
byteLength: integer(row.byte_length, `${label}.byte_length`),
|
||||||
|
sha256: sha256(row.sha256, `${label}.sha256`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256Hex(buffer: ArrayBuffer): Promise<string> {
|
||||||
|
if (!globalThis.crypto?.subtle) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError(
|
||||||
|
"Браузер не поддерживает проверку playback SHA-256.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const digest = await globalThis.crypto.subtle.digest("SHA-256", buffer);
|
||||||
|
return [...new Uint8Array(digest)]
|
||||||
|
.map((value) => value.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchVerified(
|
||||||
|
artifact: ArtifactDescriptor,
|
||||||
|
fetcher: LaboratoryFetch,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ArrayBuffer> {
|
||||||
|
const response = await fetcher(artifact.url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: { Accept: artifact.mediaType },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError(
|
||||||
|
`Sealed local playback недоступен: HTTP ${response.status}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
if (buffer.byteLength !== artifact.byteLength) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError(
|
||||||
|
"Sealed local playback: получен неверный размер.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (await sha256Hex(buffer) !== artifact.sha256) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError(
|
||||||
|
"Sealed local playback: SHA-256 не совпал.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStateCodes(value: unknown): Readonly<Record<string, number>> {
|
||||||
|
const row = objectValue(value, "M49 physical-safety state codes");
|
||||||
|
const parsed: Record<string, number> = {};
|
||||||
|
for (const [name, code] of Object.entries(row)) {
|
||||||
|
const stateCode = integer(code, `M49 physical-safety state code ${name}`);
|
||||||
|
if (stateCode > 255) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError("M49 state code превышает uint8.");
|
||||||
|
}
|
||||||
|
parsed[name] = stateCode;
|
||||||
|
}
|
||||||
|
if (parsed.UNOBSERVED !== 0 || !Object.keys(parsed).length) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError("M49 UNOBSERVED state изменился.");
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchManifest(
|
||||||
|
resultId: string,
|
||||||
|
fetcher: LaboratoryFetch,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<M49PhysicalSafetyPlaybackManifest> {
|
||||||
|
if (!RESULT_ID.test(resultId)) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError("M49 physical-safety identity недопустима.");
|
||||||
|
}
|
||||||
|
const base = `${ENDPOINT_ROOT}/${encodeURIComponent(resultId)}`;
|
||||||
|
const response = await fetcher(`${base}/manifest`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError(
|
||||||
|
`M49 physical-safety manifest недоступен: HTTP ${response.status}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const payload = objectValue(await response.json(), "M49 physical-safety manifest");
|
||||||
|
exact(payload.schema_version, SCHEMA, "M49 physical-safety schema");
|
||||||
|
exact(payload.result_id, resultId, "M49 physical-safety result");
|
||||||
|
exact(payload.access, "read-only-sealed-local", "M49 physical-safety access");
|
||||||
|
const identity = objectValue(payload.identity, "M49 physical-safety identity");
|
||||||
|
const execution = objectValue(payload.execution, "M49 physical-safety execution");
|
||||||
|
exact(execution.execution_class, "local-sequential-offline", "M49 execution class");
|
||||||
|
exact(execution.worker_role, "realtime-only", "M49 worker role");
|
||||||
|
exact(execution.worker_runtime_dependency, false, "M49 worker dependency");
|
||||||
|
exact(execution.worker_requests_required, 0, "M49 worker requests");
|
||||||
|
const authority = objectValue(payload.authority, "M49 physical-safety authority");
|
||||||
|
exact(authority.commands_enabled, false, "M49 commands");
|
||||||
|
exact(authority.navigation_or_safety_accepted, false, "M49 safety authority");
|
||||||
|
exact(authority.actuation_accepted, false, "M49 actuation authority");
|
||||||
|
const playback = objectValue(payload.playback, "M49 physical-safety playback");
|
||||||
|
exact(playback.coordinate_frame, "map-gravity-local", "M49 coordinate frame");
|
||||||
|
const frameCount = integer(playback.frame_count, "M49 frame count");
|
||||||
|
const cellCount = integer(playback.cell_count, "M49 cell count");
|
||||||
|
if (!frameCount || !cellCount) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError("M49 playback не содержит evidence.");
|
||||||
|
}
|
||||||
|
const chunkFrameCount = exact(
|
||||||
|
integer(playback.chunk_frame_count, "M49 chunk frame count"),
|
||||||
|
32,
|
||||||
|
"M49 chunk frame count",
|
||||||
|
);
|
||||||
|
const startupPrebufferChunkCount = exact(
|
||||||
|
integer(playback.startup_prebuffer_chunk_count, "M49 startup prebuffer"),
|
||||||
|
2,
|
||||||
|
"M49 startup prebuffer",
|
||||||
|
);
|
||||||
|
const residentChunkCountMax = exact(
|
||||||
|
integer(playback.resident_chunk_count_max, "M49 resident chunks"),
|
||||||
|
3,
|
||||||
|
"M49 resident chunks",
|
||||||
|
);
|
||||||
|
const forwardPrefetchChunkCount = exact(
|
||||||
|
integer(playback.forward_prefetch_chunk_count, "M49 forward prefetch"),
|
||||||
|
1,
|
||||||
|
"M49 forward prefetch",
|
||||||
|
);
|
||||||
|
const centers = descriptor(playback.centers, `${base}/tracks/centers`, "M49 centers");
|
||||||
|
const centerRow = objectValue(playback.centers, "M49 centers");
|
||||||
|
exact(centerRow.dtype, "<f4", "M49 centers dtype");
|
||||||
|
if (!Array.isArray(centerRow.shape)
|
||||||
|
|| centerRow.shape.length !== 2
|
||||||
|
|| centerRow.shape[0] !== cellCount
|
||||||
|
|| centerRow.shape[1] !== 2
|
||||||
|
|| centers.byteLength !== cellCount * 2 * 4) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError("M49 centers shape изменилась.");
|
||||||
|
}
|
||||||
|
const frames = descriptor(playback.frames, `${base}/tracks/frames`, "M49 frames");
|
||||||
|
const frameRow = objectValue(playback.frames, "M49 frames");
|
||||||
|
exact(frameRow.dtype, "ndjson", "M49 frames dtype");
|
||||||
|
if (!Array.isArray(frameRow.shape)
|
||||||
|
|| frameRow.shape.length !== 1
|
||||||
|
|| frameRow.shape[0] !== frameCount) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError("M49 frames shape изменилась.");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(playback.chunks)) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError("M49 chunk catalog отсутствует.");
|
||||||
|
}
|
||||||
|
const expectedChunkCount = Math.ceil(frameCount / chunkFrameCount);
|
||||||
|
if (playback.chunks.length !== expectedChunkCount) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError("M49 chunk accounting нарушен.");
|
||||||
|
}
|
||||||
|
const chunks = playback.chunks.map((value, index): ChunkDescriptor => {
|
||||||
|
const row = objectValue(value, `M49 chunk ${index}`);
|
||||||
|
const common = descriptor(value, `${base}/chunks/${index}`, `M49 chunk ${index}`);
|
||||||
|
const start = index * chunkFrameCount;
|
||||||
|
const count = Math.min(chunkFrameCount, frameCount - start);
|
||||||
|
exact(integer(row.index, `M49 chunk ${index}.index`), index, `M49 chunk ${index}.index`);
|
||||||
|
exact(integer(row.start, `M49 chunk ${index}.start`), start, `M49 chunk ${index}.start`);
|
||||||
|
exact(integer(row.count, `M49 chunk ${index}.count`), count, `M49 chunk ${index}.count`);
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
index,
|
||||||
|
start,
|
||||||
|
count,
|
||||||
|
headerBytes: exact(
|
||||||
|
integer(row.header_bytes, `M49 chunk ${index}.header`),
|
||||||
|
CHUNK_HEADER_BYTES,
|
||||||
|
`M49 chunk ${index}.header`,
|
||||||
|
),
|
||||||
|
format: exact(
|
||||||
|
row.format,
|
||||||
|
"mcpsch01-states-u8-aligned-z-bounds-f32le",
|
||||||
|
`M49 chunk ${index}.format`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
resultId,
|
||||||
|
createdAtUtc: text(payload.created_at_utc, "M49 physical-safety created"),
|
||||||
|
sourceResultId: text(identity.source_result_id, "M49 physical-safety source"),
|
||||||
|
frameCount,
|
||||||
|
cellCount,
|
||||||
|
cellSizeM: finiteNumber(playback.cell_size_m, "M49 cell size"),
|
||||||
|
radiusM: finiteNumber(playback.radius_m, "M49 radius"),
|
||||||
|
sourcePaceHz: finiteNumber(playback.source_pace_hz, "M49 source pace"),
|
||||||
|
stateCodes: parseStateCodes(playback.state_codes),
|
||||||
|
chunkFrameCount,
|
||||||
|
startupPrebufferChunkCount,
|
||||||
|
residentChunkCountMax,
|
||||||
|
forwardPrefetchChunkCount,
|
||||||
|
centers,
|
||||||
|
frames,
|
||||||
|
chunks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchM49PhysicalSafetyPlaybackIdForSource(
|
||||||
|
sourceResultId: string,
|
||||||
|
{
|
||||||
|
fetcher = fetch,
|
||||||
|
signal,
|
||||||
|
}: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!SOURCE_RESULT_ID.test(sourceResultId)) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError("M49 source identity недопустима.");
|
||||||
|
}
|
||||||
|
const response = await fetcher(
|
||||||
|
`${ENDPOINT_ROOT}/results?limit=2&source_result_id=${encodeURIComponent(sourceResultId)}`,
|
||||||
|
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError(
|
||||||
|
`M49 physical-safety catalog недоступен: HTTP ${response.status}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const payload = objectValue(await response.json(), "M49 physical-safety catalog");
|
||||||
|
exact(
|
||||||
|
payload.schema_version,
|
||||||
|
"missioncore.m49-physical-safety-playback-catalog/v1",
|
||||||
|
"M49 physical-safety catalog schema",
|
||||||
|
);
|
||||||
|
exact(payload.worker_runtime_dependency, false, "M49 physical-safety catalog worker");
|
||||||
|
exact(payload.access, "read-only-sealed-local", "M49 physical-safety catalog access");
|
||||||
|
if (!Array.isArray(payload.items)) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError("M49 physical-safety catalog изменился.");
|
||||||
|
}
|
||||||
|
if (!payload.items.length) return null;
|
||||||
|
if (payload.items.length !== 1) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError(
|
||||||
|
"M49 physical-safety source имеет несколько активных playback artifacts.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const item = objectValue(payload.items[0], "M49 physical-safety catalog item");
|
||||||
|
exact(item.source_result_id, sourceResultId, "M49 physical-safety catalog source");
|
||||||
|
exact(item.worker_runtime_dependency, false, "M49 physical-safety item worker");
|
||||||
|
exact(item.navigation_or_safety_accepted, false, "M49 physical-safety item authority");
|
||||||
|
const resultId = text(item.result_id, "M49 physical-safety catalog result");
|
||||||
|
if (!RESULT_ID.test(resultId)) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError(
|
||||||
|
"M49 physical-safety catalog result недопустим.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return resultId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFrames(
|
||||||
|
buffer: ArrayBuffer,
|
||||||
|
frameCount: number,
|
||||||
|
): readonly M49PhysicalSafetyPlaybackFrameMetadata[] {
|
||||||
|
const lines = new TextDecoder().decode(buffer).trim().split("\n");
|
||||||
|
if (lines.length !== frameCount) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError("M49 frame catalog изменил размер.");
|
||||||
|
}
|
||||||
|
return lines.map((line, sourceSequence) => {
|
||||||
|
const row = objectValue(JSON.parse(line), `M49 frame ${sourceSequence}`);
|
||||||
|
const metrics: Record<string, number> = {};
|
||||||
|
for (const [name, value] of Object.entries(row)) {
|
||||||
|
if (name.endsWith("_count") && typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
metrics[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sourceSequence,
|
||||||
|
sourceFrameIndex: integer(row.source_frame_index, `M49 frame ${sourceSequence}.source`),
|
||||||
|
sessionSeconds: finiteNumber(row.session_seconds, `M49 frame ${sourceSequence}.time`),
|
||||||
|
sampleAvailable: booleanValue(
|
||||||
|
row.sample_available,
|
||||||
|
`M49 frame ${sourceSequence}.available`,
|
||||||
|
),
|
||||||
|
metrics,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChunk(
|
||||||
|
buffer: ArrayBuffer,
|
||||||
|
descriptorValue: ChunkDescriptor,
|
||||||
|
cellCount: number,
|
||||||
|
frames: readonly M49PhysicalSafetyPlaybackFrameMetadata[],
|
||||||
|
admittedStateCodes: ReadonlySet<number>,
|
||||||
|
): ResidentChunk {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
if (bytes.byteLength < CHUNK_HEADER_BYTES
|
||||||
|
|| new TextDecoder("latin1").decode(bytes.subarray(0, 8)) !== CHUNK_MAGIC) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError("M49 chunk signature изменилась.");
|
||||||
|
}
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
const start = view.getUint32(8, true);
|
||||||
|
const count = view.getUint32(12, true);
|
||||||
|
const cells = view.getUint32(16, true);
|
||||||
|
const stateBytes = view.getUint32(20, true);
|
||||||
|
const zBytes = view.getUint32(24, true);
|
||||||
|
const expectedStateBytes = count * cellCount;
|
||||||
|
const expectedZBytes = count * cellCount * 2 * 4;
|
||||||
|
const zOffset = CHUNK_HEADER_BYTES + stateBytes
|
||||||
|
+ ((4 - ((CHUNK_HEADER_BYTES + stateBytes) % 4)) % 4);
|
||||||
|
if (start !== descriptorValue.start
|
||||||
|
|| count !== descriptorValue.count
|
||||||
|
|| cells !== cellCount
|
||||||
|
|| stateBytes !== expectedStateBytes
|
||||||
|
|| zBytes !== expectedZBytes
|
||||||
|
|| zOffset + zBytes !== bytes.byteLength) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError("M49 chunk layout изменился.");
|
||||||
|
}
|
||||||
|
const states = new Uint8Array(buffer, CHUNK_HEADER_BYTES, stateBytes);
|
||||||
|
const zBoundsM = new Float32Array(buffer, zOffset, count * cellCount * 2);
|
||||||
|
for (let localFrame = 0; localFrame < count; localFrame += 1) {
|
||||||
|
const stateStart = localFrame * cellCount;
|
||||||
|
const zStart = localFrame * cellCount * 2;
|
||||||
|
for (let cell = 0; cell < cellCount; cell += 1) {
|
||||||
|
const state = states[stateStart + cell];
|
||||||
|
if (!admittedStateCodes.has(state)) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError("M49 chunk содержит неизвестный state.");
|
||||||
|
}
|
||||||
|
if (!frames[start + localFrame].sampleAvailable
|
||||||
|
&& (state !== 0
|
||||||
|
|| Number.isFinite(zBoundsM[zStart + cell * 2])
|
||||||
|
|| Number.isFinite(zBoundsM[zStart + cell * 2 + 1]))) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError(
|
||||||
|
"M49 missing sample перестал быть fail-closed.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { descriptor: descriptorValue, states, zBoundsM, byteLength: buffer.byteLength };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class M49PhysicalSafetyPlaybackBuffer {
|
||||||
|
readonly manifest: M49PhysicalSafetyPlaybackManifest;
|
||||||
|
readonly centersXyM: Float32Array;
|
||||||
|
readonly frames: readonly M49PhysicalSafetyPlaybackFrameMetadata[];
|
||||||
|
|
||||||
|
private readonly fetcher: LaboratoryFetch;
|
||||||
|
private readonly signal: AbortSignal | undefined;
|
||||||
|
private readonly onProgress: ((value: M49PhysicalSafetyPlaybackProgress) => void) | undefined;
|
||||||
|
private readonly chunks = new Map<number, ResidentChunk>();
|
||||||
|
private readonly pending = new Map<number, Promise<ResidentChunk>>();
|
||||||
|
private readonly admittedStateCodes: ReadonlySet<number>;
|
||||||
|
private staticBytes: number;
|
||||||
|
private preparedWindowKey: string | null = null;
|
||||||
|
private pendingPrepare: { key: string; promise: Promise<void> } | null = null;
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
manifest: M49PhysicalSafetyPlaybackManifest,
|
||||||
|
centersXyM: Float32Array,
|
||||||
|
frames: readonly M49PhysicalSafetyPlaybackFrameMetadata[],
|
||||||
|
fetcher: LaboratoryFetch,
|
||||||
|
signal: AbortSignal | undefined,
|
||||||
|
onProgress: ((value: M49PhysicalSafetyPlaybackProgress) => void) | undefined,
|
||||||
|
) {
|
||||||
|
this.manifest = manifest;
|
||||||
|
this.centersXyM = centersXyM;
|
||||||
|
this.frames = frames;
|
||||||
|
this.fetcher = fetcher;
|
||||||
|
this.signal = signal;
|
||||||
|
this.onProgress = onProgress;
|
||||||
|
this.admittedStateCodes = new Set(Object.values(manifest.stateCodes));
|
||||||
|
this.staticBytes = centersXyM.byteLength + manifest.frames.byteLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async open(
|
||||||
|
resultId: string,
|
||||||
|
{
|
||||||
|
fetcher = fetch,
|
||||||
|
signal,
|
||||||
|
onProgress,
|
||||||
|
}: {
|
||||||
|
fetcher?: LaboratoryFetch;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onProgress?: (value: M49PhysicalSafetyPlaybackProgress) => void;
|
||||||
|
} = {},
|
||||||
|
): Promise<M49PhysicalSafetyPlaybackBuffer> {
|
||||||
|
onProgress?.({ phase: "manifest", residentChunkIndexes: [], loadedBytes: 0 });
|
||||||
|
const manifest = await fetchManifest(resultId, fetcher, signal);
|
||||||
|
const [centersBuffer, framesBuffer] = await Promise.all([
|
||||||
|
fetchVerified(manifest.centers, fetcher, signal),
|
||||||
|
fetchVerified(manifest.frames, fetcher, signal),
|
||||||
|
]);
|
||||||
|
onProgress?.({
|
||||||
|
phase: "static",
|
||||||
|
residentChunkIndexes: [],
|
||||||
|
loadedBytes: centersBuffer.byteLength + framesBuffer.byteLength,
|
||||||
|
});
|
||||||
|
const centersXyM = new Float32Array(centersBuffer);
|
||||||
|
const frames = parseFrames(framesBuffer, manifest.frameCount);
|
||||||
|
const playback = new M49PhysicalSafetyPlaybackBuffer(
|
||||||
|
manifest,
|
||||||
|
centersXyM,
|
||||||
|
frames,
|
||||||
|
fetcher,
|
||||||
|
signal,
|
||||||
|
onProgress,
|
||||||
|
);
|
||||||
|
const startupCount = Math.min(
|
||||||
|
manifest.startupPrebufferChunkCount,
|
||||||
|
manifest.chunks.length,
|
||||||
|
);
|
||||||
|
await Promise.all(Array.from(
|
||||||
|
{ length: startupCount },
|
||||||
|
(_, index) => playback.loadChunk(index, "prebuffer"),
|
||||||
|
));
|
||||||
|
playback.emit("ready");
|
||||||
|
return playback;
|
||||||
|
}
|
||||||
|
|
||||||
|
get residentChunkIndexes(): readonly number[] {
|
||||||
|
return [...this.chunks.keys()].sort((left, right) => left - right);
|
||||||
|
}
|
||||||
|
|
||||||
|
get residentByteLength(): number {
|
||||||
|
return this.staticBytes
|
||||||
|
+ [...this.chunks.values()].reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async prepare(sourceSequence: number): Promise<void> {
|
||||||
|
const chunkIndex = this.chunkIndex(sourceSequence);
|
||||||
|
const nextIndex = chunkIndex + this.manifest.forwardPrefetchChunkCount;
|
||||||
|
const indexes = [chunkIndex, nextIndex].filter(
|
||||||
|
(index) => index < this.manifest.chunks.length,
|
||||||
|
);
|
||||||
|
const key = indexes.join(":");
|
||||||
|
if (this.preparedWindowKey === key && indexes.every((index) => this.chunks.has(index))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.pendingPrepare?.key === key) return this.pendingPrepare.promise;
|
||||||
|
const promise = Promise.all(indexes.map((index) => this.loadChunk(index, "seek")))
|
||||||
|
.then(() => {
|
||||||
|
this.trim(new Set(indexes));
|
||||||
|
this.preparedWindowKey = key;
|
||||||
|
this.emit("seek");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (this.pendingPrepare?.key === key) this.pendingPrepare = null;
|
||||||
|
});
|
||||||
|
this.pendingPrepare = { key, promise };
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async frame(sourceSequence: number): Promise<M49PhysicalSafetyPlaybackFrame> {
|
||||||
|
const chunkIndex = this.chunkIndex(sourceSequence);
|
||||||
|
const chunk = await this.loadChunk(chunkIndex, "seek");
|
||||||
|
const frame = this.frameFromChunk(sourceSequence, chunk);
|
||||||
|
const nextIndex = chunkIndex + this.manifest.forwardPrefetchChunkCount;
|
||||||
|
if (nextIndex < this.manifest.chunks.length && !this.chunks.has(nextIndex)) {
|
||||||
|
void this.loadChunk(nextIndex, "seek").catch(() => undefined);
|
||||||
|
}
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
frameIfResident(sourceSequence: number): M49PhysicalSafetyPlaybackFrame | null {
|
||||||
|
const chunkIndex = this.chunkIndex(sourceSequence);
|
||||||
|
const chunk = this.chunks.get(chunkIndex);
|
||||||
|
return chunk ? this.frameFromChunk(sourceSequence, chunk) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private frameFromChunk(
|
||||||
|
sourceSequence: number,
|
||||||
|
chunk: ResidentChunk,
|
||||||
|
): M49PhysicalSafetyPlaybackFrame {
|
||||||
|
const localFrame = sourceSequence - chunk.descriptor.start;
|
||||||
|
const stateOffset = localFrame * this.manifest.cellCount;
|
||||||
|
const zOffset = stateOffset * 2;
|
||||||
|
return {
|
||||||
|
metadata: this.frames[sourceSequence],
|
||||||
|
centersXyM: this.centersXyM,
|
||||||
|
states: chunk.states.subarray(stateOffset, stateOffset + this.manifest.cellCount),
|
||||||
|
zBoundsM: chunk.zBoundsM.subarray(zOffset, zOffset + this.manifest.cellCount * 2),
|
||||||
|
cellSizeM: this.manifest.cellSizeM,
|
||||||
|
radiusM: this.manifest.radiusM,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private chunkIndex(sourceSequence: number): number {
|
||||||
|
if (!Number.isSafeInteger(sourceSequence)
|
||||||
|
|| sourceSequence < 0
|
||||||
|
|| sourceSequence >= this.manifest.frameCount) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError("M49 source sequence недопустима.");
|
||||||
|
}
|
||||||
|
return Math.floor(sourceSequence / this.manifest.chunkFrameCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadChunk(
|
||||||
|
index: number,
|
||||||
|
phase: "prebuffer" | "seek",
|
||||||
|
): Promise<ResidentChunk> {
|
||||||
|
const existing = this.chunks.get(index);
|
||||||
|
if (existing) {
|
||||||
|
this.chunks.delete(index);
|
||||||
|
this.chunks.set(index, existing);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const inflight = this.pending.get(index);
|
||||||
|
if (inflight) return inflight;
|
||||||
|
const descriptorValue = this.manifest.chunks[index];
|
||||||
|
if (!descriptorValue) {
|
||||||
|
throw new M49PhysicalSafetyPlaybackContractError("M49 chunk отсутствует.");
|
||||||
|
}
|
||||||
|
const promise = fetchVerified(descriptorValue, this.fetcher, this.signal)
|
||||||
|
.then((buffer) => parseChunk(
|
||||||
|
buffer,
|
||||||
|
descriptorValue,
|
||||||
|
this.manifest.cellCount,
|
||||||
|
this.frames,
|
||||||
|
this.admittedStateCodes,
|
||||||
|
))
|
||||||
|
.then((chunk) => {
|
||||||
|
this.chunks.set(index, chunk);
|
||||||
|
this.trim(new Set([index]));
|
||||||
|
this.emit(phase);
|
||||||
|
return chunk;
|
||||||
|
})
|
||||||
|
.finally(() => this.pending.delete(index));
|
||||||
|
this.pending.set(index, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private trim(protectedIndexes: ReadonlySet<number>): void {
|
||||||
|
while (this.chunks.size > this.manifest.residentChunkCountMax) {
|
||||||
|
const removable = [...this.chunks.keys()].find((index) => !protectedIndexes.has(index));
|
||||||
|
if (removable === undefined) break;
|
||||||
|
this.chunks.delete(removable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(phase: M49PhysicalSafetyPlaybackProgress["phase"]): void {
|
||||||
|
this.onProgress?.({
|
||||||
|
phase,
|
||||||
|
residentChunkIndexes: this.residentChunkIndexes,
|
||||||
|
loadedBytes: this.residentByteLength,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,7 +120,7 @@ export interface M49TgsFullShadowPlaybackPack {
|
|||||||
frameCount: 4489;
|
frameCount: 4489;
|
||||||
cellCount: 2244;
|
cellCount: 2244;
|
||||||
totalByteLength: number;
|
totalByteLength: number;
|
||||||
centersXyM: readonly (readonly [number, number])[];
|
centersXyM: Float32Array;
|
||||||
states: Uint8Array;
|
states: Uint8Array;
|
||||||
zBoundsM: Float32Array;
|
zBoundsM: Float32Array;
|
||||||
frames: readonly M49TgsFullShadowPlaybackFrame[];
|
frames: readonly M49TgsFullShadowPlaybackFrame[];
|
||||||
@@ -628,9 +628,6 @@ export async function fetchM49TgsFullShadowPlaybackPack(
|
|||||||
"<f4",
|
"<f4",
|
||||||
centersTrack.shape,
|
centersTrack.shape,
|
||||||
) as Float32Array;
|
) as Float32Array;
|
||||||
const centersXyM = Array.from({ length: manifest.cellCount }, (_, index) => (
|
|
||||||
[centersRaw[index * 2]!, centersRaw[index * 2 + 1]!] as const
|
|
||||||
));
|
|
||||||
const states = parseM49TgsNpyTrack(
|
const states = parseM49TgsNpyTrack(
|
||||||
buffers.get("states")!,
|
buffers.get("states")!,
|
||||||
"|u1",
|
"|u1",
|
||||||
@@ -653,7 +650,7 @@ export async function fetchM49TgsFullShadowPlaybackPack(
|
|||||||
frameCount: manifest.frameCount,
|
frameCount: manifest.frameCount,
|
||||||
cellCount: manifest.cellCount,
|
cellCount: manifest.cellCount,
|
||||||
totalByteLength: manifest.totalByteLength,
|
totalByteLength: manifest.totalByteLength,
|
||||||
centersXyM,
|
centersXyM: centersRaw,
|
||||||
states,
|
states,
|
||||||
zBoundsM,
|
zBoundsM,
|
||||||
frames,
|
frames,
|
||||||
|
|||||||
@@ -238,6 +238,37 @@ export interface M4ThreatPlaybackPointPack {
|
|||||||
pointCount: number;
|
pointCount: number;
|
||||||
pointOffsets: Uint32Array;
|
pointOffsets: Uint32Array;
|
||||||
pointsMapXyzM: Float32Array;
|
pointsMapXyzM: Float32Array;
|
||||||
|
pointStart?: number;
|
||||||
|
startSequence?: number;
|
||||||
|
sequenceCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M4ThreatPlaybackChunkDescriptor {
|
||||||
|
index: number;
|
||||||
|
start: number;
|
||||||
|
count: number;
|
||||||
|
pointStart: number;
|
||||||
|
pointCount: number;
|
||||||
|
url: string;
|
||||||
|
mediaType: "application/octet-stream";
|
||||||
|
byteLength: number;
|
||||||
|
sha256: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M4ThreatPlaybackManifest {
|
||||||
|
resultId: string;
|
||||||
|
frameCount: 4489;
|
||||||
|
pointCount: number;
|
||||||
|
pointOffsets: Uint32Array;
|
||||||
|
chunkFrameCount: 24;
|
||||||
|
residentChunkCountMax: 4;
|
||||||
|
forwardPrefetchChunkCount: 1;
|
||||||
|
chunks: readonly M4ThreatPlaybackChunkDescriptor[];
|
||||||
|
track: {
|
||||||
|
url: string;
|
||||||
|
byteLength: number;
|
||||||
|
sha256: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||||
@@ -889,7 +920,14 @@ export function hydrateM4ThreatTimelineFrame(
|
|||||||
frame: M4ThreatTimelineFrame,
|
frame: M4ThreatTimelineFrame,
|
||||||
pack: M4ThreatPlaybackPointPack,
|
pack: M4ThreatPlaybackPointPack,
|
||||||
): M4ThreatTimelineFrame {
|
): M4ThreatTimelineFrame {
|
||||||
if (frame.sequence >= pack.frameCount || pack.resultId === "") {
|
if (
|
||||||
|
frame.sequence >= pack.frameCount
|
||||||
|
|| pack.resultId === ""
|
||||||
|
|| (pack.startSequence !== undefined && (
|
||||||
|
frame.sequence < pack.startSequence
|
||||||
|
|| frame.sequence >= pack.startSequence + (pack.sequenceCount ?? 0)
|
||||||
|
))
|
||||||
|
) {
|
||||||
throw new M4ThreatContractError("M4.6 binary playback frame: нарушена идентичность.");
|
throw new M4ThreatContractError("M4.6 binary playback frame: нарушена идентичность.");
|
||||||
}
|
}
|
||||||
if (!frame.sourceAvailable || !frame.spatialAvailable || !frame.bodyFrame) {
|
if (!frame.sourceAvailable || !frame.spatialAvailable || !frame.bodyFrame) {
|
||||||
@@ -900,7 +938,15 @@ export function hydrateM4ThreatTimelineFrame(
|
|||||||
}
|
}
|
||||||
const start = pack.pointOffsets[frame.sequence];
|
const start = pack.pointOffsets[frame.sequence];
|
||||||
const stop = pack.pointOffsets[frame.sequence + 1];
|
const stop = pack.pointOffsets[frame.sequence + 1];
|
||||||
if (start === undefined || stop === undefined || stop < start || stop > pack.pointCount) {
|
const pointStart = pack.pointStart ?? 0;
|
||||||
|
const pointStop = pointStart + pack.pointCount;
|
||||||
|
if (
|
||||||
|
start === undefined
|
||||||
|
|| stop === undefined
|
||||||
|
|| stop < start
|
||||||
|
|| start < pointStart
|
||||||
|
|| stop > pointStop
|
||||||
|
) {
|
||||||
throw new M4ThreatContractError("M4.6 binary playback offsets: нарушена размерность.");
|
throw new M4ThreatContractError("M4.6 binary playback offsets: нарушена размерность.");
|
||||||
}
|
}
|
||||||
const count = stop - start;
|
const count = stop - start;
|
||||||
@@ -911,7 +957,7 @@ export function hydrateM4ThreatTimelineFrame(
|
|||||||
const basis = frame.bodyFrame.basisMapFromBody;
|
const basis = frame.bodyFrame.basisMapFromBody;
|
||||||
const points = new Array<M4Point3>(count);
|
const points = new Array<M4Point3>(count);
|
||||||
for (let index = 0; index < count; index += 1) {
|
for (let index = 0; index < count; index += 1) {
|
||||||
const sourceOffset = (start + index) * 3;
|
const sourceOffset = (start - pointStart + index) * 3;
|
||||||
const dx = pack.pointsMapXyzM[sourceOffset]! - origin[0];
|
const dx = pack.pointsMapXyzM[sourceOffset]! - origin[0];
|
||||||
const dy = pack.pointsMapXyzM[sourceOffset + 1]! - origin[1];
|
const dy = pack.pointsMapXyzM[sourceOffset + 1]! - origin[1];
|
||||||
const dz = pack.pointsMapXyzM[sourceOffset + 2]! - origin[2];
|
const dz = pack.pointsMapXyzM[sourceOffset + 2]! - origin[2];
|
||||||
@@ -924,22 +970,19 @@ export function hydrateM4ThreatTimelineFrame(
|
|||||||
return { ...frame, pointCloudBodyXyzM: points, pointCloudSampleCount: count };
|
return { ...frame, pointCloudBodyXyzM: points, pointCloudSampleCount: count };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchM4ThreatPlaybackPointPack(
|
export async function fetchM4ThreatPlaybackManifest(
|
||||||
result: string,
|
result: string,
|
||||||
{
|
{
|
||||||
fetcher = fetch,
|
fetcher = fetch,
|
||||||
signal,
|
signal,
|
||||||
endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT,
|
endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT,
|
||||||
onProgress,
|
|
||||||
}: {
|
}: {
|
||||||
fetcher?: LaboratoryFetch;
|
fetcher?: LaboratoryFetch;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
endpointRoot?: string;
|
endpointRoot?: string;
|
||||||
onProgress?: (progress: M4ThreatPlaybackProgress) => void;
|
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<M4ThreatPlaybackPointPack> {
|
): Promise<M4ThreatPlaybackManifest> {
|
||||||
resultId(result);
|
resultId(result);
|
||||||
onProgress?.({ phase: "manifest", loadedBytes: 0, totalBytes: 0 });
|
|
||||||
const manifestResponse = await fetcher(`${endpointRoot}/${result}/timeline/playback`, {
|
const manifestResponse = await fetcher(`${endpointRoot}/${result}/timeline/playback`, {
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
signal,
|
signal,
|
||||||
@@ -982,46 +1025,180 @@ export async function fetchM4ThreatPlaybackPointPack(
|
|||||||
if (!/^[a-f0-9]{64}$/.test(sha256)) {
|
if (!/^[a-f0-9]{64}$/.test(sha256)) {
|
||||||
throw new M4ThreatContractError("M4.6 playback SHA-256: нарушен контракт.");
|
throw new M4ThreatContractError("M4.6 playback SHA-256: нарушен контракт.");
|
||||||
}
|
}
|
||||||
const response = await fetcher(text(track.url, "M4.6 playback URL"), {
|
const trackUrl = text(track.url, "M4.6 playback URL");
|
||||||
headers: { Accept: "application/octet-stream" },
|
const chunkFrameCount = exact(
|
||||||
signal,
|
integer(manifest.chunk_frame_count, "M4.6 playback chunk frames"),
|
||||||
|
24,
|
||||||
|
"M4.6 playback chunk frames",
|
||||||
|
);
|
||||||
|
const residentChunkCountMax = exact(
|
||||||
|
integer(manifest.resident_chunk_count_max, "M4.6 playback resident chunks"),
|
||||||
|
4,
|
||||||
|
"M4.6 playback resident chunks",
|
||||||
|
);
|
||||||
|
const forwardPrefetchChunkCount = exact(
|
||||||
|
integer(manifest.forward_prefetch_chunk_count, "M4.6 playback prefetch chunks"),
|
||||||
|
1,
|
||||||
|
"M4.6 playback prefetch chunks",
|
||||||
|
);
|
||||||
|
const chunkRows = array(manifest.chunks, "M4.6 playback chunks");
|
||||||
|
const expectedChunkCount = Math.ceil(frameCount / chunkFrameCount);
|
||||||
|
if (chunkRows.length !== expectedChunkCount) {
|
||||||
|
throw new M4ThreatContractError("M4.6 playback chunk catalog: неверный размер.");
|
||||||
|
}
|
||||||
|
const chunks = chunkRows.map((value, index): M4ThreatPlaybackChunkDescriptor => {
|
||||||
|
const row = object(value, `M4.6 playback chunk ${index}`);
|
||||||
|
const start = index * chunkFrameCount;
|
||||||
|
const count = Math.min(chunkFrameCount, frameCount - start);
|
||||||
|
const pointStart = pointOffsets[start]!;
|
||||||
|
const pointStop = pointOffsets[start + count]!;
|
||||||
|
const pointChunkCount = pointStop - pointStart;
|
||||||
|
exact(integer(row.index, `M4.6 playback chunk ${index}.index`), index, `M4.6 playback chunk ${index}.index`);
|
||||||
|
exact(integer(row.start, `M4.6 playback chunk ${index}.start`), start, `M4.6 playback chunk ${index}.start`);
|
||||||
|
exact(integer(row.count, `M4.6 playback chunk ${index}.count`), count, `M4.6 playback chunk ${index}.count`);
|
||||||
|
exact(integer(row.point_start, `M4.6 playback chunk ${index}.point start`), pointStart, `M4.6 playback chunk ${index}.point start`);
|
||||||
|
exact(integer(row.point_count, `M4.6 playback chunk ${index}.point count`), pointChunkCount, `M4.6 playback chunk ${index}.point count`);
|
||||||
|
exact(row.media_type, "application/octet-stream", `M4.6 playback chunk ${index}.media type`);
|
||||||
|
exact(row.dtype, "<f4", `M4.6 playback chunk ${index}.dtype`);
|
||||||
|
const shapeValue = array(row.shape, `M4.6 playback chunk ${index}.shape`)
|
||||||
|
.map((item) => integer(item, `M4.6 playback chunk ${index}.shape`));
|
||||||
|
const chunkBytes = integer(row.bytes, `M4.6 playback chunk ${index}.bytes`);
|
||||||
|
if (
|
||||||
|
shapeValue.length !== 2
|
||||||
|
|| shapeValue[0] !== pointChunkCount
|
||||||
|
|| shapeValue[1] !== 3
|
||||||
|
|| chunkBytes !== pointChunkCount * 3 * Float32Array.BYTES_PER_ELEMENT
|
||||||
|
) {
|
||||||
|
throw new M4ThreatContractError(`M4.6 playback chunk ${index}: нарушена размерность.`);
|
||||||
|
}
|
||||||
|
const chunkSha256 = text(row.sha256, `M4.6 playback chunk ${index}.SHA-256`);
|
||||||
|
if (!/^[a-f0-9]{64}$/.test(chunkSha256)) {
|
||||||
|
throw new M4ThreatContractError(`M4.6 playback chunk ${index}: SHA-256 недопустим.`);
|
||||||
|
}
|
||||||
|
const expectedUrl = `${endpointRoot}/${result}/timeline/playback/chunks/${index}`;
|
||||||
|
const url = text(row.url, `M4.6 playback chunk ${index}.URL`);
|
||||||
|
if (url !== expectedUrl || url.includes("worker")) {
|
||||||
|
throw new M4ThreatContractError(`M4.6 playback chunk ${index}: разрешён только sealed local API.`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
start,
|
||||||
|
count,
|
||||||
|
pointStart,
|
||||||
|
pointCount: pointChunkCount,
|
||||||
|
url,
|
||||||
|
mediaType: "application/octet-stream",
|
||||||
|
byteLength: chunkBytes,
|
||||||
|
sha256: chunkSha256,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new M4ThreatContractError(`M4.6 playback points: HTTP ${response.status}.`);
|
|
||||||
const bytes = new Uint8Array(byteLength);
|
|
||||||
if (response.body) {
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
let offset = 0;
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
if (offset + value.byteLength > bytes.byteLength) {
|
|
||||||
throw new M4ThreatContractError("M4.6 playback points превысил объявленный размер.");
|
|
||||||
}
|
|
||||||
bytes.set(value, offset);
|
|
||||||
offset += value.byteLength;
|
|
||||||
onProgress?.({ phase: "download", loadedBytes: offset, totalBytes: byteLength });
|
|
||||||
}
|
|
||||||
if (offset !== byteLength) {
|
|
||||||
throw new M4ThreatContractError("M4.6 playback points получен не полностью.");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const fallback = new Uint8Array(await response.arrayBuffer());
|
|
||||||
if (fallback.byteLength !== byteLength) {
|
|
||||||
throw new M4ThreatContractError("M4.6 playback points: неверный размер.");
|
|
||||||
}
|
|
||||||
bytes.set(fallback);
|
|
||||||
}
|
|
||||||
onProgress?.({ phase: "verify", loadedBytes: byteLength, totalBytes: byteLength });
|
|
||||||
if (await playbackSha256Hex(bytes.buffer) !== sha256) {
|
|
||||||
throw new M4ThreatContractError("M4.6 playback points: SHA-256 не совпал.");
|
|
||||||
}
|
|
||||||
onProgress?.({ phase: "ready", loadedBytes: byteLength, totalBytes: byteLength });
|
|
||||||
return {
|
return {
|
||||||
resultId: result,
|
resultId: result,
|
||||||
frameCount,
|
frameCount,
|
||||||
pointCount,
|
pointCount,
|
||||||
pointOffsets,
|
pointOffsets,
|
||||||
pointsMapXyzM: new Float32Array(bytes.buffer),
|
chunkFrameCount,
|
||||||
|
residentChunkCountMax,
|
||||||
|
forwardPrefetchChunkCount,
|
||||||
|
chunks,
|
||||||
|
track: { url: trackUrl, byteLength, sha256 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchM4ThreatPlaybackPointChunk(
|
||||||
|
manifest: M4ThreatPlaybackManifest,
|
||||||
|
chunkIndex: number,
|
||||||
|
{
|
||||||
|
fetcher = fetch,
|
||||||
|
signal,
|
||||||
|
onProgress,
|
||||||
|
}: {
|
||||||
|
fetcher?: LaboratoryFetch;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onProgress?: (progress: M4ThreatPlaybackProgress) => void;
|
||||||
|
} = {},
|
||||||
|
): Promise<M4ThreatPlaybackPointPack> {
|
||||||
|
const descriptor = manifest.chunks[chunkIndex];
|
||||||
|
if (!descriptor || descriptor.index !== chunkIndex) {
|
||||||
|
throw new M4ThreatContractError("M4.6 playback chunk отсутствует.");
|
||||||
|
}
|
||||||
|
onProgress?.({ phase: "download", loadedBytes: 0, totalBytes: descriptor.byteLength });
|
||||||
|
const response = await fetcher(descriptor.url, {
|
||||||
|
headers: { Accept: "application/octet-stream" },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new M4ThreatContractError(`M4.6 playback chunk: HTTP ${response.status}.`);
|
||||||
|
}
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
if (buffer.byteLength !== descriptor.byteLength) {
|
||||||
|
throw new M4ThreatContractError("M4.6 playback chunk: неверный размер.");
|
||||||
|
}
|
||||||
|
onProgress?.({
|
||||||
|
phase: "verify",
|
||||||
|
loadedBytes: descriptor.byteLength,
|
||||||
|
totalBytes: descriptor.byteLength,
|
||||||
|
});
|
||||||
|
if (await playbackSha256Hex(buffer) !== descriptor.sha256) {
|
||||||
|
throw new M4ThreatContractError("M4.6 playback chunk: SHA-256 не совпал.");
|
||||||
|
}
|
||||||
|
onProgress?.({
|
||||||
|
phase: "ready",
|
||||||
|
loadedBytes: descriptor.byteLength,
|
||||||
|
totalBytes: descriptor.byteLength,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
resultId: manifest.resultId,
|
||||||
|
frameCount: manifest.frameCount,
|
||||||
|
pointCount: descriptor.pointCount,
|
||||||
|
pointOffsets: manifest.pointOffsets,
|
||||||
|
pointsMapXyzM: new Float32Array(buffer),
|
||||||
|
pointStart: descriptor.pointStart,
|
||||||
|
startSequence: descriptor.start,
|
||||||
|
sequenceCount: descriptor.count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchM4ThreatPlaybackPointPack(
|
||||||
|
result: string,
|
||||||
|
{
|
||||||
|
fetcher = fetch,
|
||||||
|
signal,
|
||||||
|
endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT,
|
||||||
|
onProgress,
|
||||||
|
}: {
|
||||||
|
fetcher?: LaboratoryFetch;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
endpointRoot?: string;
|
||||||
|
onProgress?: (progress: M4ThreatPlaybackProgress) => void;
|
||||||
|
} = {},
|
||||||
|
): Promise<M4ThreatPlaybackPointPack> {
|
||||||
|
onProgress?.({ phase: "manifest", loadedBytes: 0, totalBytes: 0 });
|
||||||
|
const manifest = await fetchM4ThreatPlaybackManifest(result, {
|
||||||
|
fetcher,
|
||||||
|
signal,
|
||||||
|
endpointRoot,
|
||||||
|
});
|
||||||
|
const response = await fetcher(manifest.track.url, {
|
||||||
|
headers: { Accept: "application/octet-stream" },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new M4ThreatContractError(`M4.6 playback points: HTTP ${response.status}.`);
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
if (buffer.byteLength !== manifest.track.byteLength) {
|
||||||
|
throw new M4ThreatContractError("M4.6 playback points: неверный размер.");
|
||||||
|
}
|
||||||
|
onProgress?.({ phase: "verify", loadedBytes: buffer.byteLength, totalBytes: buffer.byteLength });
|
||||||
|
if (await playbackSha256Hex(buffer) !== manifest.track.sha256) {
|
||||||
|
throw new M4ThreatContractError("M4.6 playback points: SHA-256 не совпал.");
|
||||||
|
}
|
||||||
|
onProgress?.({ phase: "ready", loadedBytes: buffer.byteLength, totalBytes: buffer.byteLength });
|
||||||
|
return {
|
||||||
|
resultId: manifest.resultId,
|
||||||
|
frameCount: manifest.frameCount,
|
||||||
|
pointCount: manifest.pointCount,
|
||||||
|
pointOffsets: manifest.pointOffsets,
|
||||||
|
pointsMapXyzM: new Float32Array(buffer),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -236,9 +236,10 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
width: max-content;
|
width: min(17.5rem, calc(var(--m4-replay-threat-overlay-pane-width, 100%) - 1.2rem));
|
||||||
max-width: calc(var(--m4-replay-threat-overlay-pane-width, 100%) - 1.2rem);
|
max-width: none;
|
||||||
grid-template-columns: minmax(0, max-content);
|
grid-auto-rows: 3.8rem;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -247,6 +248,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.m4-replay-threat-visual__overlay > div {
|
.m4-replay-threat-visual__overlay > div {
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 3.8rem;
|
||||||
|
grid-template-rows: auto auto minmax(1.32rem, 1fr);
|
||||||
border-radius: var(--nodedc-radius-control-compact);
|
border-radius: var(--nodedc-radius-control-compact);
|
||||||
background: color-mix(in srgb, var(--nodedc-floating-surface) 80%, transparent);
|
background: color-mix(in srgb, var(--nodedc-floating-surface) 80%, transparent);
|
||||||
padding: 0.55rem 0.65rem;
|
padding: 0.55rem 0.65rem;
|
||||||
@@ -254,15 +258,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.m4-replay-threat-visual__overlay span,
|
.m4-replay-threat-visual__overlay span,
|
||||||
.m4-replay-threat-visual__overlay strong,
|
.m4-replay-threat-visual__overlay strong {
|
||||||
.m4-replay-threat-visual__overlay small {
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.m4-replay-threat-visual__overlay small {
|
||||||
|
display: -webkit-box;
|
||||||
|
min-height: 1.32rem;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.m4-replay-threat-visual__overlay {
|
.m4-replay-threat-visual__overlay {
|
||||||
|
width: min(17.5rem, calc(100% - 1.2rem));
|
||||||
max-width: calc(100% - 1.2rem);
|
max-width: calc(100% - 1.2rem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,3 +385,13 @@
|
|||||||
background: var(--nodedc-text-muted);
|
background: var(--nodedc-text-muted);
|
||||||
opacity: 0.42;
|
opacity: 0.42;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.laboratory-metric-evidence-scene__legend span[data-decision="performance"] {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-metric-evidence-scene__legend span[data-decision="performance"]::before {
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid rgb(var(--nodedc-accent-rgb));
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,13 +8,16 @@ import {
|
|||||||
fetchE47SemanticSlamResult,
|
fetchE47SemanticSlamResult,
|
||||||
type E47SemanticSlamResult,
|
type E47SemanticSlamResult,
|
||||||
} from "../../core/laboratory/e47SemanticSlam";
|
} from "../../core/laboratory/e47SemanticSlam";
|
||||||
|
import {
|
||||||
|
fetchM49PhysicalSafetyPlaybackIdForSource,
|
||||||
|
M49PhysicalSafetyPlaybackBuffer,
|
||||||
|
type M49PhysicalSafetyPlaybackProgress,
|
||||||
|
} from "../../core/laboratory/m49PhysicalSafetyPlayback";
|
||||||
import {
|
import {
|
||||||
fetchM49TgsFullShadowPlaybackPack,
|
fetchM49TgsFullShadowPlaybackPack,
|
||||||
type M49TgsFullShadowResult,
|
type M49TgsFullShadowResult,
|
||||||
type M49TgsFullShadowPlaybackPack,
|
type M49TgsFullShadowPlaybackPack,
|
||||||
type M49TgsFullShadowPlaybackProgress,
|
type M49TgsFullShadowPlaybackProgress,
|
||||||
type M49TgsFullShadowSpatial,
|
|
||||||
type M49TgsFullShadowStateCode,
|
|
||||||
} from "../../core/laboratory/m49TgsFullShadow";
|
} from "../../core/laboratory/m49TgsFullShadow";
|
||||||
import {
|
import {
|
||||||
M4ReplayThreatVisual,
|
M4ReplayThreatVisual,
|
||||||
@@ -33,13 +36,6 @@ const PALETTE: readonly RecordedEvidenceSemanticPaletteEntry[] = [
|
|||||||
{ classId: 3, color: { kind: "token", token: "--nodedc-warning-rgb" } },
|
{ classId: 3, color: { kind: "token", token: "--nodedc-warning-rgb" } },
|
||||||
];
|
];
|
||||||
|
|
||||||
function cellState(code: M49TgsFullShadowStateCode): M4ReplayClassifiedSpatialFrame["cellsMapGravityLocal"][number]["state"] {
|
|
||||||
if (code === 1) return "ground-support";
|
|
||||||
if (code === 2) return "nonground-occupied";
|
|
||||||
if (code === 3) return "unknown-rejected";
|
|
||||||
return "unobserved";
|
|
||||||
}
|
|
||||||
|
|
||||||
function message(error: unknown): string {
|
function message(error: unknown): string {
|
||||||
return error instanceof Error && error.message.trim()
|
return error instanceof Error && error.message.trim()
|
||||||
? error.message
|
? error.message
|
||||||
@@ -51,6 +47,9 @@ export function M49TgsFullShadowEvidence({ result }: { result: M49TgsFullShadowR
|
|||||||
const [semantic, setSemantic] = useState<E47SemanticSlamResult | null>(null);
|
const [semantic, setSemantic] = useState<E47SemanticSlamResult | null>(null);
|
||||||
const [semanticError, setSemanticError] = useState<string | null>(null);
|
const [semanticError, setSemanticError] = useState<string | null>(null);
|
||||||
const [playbackPack, setPlaybackPack] = useState<M49TgsFullShadowPlaybackPack | null>(null);
|
const [playbackPack, setPlaybackPack] = useState<M49TgsFullShadowPlaybackPack | null>(null);
|
||||||
|
const [physicalPlayback, setPhysicalPlayback] = useState<M49PhysicalSafetyPlaybackBuffer | null>(null);
|
||||||
|
const [physicalProgress, setPhysicalProgress] = useState<M49PhysicalSafetyPlaybackProgress | null>(null);
|
||||||
|
const [physicalRevision, setPhysicalRevision] = useState(0);
|
||||||
const [playbackProgress, setPlaybackProgress] = useState<M49TgsFullShadowPlaybackProgress>({
|
const [playbackProgress, setPlaybackProgress] = useState<M49TgsFullShadowPlaybackProgress>({
|
||||||
phase: "manifest",
|
phase: "manifest",
|
||||||
trackId: null,
|
trackId: null,
|
||||||
@@ -83,12 +82,27 @@ export function M49TgsFullShadowEvidence({ result }: { result: M49TgsFullShadowR
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setPlaybackPack(null);
|
setPlaybackPack(null);
|
||||||
|
setPhysicalPlayback(null);
|
||||||
|
setPhysicalProgress(null);
|
||||||
|
setPhysicalRevision(0);
|
||||||
setPlaybackProgress({ phase: "manifest", trackId: null, loadedBytes: 0, totalBytes: 0 });
|
setPlaybackProgress({ phase: "manifest", trackId: null, loadedBytes: 0, totalBytes: 0 });
|
||||||
setError(null);
|
setError(null);
|
||||||
void fetchM49TgsFullShadowPlaybackPack(result.resultId, {
|
void fetchM49PhysicalSafetyPlaybackIdForSource(result.resultId, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
onProgress: setPlaybackProgress,
|
}).then(async (physicalResultId) => {
|
||||||
}).then((pack) => {
|
if (controller.signal.aborted) return;
|
||||||
|
if (physicalResultId) {
|
||||||
|
const playback = await M49PhysicalSafetyPlaybackBuffer.open(physicalResultId, {
|
||||||
|
signal: controller.signal,
|
||||||
|
onProgress: setPhysicalProgress,
|
||||||
|
});
|
||||||
|
if (!controller.signal.aborted) setPhysicalPlayback(playback);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pack = await fetchM49TgsFullShadowPlaybackPack(result.resultId, {
|
||||||
|
signal: controller.signal,
|
||||||
|
onProgress: setPlaybackProgress,
|
||||||
|
});
|
||||||
if (!controller.signal.aborted) setPlaybackPack(pack);
|
if (!controller.signal.aborted) setPlaybackPack(pack);
|
||||||
}).catch((caught: unknown) => {
|
}).catch((caught: unknown) => {
|
||||||
if (!controller.signal.aborted) setError(message(caught));
|
if (!controller.signal.aborted) setError(message(caught));
|
||||||
@@ -96,69 +110,84 @@ export function M49TgsFullShadowEvidence({ result }: { result: M49TgsFullShadowR
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [result.resultId]);
|
}, [result.resultId]);
|
||||||
|
|
||||||
const spatial = useMemo<M49TgsFullShadowSpatial | null>(() => {
|
useEffect(() => {
|
||||||
if (activeSequence === null || !playbackPack) return null;
|
if (activeSequence === null || !physicalPlayback) return;
|
||||||
|
let active = true;
|
||||||
|
const resident = physicalPlayback.frameIfResident(activeSequence);
|
||||||
|
void physicalPlayback.prepare(activeSequence).then(() => {
|
||||||
|
if (active && !resident) setPhysicalRevision((value) => value + 1);
|
||||||
|
}).catch((caught: unknown) => {
|
||||||
|
if (active) setError(message(caught));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [activeSequence, physicalPlayback]);
|
||||||
|
|
||||||
|
const classifiedFrame = useMemo<M4ReplayClassifiedSpatialFrame | null>(() => {
|
||||||
|
if (activeSequence === null) return null;
|
||||||
|
if (physicalPlayback) {
|
||||||
|
const frame = physicalPlayback.frameIfResident(activeSequence);
|
||||||
|
if (!frame) return null;
|
||||||
|
return {
|
||||||
|
sourceSequence: activeSequence,
|
||||||
|
sampleAvailable: frame.metadata.sampleAvailable,
|
||||||
|
sourcePointCount: frame.metadata.metrics.eligible_point_count ?? 0,
|
||||||
|
pointsMapGravityLocalXyzM: [],
|
||||||
|
pointClassIds: [],
|
||||||
|
cellsMapGravityLocal: [],
|
||||||
|
packedCellsMapGravityLocal: {
|
||||||
|
centersXyM: frame.centersXyM,
|
||||||
|
stateCodes: frame.states,
|
||||||
|
zBoundsM: frame.zBoundsM,
|
||||||
|
},
|
||||||
|
cellSizeM: frame.cellSizeM,
|
||||||
|
classes: CLASSES,
|
||||||
|
palette: PALETTE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!playbackPack) return null;
|
||||||
const frame = playbackPack.frames[activeSequence];
|
const frame = playbackPack.frames[activeSequence];
|
||||||
if (!frame) return null;
|
if (!frame) return null;
|
||||||
const cellOffset = activeSequence * playbackPack.cellCount;
|
const cellOffset = activeSequence * playbackPack.cellCount;
|
||||||
const zOffset = cellOffset * 2;
|
const zOffset = cellOffset * 2;
|
||||||
const states = Array.from(
|
|
||||||
playbackPack.states.subarray(cellOffset, cellOffset + playbackPack.cellCount),
|
|
||||||
(value) => value as M49TgsFullShadowStateCode,
|
|
||||||
);
|
|
||||||
const zBoundsM = Array.from({ length: playbackPack.cellCount }, (_, index) => {
|
|
||||||
const minimum = playbackPack.zBoundsM[zOffset + index * 2]!;
|
|
||||||
const maximum = playbackPack.zBoundsM[zOffset + index * 2 + 1]!;
|
|
||||||
return [
|
|
||||||
Number.isFinite(minimum) ? minimum : null,
|
|
||||||
Number.isFinite(maximum) ? maximum : null,
|
|
||||||
] as const;
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
resultId: playbackPack.resultId,
|
|
||||||
sourceSequence: activeSequence,
|
sourceSequence: activeSequence,
|
||||||
sourceFrameIndex: frame.sourceFrameIndex,
|
|
||||||
sessionSeconds: frame.sessionSeconds,
|
|
||||||
sampleAvailable: frame.sampleAvailable,
|
sampleAvailable: frame.sampleAvailable,
|
||||||
costmap: {
|
sourcePointCount: frame.metrics.eligiblePointCount,
|
||||||
cellSizeM: result.configuration.cellSizeM,
|
pointsMapGravityLocalXyzM: [],
|
||||||
radiusM: result.configuration.radiusM,
|
pointClassIds: [],
|
||||||
|
cellsMapGravityLocal: [],
|
||||||
|
packedCellsMapGravityLocal: {
|
||||||
centersXyM: playbackPack.centersXyM,
|
centersXyM: playbackPack.centersXyM,
|
||||||
states,
|
stateCodes: playbackPack.states.subarray(
|
||||||
zBoundsM,
|
cellOffset,
|
||||||
|
cellOffset + playbackPack.cellCount,
|
||||||
|
),
|
||||||
|
zBoundsM: playbackPack.zBoundsM.subarray(
|
||||||
|
zOffset,
|
||||||
|
zOffset + playbackPack.cellCount * 2,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
metrics: frame.metrics,
|
cellSizeM: result.configuration.cellSizeM,
|
||||||
|
classes: CLASSES,
|
||||||
|
palette: PALETTE,
|
||||||
};
|
};
|
||||||
}, [activeSequence, playbackPack, result.configuration.cellSizeM, result.configuration.radiusM]);
|
}, [activeSequence, physicalPlayback, physicalRevision, playbackPack, result.configuration.cellSizeM]);
|
||||||
const loading = activeSequence !== null && !spatial && !error;
|
const loading = activeSequence !== null && !classifiedFrame && !error;
|
||||||
const progressPercent = playbackProgress.totalBytes > 0
|
const progressPercent = playbackProgress.totalBytes > 0
|
||||||
? Math.min(100, Math.round((playbackProgress.loadedBytes / playbackProgress.totalBytes) * 100))
|
? Math.min(100, Math.round((playbackProgress.loadedBytes / playbackProgress.totalBytes) * 100))
|
||||||
: 0;
|
: 0;
|
||||||
const loadingLabel = playbackProgress.phase === "manifest"
|
const loadingLabel = physicalProgress
|
||||||
|
? physicalProgress.phase === "ready"
|
||||||
|
? "Sealed TGS playback готов"
|
||||||
|
: `Буферизуем sealed TGS · ${physicalProgress.residentChunkIndexes.length}/3 chunks · ${(physicalProgress.loadedBytes / 1_048_576).toFixed(1)} МБ`
|
||||||
|
: playbackProgress.phase === "manifest"
|
||||||
? "Проверяем playback manifest"
|
? "Проверяем playback manifest"
|
||||||
: playbackProgress.phase === "ready"
|
: playbackProgress.phase === "ready"
|
||||||
? "TGS playback готов"
|
? "TGS playback готов"
|
||||||
: `Подготавливаем TGS playback · ${progressPercent}% · ${(playbackProgress.loadedBytes / 1_048_576).toFixed(1)}/${(playbackProgress.totalBytes / 1_048_576).toFixed(1)} МБ`;
|
: `Подготавливаем TGS playback · ${progressPercent}% · ${(playbackProgress.loadedBytes / 1_048_576).toFixed(1)}/${(playbackProgress.totalBytes / 1_048_576).toFixed(1)} МБ`;
|
||||||
|
|
||||||
const classifiedFrame = useMemo<M4ReplayClassifiedSpatialFrame | null>(() => {
|
|
||||||
if (!spatial) return null;
|
|
||||||
return {
|
|
||||||
sourceSequence: spatial.sourceSequence,
|
|
||||||
sampleAvailable: spatial.sampleAvailable,
|
|
||||||
sourcePointCount: spatial.metrics.eligiblePointCount,
|
|
||||||
pointsMapGravityLocalXyzM: [],
|
|
||||||
pointClassIds: [],
|
|
||||||
cellsMapGravityLocal: spatial.costmap.centersXyM.map((center, index) => ({
|
|
||||||
centerXyM: center,
|
|
||||||
zBoundsM: spatial.costmap.zBoundsM[index]!,
|
|
||||||
state: cellState(spatial.costmap.states[index]!),
|
|
||||||
})),
|
|
||||||
cellSizeM: spatial.costmap.cellSizeM,
|
|
||||||
classes: CLASSES,
|
|
||||||
palette: PALETTE,
|
|
||||||
};
|
|
||||||
}, [spatial]);
|
|
||||||
|
|
||||||
const handleSequenceChange = useCallback((sequence: number | null) => {
|
const handleSequenceChange = useCallback((sequence: number | null) => {
|
||||||
setActiveSequence(sequence);
|
setActiveSequence(sequence);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
LaboratoryMetricEvidenceScene,
|
LaboratoryMetricEvidenceScene,
|
||||||
type LaboratoryMetricCellEvidence,
|
type LaboratoryMetricCellEvidence,
|
||||||
type LaboratoryMetricEvidenceSceneHandle,
|
type LaboratoryMetricEvidenceSceneHandle,
|
||||||
|
type LaboratoryMetricPackedCellEvidence,
|
||||||
type LaboratoryMetricSceneMode,
|
type LaboratoryMetricSceneMode,
|
||||||
} from "../../components/laboratory/LaboratoryMetricEvidenceScene";
|
} from "../../components/laboratory/LaboratoryMetricEvidenceScene";
|
||||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||||
@@ -118,6 +119,11 @@ export interface M4ReplayClassifiedSpatialFrame {
|
|||||||
zBoundsM: readonly [number | null, number | null];
|
zBoundsM: readonly [number | null, number | null];
|
||||||
state: LaboratoryMetricCellEvidence["state"];
|
state: LaboratoryMetricCellEvidence["state"];
|
||||||
}[];
|
}[];
|
||||||
|
packedCellsMapGravityLocal?: {
|
||||||
|
centersXyM: Float32Array;
|
||||||
|
zBoundsM: Float32Array;
|
||||||
|
stateCodes: Uint8Array;
|
||||||
|
};
|
||||||
cellSizeM: number;
|
cellSizeM: number;
|
||||||
classes: readonly RecordedEvidenceSemanticClass[];
|
classes: readonly RecordedEvidenceSemanticClass[];
|
||||||
palette: readonly RecordedEvidenceSemanticPaletteEntry[];
|
palette: readonly RecordedEvidenceSemanticPaletteEntry[];
|
||||||
@@ -414,19 +420,41 @@ export function M4ReplayThreatVisual({
|
|||||||
resultId: string;
|
resultId: string;
|
||||||
frame: M4ReplayClassifiedSpatialFrame;
|
frame: M4ReplayClassifiedSpatialFrame;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
if (classifiedSpatialLayer?.frame) {
|
const incomingClassifiedSpatialFrame = classifiedSpatialLayer?.frame ?? null;
|
||||||
lastClassifiedSpatialFrameRef.current = { resultId, frame: classifiedSpatialLayer.frame };
|
if (incomingClassifiedSpatialFrame && incomingClassifiedSpatialFrame.sampleAvailable !== false) {
|
||||||
|
lastClassifiedSpatialFrameRef.current = { resultId, frame: incomingClassifiedSpatialFrame };
|
||||||
}
|
}
|
||||||
|
const lastAvailableClassifiedSpatialFrame = lastClassifiedSpatialFrameRef.current?.resultId === resultId
|
||||||
|
? lastClassifiedSpatialFrameRef.current.frame
|
||||||
|
: null;
|
||||||
|
// `undefined !== false` is true, so the optional-chain form here used to
|
||||||
|
// select `null` during every short chunk miss. That unmounted the WebGL
|
||||||
|
// scene, flashed the alert state and recreated OrbitControls at the default
|
||||||
|
// view on the next frame. Keep the last complete volume until the exact
|
||||||
|
// classified frame is resident again.
|
||||||
const displayedClassifiedSpatialFrame = classifiedSpatialFrame
|
const displayedClassifiedSpatialFrame = classifiedSpatialFrame
|
||||||
?? (lastClassifiedSpatialFrameRef.current?.resultId === resultId
|
&& classifiedSpatialFrame.sampleAvailable !== false
|
||||||
? lastClassifiedSpatialFrameRef.current.frame
|
? classifiedSpatialFrame
|
||||||
: null);
|
: lastAvailableClassifiedSpatialFrame ?? classifiedSpatialFrame;
|
||||||
|
const displayedClassifiedFrameHeld = Boolean(
|
||||||
|
classifiedSpatialFrame
|
||||||
|
&& displayedClassifiedSpatialFrame
|
||||||
|
&& classifiedSpatialFrame.sourceSequence !== displayedClassifiedSpatialFrame.sourceSequence,
|
||||||
|
);
|
||||||
|
const classifiedContextSpatialFrame = displayedClassifiedSpatialFrame
|
||||||
|
? timelineFrame.availableFrames.find(
|
||||||
|
(candidate) => candidate.spatialAvailable
|
||||||
|
&& candidate.sequence === displayedClassifiedSpatialFrame.sourceSequence,
|
||||||
|
) ?? (spatialFrame?.sequence === displayedClassifiedSpatialFrame.sourceSequence
|
||||||
|
? spatialFrame
|
||||||
|
: null)
|
||||||
|
: null;
|
||||||
const replaceClassifiedPointCloud = classifiedSpatialLayer?.replacePointCloud ?? true;
|
const replaceClassifiedPointCloud = classifiedSpatialLayer?.replacePointCloud ?? true;
|
||||||
const nominalSensorHeightM = metadata.timeline?.rig.nominalSensorHeightM ?? 0;
|
const nominalSensorHeightM = metadata.timeline?.rig.nominalSensorHeightM ?? 0;
|
||||||
const mapGravityLocalSensorToBodyGround = useCallback((
|
const mapGravityLocalSensorToBodyGround = useCallback((
|
||||||
point: readonly [number, number, number],
|
point: readonly [number, number, number],
|
||||||
): readonly [number, number, number] => {
|
): readonly [number, number, number] => {
|
||||||
const basis = activeSpatialFrame?.bodyFrame?.basisMapFromBody;
|
const basis = classifiedContextSpatialFrame?.bodyFrame?.basisMapFromBody;
|
||||||
const rotated: readonly [number, number, number] = basis ? [
|
const rotated: readonly [number, number, number] = basis ? [
|
||||||
basis[0][0] * point[0] + basis[1][0] * point[1] + basis[2][0] * point[2],
|
basis[0][0] * point[0] + basis[1][0] * point[1] + basis[2][0] * point[2],
|
||||||
basis[0][1] * point[0] + basis[1][1] * point[1] + basis[2][1] * point[2],
|
basis[0][1] * point[0] + basis[1][1] * point[1] + basis[2][1] * point[2],
|
||||||
@@ -435,7 +463,7 @@ export function M4ReplayThreatVisual({
|
|||||||
// TGS evidence is translation-only map-gravity-local with the current LiDAR
|
// TGS evidence is translation-only map-gravity-local with the current LiDAR
|
||||||
// as its origin. The metric scene uses the body ground projection as z=0.
|
// as its origin. The metric scene uses the body ground projection as z=0.
|
||||||
return [rotated[0], rotated[1], rotated[2] + nominalSensorHeightM];
|
return [rotated[0], rotated[1], rotated[2] + nominalSensorHeightM];
|
||||||
}, [activeSpatialFrame?.bodyFrame?.basisMapFromBody, nominalSensorHeightM]);
|
}, [classifiedContextSpatialFrame?.bodyFrame?.basisMapFromBody, nominalSensorHeightM]);
|
||||||
const classifiedPointsBody = useMemo(
|
const classifiedPointsBody = useMemo(
|
||||||
() => displayedClassifiedSpatialFrame?.pointsMapGravityLocalXyzM.map(
|
() => displayedClassifiedSpatialFrame?.pointsMapGravityLocalXyzM.map(
|
||||||
mapGravityLocalSensorToBodyGround,
|
mapGravityLocalSensorToBodyGround,
|
||||||
@@ -465,20 +493,58 @@ export function M4ReplayThreatVisual({
|
|||||||
}) ?? [],
|
}) ?? [],
|
||||||
[displayedClassifiedSpatialFrame, mapGravityLocalSensorToBodyGround, nominalSensorHeightM],
|
[displayedClassifiedSpatialFrame, mapGravityLocalSensorToBodyGround, nominalSensorHeightM],
|
||||||
);
|
);
|
||||||
const classifiedCellCounts = useMemo(() => ({
|
const classifiedPackedCellsBody = useMemo<LaboratoryMetricPackedCellEvidence | undefined>(() => {
|
||||||
ground: classifiedSpatialFrame?.cellsMapGravityLocal.filter(
|
const packed = displayedClassifiedSpatialFrame?.packedCellsMapGravityLocal;
|
||||||
(cell) => cell.state === "ground-support",
|
if (!packed) return undefined;
|
||||||
).length ?? 0,
|
const cellCount = packed.stateCodes.length;
|
||||||
occupied: classifiedSpatialFrame?.cellsMapGravityLocal.filter(
|
if (packed.centersXyM.length !== cellCount * 2 || packed.zBoundsM.length !== cellCount * 2) {
|
||||||
(cell) => cell.state === "nonground-occupied",
|
return undefined;
|
||||||
).length ?? 0,
|
}
|
||||||
rejected: classifiedSpatialFrame?.cellsMapGravityLocal.filter(
|
const centersBodyXyM = new Float32Array(cellCount * 2);
|
||||||
(cell) => cell.state === "unknown-rejected",
|
const zBoundsM = new Float32Array(cellCount * 2);
|
||||||
).length ?? 0,
|
for (let index = 0; index < cellCount; index += 1) {
|
||||||
unobserved: classifiedSpatialFrame?.cellsMapGravityLocal.filter(
|
const body = mapGravityLocalSensorToBodyGround([
|
||||||
(cell) => cell.state === "unobserved",
|
packed.centersXyM[index * 2]!,
|
||||||
).length ?? 0,
|
packed.centersXyM[index * 2 + 1]!,
|
||||||
}), [classifiedSpatialFrame]);
|
0,
|
||||||
|
]);
|
||||||
|
centersBodyXyM[index * 2] = body[0];
|
||||||
|
centersBodyXyM[index * 2 + 1] = body[1];
|
||||||
|
const minimum = packed.zBoundsM[index * 2]!;
|
||||||
|
const maximum = packed.zBoundsM[index * 2 + 1]!;
|
||||||
|
zBoundsM[index * 2] = Number.isFinite(minimum)
|
||||||
|
? minimum + nominalSensorHeightM
|
||||||
|
: Number.NaN;
|
||||||
|
zBoundsM[index * 2 + 1] = Number.isFinite(maximum)
|
||||||
|
? maximum + nominalSensorHeightM
|
||||||
|
: Number.NaN;
|
||||||
|
}
|
||||||
|
return { centersBodyXyM, zBoundsM, stateCodes: packed.stateCodes };
|
||||||
|
}, [displayedClassifiedSpatialFrame, mapGravityLocalSensorToBodyGround, nominalSensorHeightM]);
|
||||||
|
const classifiedCellCounts = useMemo(() => {
|
||||||
|
const counts = { ground: 0, occupied: 0, rejected: 0, unobserved: 0 };
|
||||||
|
const packed = classifiedSpatialFrame?.packedCellsMapGravityLocal;
|
||||||
|
if (packed) {
|
||||||
|
for (const code of packed.stateCodes) {
|
||||||
|
if (code === 1) counts.ground += 1;
|
||||||
|
else if (code === 2) counts.occupied += 1;
|
||||||
|
else if (code === 3) counts.rejected += 1;
|
||||||
|
else if (code === 0) counts.unobserved += 1;
|
||||||
|
else counts.rejected += 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
for (const cell of classifiedSpatialFrame?.cellsMapGravityLocal ?? []) {
|
||||||
|
if (cell.state === "ground-support") counts.ground += 1;
|
||||||
|
else if (cell.state === "nonground-occupied") counts.occupied += 1;
|
||||||
|
else if (cell.state === "unknown-rejected") counts.rejected += 1;
|
||||||
|
else counts.unobserved += 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [classifiedSpatialFrame]);
|
||||||
|
const classifiedCellCount = classifiedSpatialFrame?.packedCellsMapGravityLocal?.stateCodes.length
|
||||||
|
?? classifiedSpatialFrame?.cellsMapGravityLocal.length
|
||||||
|
?? 0;
|
||||||
const sceneObstacles = useMemo(() => spatialFrame?.metricObstacles.map((obstacle) => ({
|
const sceneObstacles = useMemo(() => spatialFrame?.metricObstacles.map((obstacle) => ({
|
||||||
id: obstacle.componentId,
|
id: obstacle.componentId,
|
||||||
decision: obstacle.assessment.decision,
|
decision: obstacle.assessment.decision,
|
||||||
@@ -840,14 +906,16 @@ export function M4ReplayThreatVisual({
|
|||||||
<strong>{classifiedSpatialLayer
|
<strong>{classifiedSpatialLayer
|
||||||
? classifiedSpatialFrame
|
? classifiedSpatialFrame
|
||||||
? replaceClassifiedPointCloud
|
? replaceClassifiedPointCloud
|
||||||
? `${classifiedSpatialFrame.pointsMapGravityLocalXyzM.length.toLocaleString("ru-RU")} TGS points · ${classifiedSpatialFrame.cellsMapGravityLocal.length.toLocaleString("ru-RU")} cells`
|
? `${classifiedSpatialFrame.pointsMapGravityLocalXyzM.length.toLocaleString("ru-RU")} TGS points · ${classifiedCellCount.toLocaleString("ru-RU")} cells`
|
||||||
: `${(activeSpatialFrame?.pointCloudSourceCount ?? classifiedSpatialFrame.sourcePointCount ?? 0).toLocaleString("ru-RU")} source points · ${classifiedSpatialFrame.cellsMapGravityLocal.length.toLocaleString("ru-RU")} TGS cells`
|
: `${(activeSpatialFrame?.pointCloudSourceCount ?? classifiedSpatialFrame.sourcePointCount ?? 0).toLocaleString("ru-RU")} source points · ${classifiedCellCount.toLocaleString("ru-RU")} TGS cells`
|
||||||
: "TGS spatial buffer"
|
: "TGS spatial buffer"
|
||||||
: `${currentIncrementObstacles.length} current · ${rollingMapObstacles.length} rolling${metadata.timeline.occupancyProvenanceDelivery ? ` · ${lowStepObstacles.length} low-step` : ""}`}</strong>
|
: `${currentIncrementObstacles.length} current · ${rollingMapObstacles.length} rolling${metadata.timeline.occupancyProvenanceDelivery ? ` · ${lowStepObstacles.length} low-step` : ""}`}</strong>
|
||||||
<small>{classifiedSpatialLayer
|
<small>{classifiedSpatialLayer
|
||||||
? classifiedSpatialFrame
|
? classifiedSpatialFrame
|
||||||
? classifiedSpatialFrame.sampleAvailable === false
|
? classifiedSpatialFrame.sampleAvailable === false
|
||||||
? "LiDAR отсутствует · все ячейки принудительно UNOBSERVED · causal rolling 1 s"
|
? displayedClassifiedFrameHeld && displayedClassifiedSpatialFrame
|
||||||
|
? `LiDAR отсутствует · current UNOBSERVED · объёмный контекст удержан с frame ${displayedClassifiedSpatialFrame.sourceSequence + 1}`
|
||||||
|
: "LiDAR отсутствует · все ячейки принудительно UNOBSERVED · causal rolling 1 s"
|
||||||
: activeSpatialFrame
|
: activeSpatialFrame
|
||||||
? "map-gravity-local · all eligible points accounted · causal rolling 1 s"
|
? "map-gravity-local · all eligible points accounted · causal rolling 1 s"
|
||||||
: "TGS рассчитан · linked source cloud недоступен для этого кадра"
|
: "TGS рассчитан · linked source cloud недоступен для этого кадра"
|
||||||
@@ -987,37 +1055,36 @@ export function M4ReplayThreatVisual({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{(!classifiedSpatialLayer ? spatialFrame : displayedClassifiedSpatialFrame) ? (
|
<LaboratoryMetricEvidenceScene
|
||||||
<LaboratoryMetricEvidenceScene
|
ref={metricSceneRef}
|
||||||
ref={metricSceneRef}
|
pointCloudBodyXyzM={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||||
pointCloudBodyXyzM={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
? classifiedPointsBody
|
||||||
? classifiedPointsBody
|
: classifiedContextSpatialFrame?.pointCloudBodyXyzM ?? []}
|
||||||
: activeSpatialFrame?.pointCloudBodyXyzM ?? []}
|
localSurfaceBodyXyzM={localSurface.pointsBodyXyzM}
|
||||||
localSurfaceBodyXyzM={localSurface.pointsBodyXyzM}
|
obstacles={classifiedSpatialLayer ? [] : sceneObstacles}
|
||||||
obstacles={displayedClassifiedSpatialFrame ? [] : sceneObstacles}
|
rig={timeline.rig}
|
||||||
rig={timeline.rig}
|
corridor={timeline.corridor}
|
||||||
corridor={timeline.corridor}
|
occupiedVoxelSizeM={displayedClassifiedSpatialFrame?.cellSizeM ?? timeline.occupiedVoxelSizeM}
|
||||||
occupiedVoxelSizeM={displayedClassifiedSpatialFrame?.cellSizeM ?? timeline.occupiedVoxelSizeM}
|
mode={spatialMode}
|
||||||
mode={spatialMode}
|
label={`${evidenceLabel} exact current increment, bounded local SLAM surface and rolling occupancy`}
|
||||||
label={`${evidenceLabel} exact current increment, bounded local SLAM surface and rolling occupancy`}
|
showCurrentIncrement={showCurrentIncrement}
|
||||||
showCurrentIncrement={showCurrentIncrement}
|
showLocalSurface={showLocalSurface}
|
||||||
showLocalSurface={showLocalSurface}
|
showRollingMap={showRollingMap}
|
||||||
showRollingMap={showRollingMap}
|
showLowStep={classifiedSpatialLayer ? false : showLowStep}
|
||||||
showLowStep={displayedClassifiedSpatialFrame ? false : showLowStep}
|
pointSemanticClassIds={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||||
pointSemanticClassIds={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
? displayedClassifiedSpatialFrame.pointClassIds
|
||||||
? displayedClassifiedSpatialFrame.pointClassIds
|
: alignedSemanticPointIds}
|
||||||
: alignedSemanticPointIds}
|
semanticClasses={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||||
semanticClasses={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
? displayedClassifiedSpatialFrame.classes
|
||||||
? displayedClassifiedSpatialFrame.classes
|
: semanticClasses}
|
||||||
: semanticClasses}
|
semanticPalette={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||||
semanticPalette={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
? displayedClassifiedSpatialFrame.palette
|
||||||
? displayedClassifiedSpatialFrame.palette
|
: semanticPalette}
|
||||||
: semanticPalette}
|
classifiedCells={classifiedCellsBody}
|
||||||
classifiedCells={classifiedCellsBody}
|
classifiedPackedCells={classifiedPackedCellsBody}
|
||||||
classifiedCellSizeM={displayedClassifiedSpatialFrame?.cellSizeM}
|
classifiedCellSizeM={displayedClassifiedSpatialFrame?.cellSizeM}
|
||||||
showClassifiedCells={showRollingMap}
|
showClassifiedCells={showRollingMap}
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
{classifiedSpatialLayer && !displayedClassifiedSpatialFrame ? (
|
{classifiedSpatialLayer && !displayedClassifiedSpatialFrame ? (
|
||||||
<div className="l3-visual-audit__state" role={classifiedSpatialLayer.error ? "alert" : "status"}>
|
<div className="l3-visual-audit__state" role={classifiedSpatialLayer.error ? "alert" : "status"}>
|
||||||
{classifiedSpatialLayer.loading || displayingBufferedFrame
|
{classifiedSpatialLayer.loading || displayingBufferedFrame
|
||||||
@@ -1033,7 +1100,10 @@ export function M4ReplayThreatVisual({
|
|||||||
) : null}
|
) : null}
|
||||||
{classifiedSpatialFrame?.sampleAvailable === false ? (
|
{classifiedSpatialFrame?.sampleAvailable === false ? (
|
||||||
<div className="m4-replay-threat-visual__pane-status" role="status">
|
<div className="m4-replay-threat-visual__pane-status" role="status">
|
||||||
Кадр {classifiedSpatialFrame.sourceSequence + 1}: LiDAR отсутствует; все 2 244 TGS-ячейки явно UNOBSERVED.
|
Кадр {classifiedSpatialFrame.sourceSequence + 1}: LiDAR отсутствует; current safety — все {classifiedCellCount.toLocaleString("ru-RU")} TGS-ячейки UNOBSERVED
|
||||||
|
{displayedClassifiedFrameHeld && displayedClassifiedSpatialFrame
|
||||||
|
? `; для ориентации удержан последний объёмный контекст кадра ${displayedClassifiedSpatialFrame.sourceSequence + 1}.`
|
||||||
|
: "."}
|
||||||
</div>
|
</div>
|
||||||
) : classifiedSpatialFrame && !activeSpatialFrame ? (
|
) : classifiedSpatialFrame && !activeSpatialFrame ? (
|
||||||
<div className="m4-replay-threat-visual__pane-status" role="status">
|
<div className="m4-replay-threat-visual__pane-status" role="status">
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
fetchM4ThreatCameraPointOverlay,
|
fetchM4ThreatCameraPointOverlay,
|
||||||
fetchM4ThreatPlaybackPointPack,
|
fetchM4ThreatPlaybackManifest,
|
||||||
|
fetchM4ThreatPlaybackPointChunk,
|
||||||
fetchM4ThreatTimeline,
|
fetchM4ThreatTimeline,
|
||||||
fetchM4ThreatTimelineChunk,
|
fetchM4ThreatTimelineChunk,
|
||||||
M4_THREAT_TIMELINE_ENDPOINT_ROOT,
|
M4_THREAT_TIMELINE_ENDPOINT_ROOT,
|
||||||
selectM4ThreatTimelineSequence,
|
selectM4ThreatTimelineSequence,
|
||||||
type M4ThreatCameraPointOverlay,
|
type M4ThreatCameraPointOverlay,
|
||||||
type M4ThreatPlaybackPointPack,
|
type M4ThreatPlaybackManifest,
|
||||||
type M4ThreatPlaybackProgress,
|
type M4ThreatPlaybackProgress,
|
||||||
type M4ThreatTimeline,
|
type M4ThreatTimeline,
|
||||||
type M4ThreatTimelineChunk,
|
type M4ThreatTimelineChunk,
|
||||||
@@ -86,7 +87,7 @@ export function useM4ThreatTimelineFrame({
|
|||||||
() => new Map(),
|
() => new Map(),
|
||||||
);
|
);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [pointPack, setPointPack] = useState<M4ThreatPlaybackPointPack | null>(null);
|
const [playbackManifest, setPlaybackManifest] = useState<M4ThreatPlaybackManifest | null>(null);
|
||||||
const [playbackProgress, setPlaybackProgress] = useState<M4ThreatPlaybackProgress>({
|
const [playbackProgress, setPlaybackProgress] = useState<M4ThreatPlaybackProgress>({
|
||||||
phase: "manifest",
|
phase: "manifest",
|
||||||
loadedBytes: 0,
|
loadedBytes: 0,
|
||||||
@@ -102,7 +103,7 @@ export function useM4ThreatTimelineFrame({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setPointPack(null);
|
setPlaybackManifest(null);
|
||||||
setPlaybackError(null);
|
setPlaybackError(null);
|
||||||
setPlaybackProgress({ phase: "manifest", loadedBytes: 0, totalBytes: 0 });
|
setPlaybackProgress({ phase: "manifest", loadedBytes: 0, totalBytes: 0 });
|
||||||
if (!timeline) return () => controller.abort();
|
if (!timeline) return () => controller.abort();
|
||||||
@@ -110,15 +111,15 @@ export function useM4ThreatTimelineFrame({
|
|||||||
setPlaybackProgress({ phase: "ready", loadedBytes: 0, totalBytes: 0 });
|
setPlaybackProgress({ phase: "ready", loadedBytes: 0, totalBytes: 0 });
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}
|
}
|
||||||
void fetchM4ThreatPlaybackPointPack(resultId, {
|
void fetchM4ThreatPlaybackManifest(resultId, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
endpointRoot,
|
endpointRoot,
|
||||||
onProgress: (progress) => {
|
|
||||||
if (!controller.signal.aborted) setPlaybackProgress(progress);
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.then((pack) => {
|
.then((manifest) => {
|
||||||
if (!controller.signal.aborted) setPointPack(pack);
|
if (!controller.signal.aborted) {
|
||||||
|
setPlaybackManifest(manifest);
|
||||||
|
setPlaybackProgress({ phase: "ready", loadedBytes: 0, totalBytes: 0 });
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((caught: unknown) => {
|
.catch((caught: unknown) => {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
@@ -157,7 +158,7 @@ export function useM4ThreatTimelineFrame({
|
|||||||
activeChunkStartRef.current = activeChunkStart;
|
activeChunkStartRef.current = activeChunkStart;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!timeline || activeChunkStart === null || (binaryPlayback && !pointPack)) return;
|
if (!timeline || activeChunkStart === null || (binaryPlayback && !playbackManifest)) return;
|
||||||
const starts = m4ThreatChunkWindowStarts(
|
const starts = m4ThreatChunkWindowStarts(
|
||||||
activeChunkStart,
|
activeChunkStart,
|
||||||
chunkSize,
|
chunkSize,
|
||||||
@@ -168,12 +169,28 @@ export function useM4ThreatTimelineFrame({
|
|||||||
if (chunksRef.current.has(start) || inFlight.current.has(start)) continue;
|
if (chunksRef.current.has(start) || inFlight.current.has(start)) continue;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
inFlight.current.set(start, controller);
|
inFlight.current.set(start, controller);
|
||||||
void fetchM4ThreatTimelineChunk(resultId, start, chunkSize, {
|
void (async () => {
|
||||||
signal: controller.signal,
|
const playbackPointPack = binaryPlayback && playbackManifest
|
||||||
endpointRoot,
|
? await fetchM4ThreatPlaybackPointChunk(
|
||||||
cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery,
|
playbackManifest,
|
||||||
playbackPointPack: binaryPlayback ? pointPack ?? undefined : undefined,
|
Math.floor(start / playbackManifest.chunkFrameCount),
|
||||||
})
|
{
|
||||||
|
signal: controller.signal,
|
||||||
|
onProgress: (progress) => {
|
||||||
|
if (!controller.signal.aborted && start === activeChunkStartRef.current) {
|
||||||
|
setPlaybackProgress(progress);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
return fetchM4ThreatTimelineChunk(resultId, start, chunkSize, {
|
||||||
|
signal: controller.signal,
|
||||||
|
endpointRoot,
|
||||||
|
cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery,
|
||||||
|
playbackPointPack,
|
||||||
|
});
|
||||||
|
})()
|
||||||
.then((chunk) => {
|
.then((chunk) => {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
setChunks((current) => {
|
setChunks((current) => {
|
||||||
@@ -203,7 +220,7 @@ export function useM4ThreatTimelineFrame({
|
|||||||
// loaded first, then the next chunk is prefetched on the following render.
|
// loaded first, then the next chunk is prefetched on the following render.
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [activeChunkStart, binaryPlayback, chunkSize, chunks, endpointRoot, pointPack, resultId, timeline]);
|
}, [activeChunkStart, binaryPlayback, chunkSize, chunks, endpointRoot, playbackManifest, resultId, timeline]);
|
||||||
|
|
||||||
const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => {
|
const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => {
|
||||||
if (activeSequence === null || activeChunkStart === null) return null;
|
if (activeSequence === null || activeChunkStart === null) return null;
|
||||||
@@ -230,8 +247,8 @@ export function useM4ThreatTimelineFrame({
|
|||||||
? "Проверяем M4 playback manifest"
|
? "Проверяем M4 playback manifest"
|
||||||
: playbackProgress.phase === "ready"
|
: playbackProgress.phase === "ready"
|
||||||
? "Подготавливаем текущий spatial-кадр"
|
? "Подготавливаем текущий spatial-кадр"
|
||||||
: `${playbackProgress.phase === "verify" ? "Проверяем" : "Загружаем"} M4 spatial playback · ${playbackProgress.totalBytes > 0
|
: `${playbackProgress.phase === "verify" ? "Проверяем" : "Буферизуем"} текущий spatial chunk · ${playbackProgress.totalBytes > 0
|
||||||
? `${Math.min(100, Math.round(playbackProgress.loadedBytes / playbackProgress.totalBytes * 100))}% · ${(playbackProgress.loadedBytes / 1_048_576).toFixed(1)}/${(playbackProgress.totalBytes / 1_048_576).toFixed(1)} МБ`
|
? `${Math.min(100, Math.round(playbackProgress.loadedBytes / playbackProgress.totalBytes * 100))}% · ${(playbackProgress.loadedBytes / 1_048_576).toFixed(1)} МБ`
|
||||||
: "0%"}`,
|
: "0%"}`,
|
||||||
error: playbackError ?? error,
|
error: playbackError ?? error,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -691,3 +691,18 @@ test("metric evidence keeps one WebGL renderer while frame labels advance", () =
|
|||||||
/querySelector\("canvas"\)[\s\S]*?setAttribute\("aria-label", label\)[\s\S]*?\}, \[label\]\);/,
|
/querySelector\("canvas"\)[\s\S]*?setAttribute\("aria-label", label\)[\s\S]*?\}, \[label\]\);/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("metric evidence renders on demand while preserving damped camera interaction", () => {
|
||||||
|
const source = readFileSync(
|
||||||
|
new URL("../src/components/laboratory/LaboratoryMetricEvidenceScene.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(source, /const requestRenderRef = useRef/);
|
||||||
|
assert.match(source, /controls\.addEventListener\("change", requestRender\)/);
|
||||||
|
assert.match(source, /requestRenderRef\.current\(\)/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
source,
|
||||||
|
/const render = \(\) => \{\s*animationFrame = window\.requestAnimationFrame\(render\)/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let M49PhysicalSafetyPlaybackBuffer;
|
||||||
|
let fetchM49PhysicalSafetyPlaybackIdForSource;
|
||||||
|
|
||||||
|
const resultId = `m49-physical-safety-playback-${"a".repeat(64)}`;
|
||||||
|
const endpoint = `/api/v1/laboratory/m49/physical-safety-playback/${resultId}`;
|
||||||
|
const frameCount = 160;
|
||||||
|
const cellCount = 4;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await createServer({
|
||||||
|
appType: "custom",
|
||||||
|
logLevel: "silent",
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
});
|
||||||
|
({
|
||||||
|
M49PhysicalSafetyPlaybackBuffer,
|
||||||
|
fetchM49PhysicalSafetyPlaybackIdForSource,
|
||||||
|
} = await server.ssrLoadModule(
|
||||||
|
"/src/core/laboratory/m49PhysicalSafetyPlayback.ts",
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await server?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function sha256(buffer) {
|
||||||
|
return createHash("sha256").update(Buffer.from(buffer)).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk(index) {
|
||||||
|
const start = index * 32;
|
||||||
|
const count = Math.min(32, frameCount - start);
|
||||||
|
const stateBytes = count * cellCount;
|
||||||
|
const zBytes = count * cellCount * 2 * 4;
|
||||||
|
const headerBytes = 28;
|
||||||
|
const padding = (4 - ((headerBytes + stateBytes) % 4)) % 4;
|
||||||
|
const buffer = new ArrayBuffer(headerBytes + stateBytes + padding + zBytes);
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
bytes.set(new TextEncoder().encode("MCPSCH01"));
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
view.setUint32(8, start, true);
|
||||||
|
view.setUint32(12, count, true);
|
||||||
|
view.setUint32(16, cellCount, true);
|
||||||
|
view.setUint32(20, stateBytes, true);
|
||||||
|
view.setUint32(24, zBytes, true);
|
||||||
|
const states = new Uint8Array(buffer, headerBytes, stateBytes);
|
||||||
|
states.fill(1);
|
||||||
|
const z = new Float32Array(buffer, headerBytes + stateBytes + padding, count * cellCount * 2);
|
||||||
|
for (let local = 0; local < count; local += 1) {
|
||||||
|
const sequence = start + local;
|
||||||
|
for (let cell = 0; cell < cellCount; cell += 1) {
|
||||||
|
z[(local * cellCount + cell) * 2] = 0;
|
||||||
|
z[(local * cellCount + cell) * 2 + 1] = 0.12;
|
||||||
|
}
|
||||||
|
if (sequence === 17) {
|
||||||
|
states.fill(0, local * cellCount, (local + 1) * cellCount);
|
||||||
|
z.fill(Number.NaN, local * cellCount * 2, (local + 1) * cellCount * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const centers = new Float32Array([0, 0, 0.15, 0, 0.3, 0, 0.45, 0]).buffer;
|
||||||
|
const frames = new TextEncoder().encode(Array.from({ length: frameCount }, (_, sequence) => (
|
||||||
|
JSON.stringify({
|
||||||
|
source_frame_index: sequence,
|
||||||
|
session_seconds: sequence / 10,
|
||||||
|
sample_available: sequence !== 17,
|
||||||
|
eligible_point_count: sequence === 17 ? 0 : cellCount,
|
||||||
|
})
|
||||||
|
)).join("\n") + "\n").buffer;
|
||||||
|
const chunks = Array.from({ length: Math.ceil(frameCount / 32) }, (_, index) => chunk(index));
|
||||||
|
const manifest = {
|
||||||
|
schema_version: "missioncore.m49-physical-safety-playback/v1",
|
||||||
|
result_id: resultId,
|
||||||
|
created_at_utc: "2026-08-27T12:00:00Z",
|
||||||
|
access: "read-only-sealed-local",
|
||||||
|
identity: { source_result_id: `m49-tgs-full-shadow-${"b".repeat(64)}` },
|
||||||
|
execution: {
|
||||||
|
execution_class: "local-sequential-offline",
|
||||||
|
worker_role: "realtime-only",
|
||||||
|
worker_runtime_dependency: false,
|
||||||
|
worker_requests_required: 0,
|
||||||
|
},
|
||||||
|
authority: {
|
||||||
|
commands_enabled: false,
|
||||||
|
navigation_or_safety_accepted: false,
|
||||||
|
actuation_accepted: false,
|
||||||
|
},
|
||||||
|
playback: {
|
||||||
|
coordinate_frame: "map-gravity-local",
|
||||||
|
source_pace_hz: 10,
|
||||||
|
frame_count: frameCount,
|
||||||
|
cell_count: cellCount,
|
||||||
|
cell_size_m: 0.15,
|
||||||
|
radius_m: 12,
|
||||||
|
state_codes: {
|
||||||
|
UNOBSERVED: 0,
|
||||||
|
GROUND_SUPPORT: 1,
|
||||||
|
NONGROUND_OCCUPIED: 2,
|
||||||
|
UNKNOWN_REJECTED: 3,
|
||||||
|
},
|
||||||
|
chunk_frame_count: 32,
|
||||||
|
startup_prebuffer_chunk_count: 2,
|
||||||
|
resident_chunk_count_max: 3,
|
||||||
|
forward_prefetch_chunk_count: 1,
|
||||||
|
centers: {
|
||||||
|
url: `${endpoint}/tracks/centers`,
|
||||||
|
media_type: "application/octet-stream",
|
||||||
|
byte_length: centers.byteLength,
|
||||||
|
sha256: sha256(centers),
|
||||||
|
dtype: "<f4",
|
||||||
|
shape: [cellCount, 2],
|
||||||
|
},
|
||||||
|
frames: {
|
||||||
|
url: `${endpoint}/tracks/frames`,
|
||||||
|
media_type: "application/x-ndjson",
|
||||||
|
byte_length: frames.byteLength,
|
||||||
|
sha256: sha256(frames),
|
||||||
|
dtype: "ndjson",
|
||||||
|
shape: [frameCount],
|
||||||
|
},
|
||||||
|
chunks: chunks.map((buffer, index) => ({
|
||||||
|
index,
|
||||||
|
start: index * 32,
|
||||||
|
count: Math.min(32, frameCount - index * 32),
|
||||||
|
url: `${endpoint}/chunks/${index}`,
|
||||||
|
media_type: "application/octet-stream",
|
||||||
|
byte_length: buffer.byteLength,
|
||||||
|
sha256: sha256(buffer),
|
||||||
|
header_bytes: 28,
|
||||||
|
format: "mcpsch01-states-u8-aligned-z-bounds-f32le",
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const responses = new Map([
|
||||||
|
[`${endpoint}/tracks/centers`, centers],
|
||||||
|
[`${endpoint}/tracks/frames`, frames],
|
||||||
|
...chunks.map((buffer, index) => [`${endpoint}/chunks/${index}`, buffer]),
|
||||||
|
]);
|
||||||
|
return { manifest, responses };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("physical-safety playback opens and seeks with bounded local chunks only", async () => {
|
||||||
|
const { manifest, responses } = fixture();
|
||||||
|
const requested = [];
|
||||||
|
const progress = [];
|
||||||
|
const fetcher = async (input) => {
|
||||||
|
const url = String(input);
|
||||||
|
requested.push(url);
|
||||||
|
if (url === `${endpoint.replace(`/${resultId}`, "")}/results?limit=2&source_result_id=${manifest.identity.source_result_id}`) {
|
||||||
|
return Response.json({
|
||||||
|
schema_version: "missioncore.m49-physical-safety-playback-catalog/v1",
|
||||||
|
worker_runtime_dependency: false,
|
||||||
|
access: "read-only-sealed-local",
|
||||||
|
items: [{
|
||||||
|
result_id: resultId,
|
||||||
|
source_result_id: manifest.identity.source_result_id,
|
||||||
|
worker_runtime_dependency: false,
|
||||||
|
navigation_or_safety_accepted: false,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url === `${endpoint}/manifest`) {
|
||||||
|
return Response.json(manifest);
|
||||||
|
}
|
||||||
|
const payload = responses.get(url);
|
||||||
|
return payload
|
||||||
|
? new Response(payload, { headers: { "Content-Type": "application/octet-stream" } })
|
||||||
|
: new Response(null, { status: 404 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvedResultId = await fetchM49PhysicalSafetyPlaybackIdForSource(
|
||||||
|
manifest.identity.source_result_id,
|
||||||
|
{ fetcher },
|
||||||
|
);
|
||||||
|
assert.equal(resolvedResultId, resultId);
|
||||||
|
const playback = await M49PhysicalSafetyPlaybackBuffer.open(resolvedResultId, {
|
||||||
|
fetcher,
|
||||||
|
onProgress: (value) => progress.push(value),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(playback.residentChunkIndexes, [0, 1]);
|
||||||
|
assert.equal(progress.at(-1).phase, "ready");
|
||||||
|
assert.equal(requested.some((url) => url.includes("worker")), false);
|
||||||
|
const startupRequestCount = requested.length;
|
||||||
|
await playback.prepare(0);
|
||||||
|
await playback.prepare(0);
|
||||||
|
assert.equal(requested.length, startupRequestCount);
|
||||||
|
const missing = await playback.frame(17);
|
||||||
|
assert.equal(missing.metadata.sampleAvailable, false);
|
||||||
|
assert.deepEqual(new Set(missing.states), new Set([0]));
|
||||||
|
assert.equal([...missing.zBoundsM].every((value) => Number.isNaN(value)), true);
|
||||||
|
|
||||||
|
await playback.prepare(100);
|
||||||
|
assert.equal(playback.residentChunkIndexes.length <= 3, true);
|
||||||
|
assert.equal(playback.residentChunkIndexes.includes(3), true);
|
||||||
|
assert.equal(playback.residentChunkIndexes.includes(4), true);
|
||||||
|
const selected = await playback.frame(100);
|
||||||
|
assert.deepEqual([...selected.states], [1, 1, 1, 1]);
|
||||||
|
const maximumResidentBytes = manifest.playback.centers.byte_length
|
||||||
|
+ manifest.playback.frames.byte_length
|
||||||
|
+ 3 * Math.max(...manifest.playback.chunks.map((value) => value.byte_length));
|
||||||
|
assert.equal(playback.residentByteLength <= maximumResidentBytes, true);
|
||||||
|
|
||||||
|
await playback.prepare(0);
|
||||||
|
assert.equal(playback.residentChunkIndexes.length <= 3, true);
|
||||||
|
assert.equal(playback.residentChunkIndexes.includes(0), true);
|
||||||
|
assert.equal(playback.residentChunkIndexes.includes(1), true);
|
||||||
|
assert.equal(
|
||||||
|
requested.every((url) => url.startsWith("/api/v1/laboratory/m49/physical-safety-playback")),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("physical-safety playback rejects a manifest that points at Worker", async () => {
|
||||||
|
const { manifest } = fixture();
|
||||||
|
manifest.playback.centers.url = "http://worker-006/centers";
|
||||||
|
await assert.rejects(
|
||||||
|
M49PhysicalSafetyPlaybackBuffer.open(resultId, {
|
||||||
|
fetcher: async () => Response.json(manifest),
|
||||||
|
}),
|
||||||
|
/sealed local API/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -74,24 +74,53 @@ test("M4.9T5 chunk keeps every missing-LiDAR cell explicitly UNOBSERVED", async
|
|||||||
assert.deepEqual(new Set(chunk.frames[0].costmap.states), new Set([0]));
|
assert.deepEqual(new Set(chunk.frames[0].costmap.states), new Set([0]));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("M4.9T5 viewer preloads one immutable binary playback pack", async () => {
|
test("M4.9T5 viewer prefers autonomous chunks and keeps a sealed legacy fallback", async () => {
|
||||||
const [source, contract] = await Promise.all([
|
const [source, visual, scene, contract] = await Promise.all([
|
||||||
readFile(
|
readFile(
|
||||||
new URL("../src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx", import.meta.url),
|
new URL("../src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
),
|
),
|
||||||
|
readFile(
|
||||||
|
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
readFile(
|
||||||
|
new URL("../src/components/laboratory/LaboratoryMetricEvidenceScene.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
readFile(
|
readFile(
|
||||||
new URL("../src/core/laboratory/m49TgsFullShadow.ts", import.meta.url),
|
new URL("../src/core/laboratory/m49TgsFullShadow.ts", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
assert.match(source, /fetchM49PhysicalSafetyPlaybackIdForSource/);
|
||||||
|
assert.match(source, /M49PhysicalSafetyPlaybackBuffer\.open/);
|
||||||
|
assert.match(source, /physicalPlayback\.frameIfResident/);
|
||||||
|
assert.match(source, /physicalPlayback\.prepare/);
|
||||||
assert.match(source, /fetchM49TgsFullShadowPlaybackPack/);
|
assert.match(source, /fetchM49TgsFullShadowPlaybackPack/);
|
||||||
assert.match(source, /playbackProgress/);
|
assert.match(source, /playbackProgress/);
|
||||||
assert.doesNotMatch(source, /fetchM49TgsFullShadowSpatialChunk/);
|
assert.doesNotMatch(source, /fetchM49TgsFullShadowSpatialChunk/);
|
||||||
assert.match(source, /sampleAvailable: spatial\.sampleAvailable/);
|
assert.match(source, /sampleAvailable: frame\.sampleAvailable/);
|
||||||
|
assert.match(source, /packedCellsMapGravityLocal/);
|
||||||
|
assert.match(source, /playbackPack\.states\.subarray/);
|
||||||
|
assert.match(source, /playbackPack\.zBoundsM\.subarray/);
|
||||||
|
assert.doesNotMatch(source, /Array\.from\(\s*playbackPack\.states/);
|
||||||
|
assert.doesNotMatch(source, /centersXyM\.map\(/);
|
||||||
assert.match(source, /fetchE47SemanticSlamResult/);
|
assert.match(source, /fetchE47SemanticSlamResult/);
|
||||||
assert.match(source, /next\.baseM4ResultId !== result\.source\.linkedVisualResultId/);
|
assert.match(source, /next\.baseM4ResultId !== result\.source\.linkedVisualResultId/);
|
||||||
assert.match(source, /semantic=\{semantic \? \{/);
|
assert.match(source, /semantic=\{semantic \? \{/);
|
||||||
|
assert.match(
|
||||||
|
visual,
|
||||||
|
/classifiedSpatialFrame\s*&&\s*classifiedSpatialFrame\.sampleAvailable !== false/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(visual, /classifiedSpatialFrame\?\.sampleAvailable !== false/);
|
||||||
|
assert.match(visual, /timelineFrame\.availableFrames\.find/);
|
||||||
|
assert.match(visual, /<LaboratoryMetricEvidenceScene/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
visual,
|
||||||
|
/\{\(!classifiedSpatialLayer \? spatialFrame : displayedClassifiedSpatialFrame\) \? \(/,
|
||||||
|
);
|
||||||
|
assert.match(scene, /useEffect\(\(\) => resetView\(\), \[mode, resetView\]\)/);
|
||||||
assert.match(contract, /linked_semantic_result_id/);
|
assert.match(contract, /linked_semantic_result_id/);
|
||||||
assert.match(contract, /missioncore\.m49-tgs-full-shadow-playback\/v1/);
|
assert.match(contract, /missioncore\.m49-tgs-full-shadow-playback\/v1/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { after, before, test } from "node:test";
|
import { after, before, test } from "node:test";
|
||||||
|
|
||||||
@@ -9,6 +10,8 @@ let fetchM4ThreatReplayResult;
|
|||||||
let fetchM4ThreatVisual;
|
let fetchM4ThreatVisual;
|
||||||
let fetchM4ThreatTimeline;
|
let fetchM4ThreatTimeline;
|
||||||
let fetchM4ThreatTimelineChunk;
|
let fetchM4ThreatTimelineChunk;
|
||||||
|
let fetchM4ThreatPlaybackManifest;
|
||||||
|
let fetchM4ThreatPlaybackPointChunk;
|
||||||
let fetchM4ThreatCameraPointOverlay;
|
let fetchM4ThreatCameraPointOverlay;
|
||||||
let hydrateM4ThreatTimelineFrame;
|
let hydrateM4ThreatTimelineFrame;
|
||||||
let selectM4ThreatTimelineFrame;
|
let selectM4ThreatTimelineFrame;
|
||||||
@@ -33,6 +36,8 @@ before(async () => {
|
|||||||
fetchM4ThreatVisual,
|
fetchM4ThreatVisual,
|
||||||
fetchM4ThreatTimeline,
|
fetchM4ThreatTimeline,
|
||||||
fetchM4ThreatTimelineChunk,
|
fetchM4ThreatTimelineChunk,
|
||||||
|
fetchM4ThreatPlaybackManifest,
|
||||||
|
fetchM4ThreatPlaybackPointChunk,
|
||||||
fetchM4ThreatCameraPointOverlay,
|
fetchM4ThreatCameraPointOverlay,
|
||||||
hydrateM4ThreatTimelineFrame,
|
hydrateM4ThreatTimelineFrame,
|
||||||
selectM4ThreatTimelineFrame,
|
selectM4ThreatTimelineFrame,
|
||||||
@@ -373,6 +378,76 @@ test("M4.6 hydrates a lightweight timeline frame from one retained binary point
|
|||||||
assert.equal(typeof hydrateM4ThreatTimelineFrame, "function");
|
assert.equal(typeof hydrateM4ThreatTimelineFrame, "function");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("M4.6 source cloud opens from one verified bounded chunk instead of the 105 MiB track", async () => {
|
||||||
|
const pointOffsets = [0, ...Array(4489).fill(2)];
|
||||||
|
const points = new Float32Array([11, 20, 30.25, 12, 19.5, 30]);
|
||||||
|
const pointBytes = points.buffer;
|
||||||
|
const pointSha256 = createHash("sha256").update(Buffer.from(pointBytes)).digest("hex");
|
||||||
|
const endpointRoot = "/api/v1/laboratory/m4-threat/results";
|
||||||
|
const chunks = Array.from({ length: 188 }, (_, index) => {
|
||||||
|
const start = index * 24;
|
||||||
|
const count = Math.min(24, 4489 - start);
|
||||||
|
const pointStart = pointOffsets[start];
|
||||||
|
const pointStop = pointOffsets[start + count];
|
||||||
|
const pointCount = pointStop - pointStart;
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
start,
|
||||||
|
count,
|
||||||
|
point_start: pointStart,
|
||||||
|
point_count: pointCount,
|
||||||
|
url: `${endpointRoot}/${resultId}/timeline/playback/chunks/${index}`,
|
||||||
|
media_type: "application/octet-stream",
|
||||||
|
dtype: "<f4",
|
||||||
|
shape: [pointCount, 3],
|
||||||
|
bytes: pointCount * 3 * 4,
|
||||||
|
sha256: index === 0 ? pointSha256 : "0".repeat(64),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const manifestPayload = {
|
||||||
|
schema_version: "missioncore.recorded-spatial-playback/v1",
|
||||||
|
result_id: resultId,
|
||||||
|
frame_count: 4489,
|
||||||
|
point_count: 2,
|
||||||
|
point_offsets: pointOffsets,
|
||||||
|
chunk_frame_count: 24,
|
||||||
|
resident_chunk_count_max: 4,
|
||||||
|
forward_prefetch_chunk_count: 1,
|
||||||
|
chunks,
|
||||||
|
track: {
|
||||||
|
id: "points-map-f32",
|
||||||
|
url: `${endpointRoot}/${resultId}/timeline/playback/tracks/points-map-f32`,
|
||||||
|
media_type: "application/octet-stream",
|
||||||
|
dtype: "<f4",
|
||||||
|
shape: [2, 3],
|
||||||
|
bytes: pointBytes.byteLength,
|
||||||
|
sha256: pointSha256,
|
||||||
|
},
|
||||||
|
coordinate_frame: "map",
|
||||||
|
access: "read-only-sealed-binary-playback",
|
||||||
|
};
|
||||||
|
const requested = [];
|
||||||
|
const fetcher = async (input) => {
|
||||||
|
const url = String(input);
|
||||||
|
requested.push(url);
|
||||||
|
if (url.endsWith("/timeline/playback")) return Response.json(manifestPayload);
|
||||||
|
if (url.endsWith("/timeline/playback/chunks/0")) return new Response(pointBytes.slice(0));
|
||||||
|
return new Response(null, { status: 404 });
|
||||||
|
};
|
||||||
|
const manifest = await fetchM4ThreatPlaybackManifest(resultId, { fetcher });
|
||||||
|
const chunk = await fetchM4ThreatPlaybackPointChunk(manifest, 0, { fetcher });
|
||||||
|
|
||||||
|
assert.deepEqual(requested, [
|
||||||
|
`${endpointRoot}/${resultId}/timeline/playback`,
|
||||||
|
`${endpointRoot}/${resultId}/timeline/playback/chunks/0`,
|
||||||
|
]);
|
||||||
|
assert.equal(chunk.pointCount, 2);
|
||||||
|
assert.equal(chunk.pointStart, 0);
|
||||||
|
assert.equal(chunk.sequenceCount, 24);
|
||||||
|
assert.equal(chunk.pointsMapXyzM.byteLength, 24);
|
||||||
|
assert.equal(requested.some((url) => url.endsWith("points-map-f32")), false);
|
||||||
|
});
|
||||||
|
|
||||||
test("M4.8S timeline binds factory-KB4 camera points through its exact endpoint", async () => {
|
test("M4.8S timeline binds factory-KB4 camera points through its exact endpoint", async () => {
|
||||||
const replayResultId = `m48s-fixed-class-detector-lab-${"b".repeat(64)}`;
|
const replayResultId = `m48s-fixed-class-detector-lab-${"b".repeat(64)}`;
|
||||||
const endpointRoot = "/api/v1/laboratory/m48s/fixed-class-detector";
|
const endpointRoot = "/api/v1/laboratory/m48s/fixed-class-detector";
|
||||||
@@ -825,10 +900,15 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
|||||||
assert.match(visualCss, /width: 33\.333333%/);
|
assert.match(visualCss, /width: 33\.333333%/);
|
||||||
assert.match(visualCss, /flex-flow: column nowrap/);
|
assert.match(visualCss, /flex-flow: column nowrap/);
|
||||||
assert.match(visualCss, /m4-replay-threat-visual__overlay > div/);
|
assert.match(visualCss, /m4-replay-threat-visual__overlay > div/);
|
||||||
assert.match(visualCss, /grid-template-columns: minmax\(0, max-content\)/);
|
assert.match(visualCss, /grid-template-columns: minmax\(0, 1fr\)/);
|
||||||
assert.match(visualCss, /width: max-content/);
|
assert.match(visualCss, /width: min\(17\.5rem/);
|
||||||
|
assert.match(visualCss, /grid-auto-rows: 3\.8rem/);
|
||||||
assert.match(visualCss, /--m4-replay-threat-overlay-pane-width/);
|
assert.match(visualCss, /--m4-replay-threat-overlay-pane-width/);
|
||||||
assert.match(visualCss, /white-space: nowrap/);
|
assert.match(visualCss, /white-space: nowrap/);
|
||||||
|
assert.match(visualCss, /-webkit-line-clamp: 2/);
|
||||||
|
assert.match(visual, /classifiedCellCount/);
|
||||||
|
assert.match(visual, /lastAvailableClassifiedSpatialFrame/);
|
||||||
|
assert.match(visual, /current UNOBSERVED/);
|
||||||
assert.match(visualCss, /laboratory-metric-evidence-scene__legend/);
|
assert.match(visualCss, /laboratory-metric-evidence-scene__legend/);
|
||||||
assert.match(visualCss, /bottom: auto/);
|
assert.match(visualCss, /bottom: auto/);
|
||||||
assert.match(videoScene, /<RecordedFmp4Player/);
|
assert.match(videoScene, /<RecordedFmp4Player/);
|
||||||
@@ -842,16 +922,18 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
|||||||
assert.match(visual, /showLocalSurface/);
|
assert.match(visual, /showLocalSurface/);
|
||||||
assert.match(
|
assert.match(
|
||||||
visual,
|
visual,
|
||||||
/pointCloudBodyXyzM=\{displayedClassifiedSpatialFrame && replaceClassifiedPointCloud[\s\S]*\? classifiedPointsBody[\s\S]*: activeSpatialFrame\?\.pointCloudBodyXyzM \?\? \[\]\}/,
|
/pointCloudBodyXyzM=\{displayedClassifiedSpatialFrame && replaceClassifiedPointCloud[\s\S]*\? classifiedPointsBody[\s\S]*: classifiedContextSpatialFrame\?\.pointCloudBodyXyzM \?\? \[\]\}/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
visual,
|
visual,
|
||||||
/const classifiedSpatialFrame = classifiedSpatialLayer\?\.frame\?\.sourceSequence === timelineFrame\.activeSequence[\s\S]*const displayedClassifiedSpatialFrame = classifiedSpatialFrame[\s\S]*lastClassifiedSpatialFrameRef/,
|
/const classifiedSpatialFrame = classifiedSpatialLayer\?\.frame\?\.sourceSequence === timelineFrame\.activeSequence[\s\S]*lastClassifiedSpatialFrameRef[\s\S]*const displayedClassifiedSpatialFrame = classifiedSpatialFrame\s*&&\s*classifiedSpatialFrame\.sampleAvailable !== false/,
|
||||||
);
|
);
|
||||||
|
assert.doesNotMatch(visual, /classifiedSpatialFrame\?\.sampleAvailable !== false/);
|
||||||
|
assert.match(visual, /timelineFrame\.availableFrames\.find/);
|
||||||
assert.match(visual, /localSurfaceBodyXyzM=\{localSurface\.pointsBodyXyzM\}/);
|
assert.match(visual, /localSurfaceBodyXyzM=\{localSurface\.pointsBodyXyzM\}/);
|
||||||
assert.match(visual, /showLocalSurface=\{showLocalSurface\}/);
|
assert.match(visual, /showLocalSurface=\{showLocalSurface\}/);
|
||||||
assert.match(visual, /\{semantic \? \([\s\S]*>\s*SEMANTICS\s*<\/Button>/);
|
assert.match(visual, /\{semantic \? \([\s\S]*>\s*SEMANTICS\s*<\/Button>/);
|
||||||
assert.match(visual, /все 2 244 TGS-ячейки явно UNOBSERVED/);
|
assert.match(visual, /current safety — все \{classifiedCellCount\.toLocaleString\("ru-RU"\)\} TGS-ячейки UNOBSERVED/);
|
||||||
assert.match(
|
assert.match(
|
||||||
visual,
|
visual,
|
||||||
/mapGravityLocalSensorToBodyGround[\s\S]*rotated\[2\] \+ nominalSensorHeightM/,
|
/mapGravityLocalSensorToBodyGround[\s\S]*rotated\[2\] \+ nominalSensorHeightM/,
|
||||||
|
|||||||
@@ -173,6 +173,9 @@ test("recorded player keeps full-archive range fallback and uses bounded generat
|
|||||||
assert.match(source, /pumpRecordedSegmentWindow/);
|
assert.match(source, /pumpRecordedSegmentWindow/);
|
||||||
assert.match(source, /waitForRecordedVideoTarget/);
|
assert.match(source, /waitForRecordedVideoTarget/);
|
||||||
assert.match(source, /removeRecordedMediaRange/);
|
assert.match(source, /removeRecordedMediaRange/);
|
||||||
|
assert.match(source, /hasPresentedFrame/);
|
||||||
|
assert.match(source, /retainForwardFrame/);
|
||||||
|
assert.match(source, /candidateTarget\.sequence >= previousTarget\.sequence/);
|
||||||
|
|
||||||
assert.equal(recordedMediaDecodeStartSequence([1, 1491, 1501], 1500), 1491);
|
assert.equal(recordedMediaDecodeStartSequence([1, 1491, 1501], 1500), 1491);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ test("metric evidence keeps missing semantic assignments as context and exposes
|
|||||||
assert.match(source, /recordedEvidenceSemanticCssColor/);
|
assert.match(source, /recordedEvidenceSemanticCssColor/);
|
||||||
assert.match(source, /DynamicDrawUsage/);
|
assert.match(source, /DynamicDrawUsage/);
|
||||||
assert.match(source, /classifiedMeshesRef/);
|
assert.match(source, /classifiedMeshesRef/);
|
||||||
|
assert.match(source, /classifiedPackedCells/);
|
||||||
assert.match(source, /updatePointPositions/);
|
assert.match(source, /updatePointPositions/);
|
||||||
|
assert.match(source, /renderer\.info\.render\.calls/);
|
||||||
|
assert.match(source, /data-decision="performance"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("semantic point alignment follows the last qualified spatial increment", async () => {
|
test("semantic point alignment follows the last qualified spatial increment", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "missioncore.m49-physical-safety-shadow-profile/v1",
|
||||||
|
"profile_id": "m49-mission-core-rover-physical-safety-shadow/v0",
|
||||||
|
"status": "planning-only-unqualified",
|
||||||
|
"vehicle": {
|
||||||
|
"source": "operator-supplied-2026-08-27-unverified",
|
||||||
|
"body_length_m": 1.0,
|
||||||
|
"body_width_m": 0.8,
|
||||||
|
"body_height_m": 0.4,
|
||||||
|
"ground_clearance_m": 0.15,
|
||||||
|
"nominal_mass_kg": 100.0,
|
||||||
|
"mass_qualification": "estimate-not-weighed",
|
||||||
|
"provisional_centered_footprint_body_xy_m": [
|
||||||
|
[0.5, 0.4],
|
||||||
|
[0.5, -0.4],
|
||||||
|
[-0.5, -0.4],
|
||||||
|
[-0.5, 0.4]
|
||||||
|
],
|
||||||
|
"nominal_speed_mps": 0.2777777778,
|
||||||
|
"maximum_operating_speed_mps": 0.5555555556,
|
||||||
|
"drive": {
|
||||||
|
"motor_count": 2,
|
||||||
|
"rated_power_per_motor_w": 500.0,
|
||||||
|
"motor_per_side": true,
|
||||||
|
"steering_model": "unqualified-likely-differential"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unresolved_physical_inputs": [
|
||||||
|
"measured-mass-and-center-of-gravity",
|
||||||
|
"wheel-diameter-and-width",
|
||||||
|
"wheelbase-track-and-overhangs",
|
||||||
|
"qualified-base-link-origin",
|
||||||
|
"controller-braking-and-loss-of-power-behavior",
|
||||||
|
"measured-stopping-envelope",
|
||||||
|
"maximum-step-slope-and-side-slope",
|
||||||
|
"lidar-camera-to-body-extrinsics",
|
||||||
|
"negative-obstacle-sensor-coverage"
|
||||||
|
],
|
||||||
|
"terrain_evidence": {
|
||||||
|
"accepted_baseline_profile": "m49-tgs-full-shadow-v1.json",
|
||||||
|
"accepted_baseline_cell_size_m": 0.45,
|
||||||
|
"baseline_mutation_allowed": false,
|
||||||
|
"challenger_cell_sizes_m": [0.15, 0.1],
|
||||||
|
"challenger_radius_m": 12.0,
|
||||||
|
"missing_support_policy": "stop-never-free",
|
||||||
|
"required_states": [
|
||||||
|
"SUPPORTED",
|
||||||
|
"RIGID_OR_UNKNOWN_OBSTACLE",
|
||||||
|
"NEGATIVE_OR_UNSUPPORTED",
|
||||||
|
"VEGETATION_UNKNOWN",
|
||||||
|
"VEGETATION_POTENTIALLY_TRAVERSABLE",
|
||||||
|
"VEGETATION_WITH_RIGID_GEOMETRY"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"operator_visual": {
|
||||||
|
"host_profile": "14-inch-2023-macbook-pro-18-gib",
|
||||||
|
"worker_role": "realtime-only",
|
||||||
|
"lab_artifact_generation": "local-sequential-offline-or-imported-sealed",
|
||||||
|
"lab_build_may_take_longer_than_source_duration": true,
|
||||||
|
"source_pace_hz": 10.0,
|
||||||
|
"render_policy": "on-demand-with-interaction-damping",
|
||||||
|
"transport": "sealed-local-binary-chunks-no-per-cell-json",
|
||||||
|
"worker_runtime_dependency": false,
|
||||||
|
"sealed_artifact_required": true,
|
||||||
|
"offline_worker_open_required": true,
|
||||||
|
"full_session_high_resolution_preload_allowed": false,
|
||||||
|
"playback_buffer": {
|
||||||
|
"chunk_frame_count": 32,
|
||||||
|
"startup_prebuffer_chunk_count": 2,
|
||||||
|
"startup_prebuffer_seconds": 6.4,
|
||||||
|
"resident_chunk_count_max": 3,
|
||||||
|
"resident_window_seconds_max": 9.6,
|
||||||
|
"forward_prefetch_chunk_count": 1,
|
||||||
|
"backward_seek_policy": "load-target-and-next-from-sealed-local-artifact"
|
||||||
|
},
|
||||||
|
"lod": {
|
||||||
|
"safety_computation_reduced_by_visual_lod": false,
|
||||||
|
"near_field_radius_m": 4.0,
|
||||||
|
"near_field_cell_size_m": 0.15,
|
||||||
|
"mid_field_radius_m": 8.0,
|
||||||
|
"mid_field_cell_size_m": 0.3,
|
||||||
|
"far_field_radius_m": 12.0,
|
||||||
|
"far_field_cell_size_m": 0.45,
|
||||||
|
"paused_active_frame_full_resolution_available": true
|
||||||
|
},
|
||||||
|
"acceptance_budget": {
|
||||||
|
"main_thread_frame_prepare_p95_ms_max": 10.0,
|
||||||
|
"webgl_render_submit_p95_ms_target": 16.7,
|
||||||
|
"webgl_render_submit_p95_ms_max": 33.3,
|
||||||
|
"evidence_to_visible_p95_ms_max": 100.0,
|
||||||
|
"incremental_resident_memory_mib_max": 256.0,
|
||||||
|
"post_warmup_memory_growth_mib_max": 32.0,
|
||||||
|
"long_task_over_50ms_count_max": 0,
|
||||||
|
"webgl_context_loss_count_max": 0,
|
||||||
|
"worker_unavailable_open_failure_count_max": 0,
|
||||||
|
"worker_request_count_during_lab_open_max": 0,
|
||||||
|
"worker_request_count_during_lab_build_max": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"authority": {
|
||||||
|
"commands_enabled": false,
|
||||||
|
"navigation_accepted": false,
|
||||||
|
"safety_accepted": false,
|
||||||
|
"actuation_accepted": false,
|
||||||
|
"physical_test_accepted": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# ADR 0043: Physical safety shadow and bounded operator visual
|
||||||
|
|
||||||
|
Date: 2026-08-27
|
||||||
|
Status: autonomous transport slice implemented; 0.15 m challenger and measured acceptance incomplete
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Mission Core has accepted a source-paced RF-DETR-native plus TRAVEL TGS
|
||||||
|
recorded shadow on RAVNOVES00. The retained TGS result uses a `0.45 m` cell,
|
||||||
|
`12 m` radius and four explicit states. It is useful operator evidence but is
|
||||||
|
not a planner-authoritative terrain provider. The integrated acceptance joins
|
||||||
|
TGS and the reference graph as evidence; TGS does not modify graph state.
|
||||||
|
|
||||||
|
The intended physical rover is not a lightweight laboratory fixture. The
|
||||||
|
operator supplied the following planning facts, not yet independently measured:
|
||||||
|
|
||||||
|
- body `1.0 x 0.8 x 0.4 m`;
|
||||||
|
- ground clearance `0.15 m`;
|
||||||
|
- mass approximately `100 kg`;
|
||||||
|
- one nominal `500 W` motor per side;
|
||||||
|
- nominal speed `0.2778 m/s` (`1 km/h`);
|
||||||
|
- maximum operating speed `0.5556 m/s` (`2 km/h`).
|
||||||
|
|
||||||
|
Controller, remote dead-man, braking behavior, physical E-stop, body/sensor
|
||||||
|
extrinsics, center of gravity and negative-obstacle coverage are unavailable.
|
||||||
|
Commands therefore remain disabled.
|
||||||
|
|
||||||
|
The current full TGS playback pack is about `88 MiB`: approximately `9.6 MiB`
|
||||||
|
states, `77 MiB` z-bounds and `1.1 MiB` frame metadata. Holding the same
|
||||||
|
`12 m` disk for all `4,489` frames would scale to roughly `0.8 GiB` at `0.15 m`
|
||||||
|
and `1.8 GiB` at `0.10 m`, before richer terrain features, video, semantics and
|
||||||
|
renderer allocations. Full-session high-resolution browser preload is not an
|
||||||
|
admitted design for the current 18 GiB MacBook host.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Recorded LAB and realtime perception are different runtime contours. Worker
|
||||||
|
006 is reserved for realtime perception. A new recorded LAB is built by a
|
||||||
|
bounded sequential offline builder on the MacBook, or admits an already sealed
|
||||||
|
immutable artifact. The offline build may take longer than source duration. It
|
||||||
|
must not become a hidden Worker job. An opened LAB never depends on Worker 006.
|
||||||
|
|
||||||
|
```text
|
||||||
|
recorded LAB publication, Worker 006 absent
|
||||||
|
recorded source -> local sequential offline build
|
||||||
|
-> seal immutable result + binary LOD tracks
|
||||||
|
|
|
||||||
|
v
|
||||||
|
local/CAS artifact store
|
||||||
|
|
|
||||||
|
Worker 006 remains absent
|
||||||
|
|
|
||||||
|
v
|
||||||
|
canonical local backend, read-only
|
||||||
|
|
|
||||||
|
v
|
||||||
|
MacBook LAB CAMERA + 3D + PLAN
|
||||||
|
|
||||||
|
separate future realtime contour
|
||||||
|
K1 -> Worker 006 -> live world-state/planner shadow
|
||||||
|
```
|
||||||
|
|
||||||
|
Opening, seeking, looping and visually inspecting a sealed LAB must perform
|
||||||
|
zero requests to Worker 006. If the worker, its host, Docker or the network path
|
||||||
|
to it is unavailable, a locally present sealed result opens unchanged. If a
|
||||||
|
manifest-declared local object is absent or corrupt, LAB fails with an explicit
|
||||||
|
artifact error; it does not start compute, scan worker results or silently
|
||||||
|
rebuild evidence. This preserves ADR 0033.
|
||||||
|
|
||||||
|
Publishing a new LAB also performs zero Worker requests. Slow local preparation
|
||||||
|
is acceptable, but it is explicit and bounded: build first, seal second, open
|
||||||
|
third. Historical LAB artifacts that were originally computed on Worker 006
|
||||||
|
remain valid after sealing because replay depends only on their immutable
|
||||||
|
artifacts; they do not justify a Worker dependency for new LABs.
|
||||||
|
|
||||||
|
Visual performance may never change a safety state, erase unknown space or
|
||||||
|
reduce the resolution used by the safety consumer. Conversely, the MacBook does
|
||||||
|
not need every cell of every high-resolution frame resident at once in order to
|
||||||
|
provide a complete operator explanation.
|
||||||
|
|
||||||
|
The retained `0.45 m` M49T5 profile and sealed result remain immutable. A new
|
||||||
|
physical-safety challenger may evaluate `0.15 m` and `0.10 m` cells without
|
||||||
|
rewriting that baseline. The first candidate resolution is `0.15 m`; `0.10 m`
|
||||||
|
is admitted only if LiDAR density improves the evidence. Worker compute
|
||||||
|
acceptance and autonomous LAB viewer acceptance are measured separately.
|
||||||
|
|
||||||
|
The operator transport uses binary typed arrays. It must not serialize one JSON
|
||||||
|
object per cell or expand binary frames into nested JavaScript objects before
|
||||||
|
updating GPU instances. Recorded high-resolution inspection reads only sealed
|
||||||
|
local binary chunks and active-frame detail, not a full-session high-resolution
|
||||||
|
preload.
|
||||||
|
|
||||||
|
The proposed spatial chunk is `32` source frames (`3.2 s` at `10 Hz`). Before
|
||||||
|
the spatial scene declares itself ready, it may preload the first two chunks,
|
||||||
|
or `6.4 s`, from the local artifact. The browser retains at most three chunks
|
||||||
|
(`9.6 s`), normally previous/current/next, and prefetches one forward chunk.
|
||||||
|
A backward seek loads the target and next chunk from the same sealed artifact.
|
||||||
|
This bounded startup delay is preferable to either Worker dependency or a
|
||||||
|
multi-gigabyte browser preload.
|
||||||
|
|
||||||
|
## Implemented autonomous transport slice
|
||||||
|
|
||||||
|
The first executable transport slice is now present without creating a new LAB
|
||||||
|
surface or transport clock:
|
||||||
|
|
||||||
|
- `m49_physical_safety_playback.py` is a local, sequential, filesystem-only
|
||||||
|
sealer. It accepts an already sealed source, verifies its declared hashes,
|
||||||
|
and writes immutable binary chunks atomically. It contains no Worker client,
|
||||||
|
job submission or network path.
|
||||||
|
- `m49_physical_safety_playback_api.py` exposes only the configured local
|
||||||
|
artifact root. Manifest, shared centers, frame catalog and every requested
|
||||||
|
chunk are hash-verified. Corruption returns an explicit artifact failure.
|
||||||
|
- `m49PhysicalSafetyPlayback.ts` rejects non-canonical URLs, verifies byte
|
||||||
|
length and SHA-256 in the browser, prebuffers two chunks, retains no more than
|
||||||
|
three and loads target plus next on seek.
|
||||||
|
- the existing M49 evidence composition resolves this transport by exact
|
||||||
|
`source_result_id`. If no chunked derivative has been published, it may still
|
||||||
|
read the old sealed local full pack; neither path uses Worker 006.
|
||||||
|
|
||||||
|
A real transport canary was generated from the accepted immutable `0.45 m`
|
||||||
|
M49T5 result without recomputing TGS or changing that source result. The canary
|
||||||
|
identity is
|
||||||
|
`m49-physical-safety-playback-0d0e41a995a902a4701935a79655d93f2f1898806f8fa82ebbd207129f111d69`.
|
||||||
|
It contains all `4,489` frames and `2,244` cells in `141` chunks. A normal
|
||||||
|
32-frame chunk is `646,300` bytes; two startup chunks are about `1.23 MiB` and
|
||||||
|
three spatial chunks are about `1.85 MiB`, plus the shared centers and frame
|
||||||
|
catalog. This is the transport canary only. It is not a `0.15 m` safety result
|
||||||
|
and no upsampled baseline is presented as new evidence.
|
||||||
|
|
||||||
|
The `0.15 m` shape, fail-closed missing-sample rule, bounded seek behavior and
|
||||||
|
zero-Worker URL policy are covered with synthetic contract fixtures. A real
|
||||||
|
`0.15 m` challenger still requires the local sequential terrain computation
|
||||||
|
and a separately sealed result.
|
||||||
|
|
||||||
|
The visual uses explicit LOD:
|
||||||
|
|
||||||
|
- `0-4 m`: `0.15 m` near-field cells;
|
||||||
|
- `4-8 m`: `0.30 m` explanatory cells;
|
||||||
|
- `8-12 m`: `0.45 m` context cells;
|
||||||
|
- pause/anchor inspection: full available active-frame resolution.
|
||||||
|
|
||||||
|
LOD changes only presentation. The sealed result retains the full candidate
|
||||||
|
evidence needed to reproduce its decisions; a future realtime worker separately
|
||||||
|
retains the full live safety grid across the configured safety radius.
|
||||||
|
|
||||||
|
The metric Three.js scene retains instanced meshes and all evidence layers. It
|
||||||
|
renders on evidence, camera interaction, resize or layer changes. It does not
|
||||||
|
run a permanent animation loop while idle. The scene exposes render-pass time,
|
||||||
|
draw calls, triangles, cell count and pixel ratio in the operator surface.
|
||||||
|
|
||||||
|
## Acceptance budget
|
||||||
|
|
||||||
|
Acceptance is measured on the named 14-inch 2023 MacBook Pro with 18 GiB RAM,
|
||||||
|
with recorded video, semantic overlay and TGS 3D/PLAN evidence available:
|
||||||
|
|
||||||
|
- source pace: `10 Hz` without evidence loss;
|
||||||
|
- main-thread frame preparation p95 at most `10 ms`;
|
||||||
|
- CPU-side WebGL render submission p95 target `16.7 ms`, hard maximum
|
||||||
|
`33.3 ms`; GPU completion requires a separate supported timer query;
|
||||||
|
- evidence-to-visible p95 at most `100 ms`;
|
||||||
|
- incremental visual resident memory at most `256 MiB`;
|
||||||
|
- post-warmup growth at most `32 MiB` over a ten-minute soak;
|
||||||
|
- zero browser long tasks above `50 ms` during source-paced playback;
|
||||||
|
- zero WebGL context losses;
|
||||||
|
- zero Worker 006 requests while opening, seeking or playing a LAB;
|
||||||
|
- zero Worker 006 requests while building a new recorded LAB;
|
||||||
|
- the sealed LAB opens with Worker 006 unavailable;
|
||||||
|
- idle metric scene performs no continuous render loop.
|
||||||
|
|
||||||
|
These are proposed thresholds until a canonical browser run emits a sealed
|
||||||
|
performance ledger. Passing them does not grant navigation, safety or actuation
|
||||||
|
authority.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The operator keeps the full explanatory visual instead of receiving a
|
||||||
|
stripped-down safety UI.
|
||||||
|
- High-resolution terrain work does not turn the MacBook into a second
|
||||||
|
perception worker.
|
||||||
|
- Historical LAB remains usable during Worker maintenance, Docker failure or
|
||||||
|
loss of the Worker network path.
|
||||||
|
- Evidence identity and safety decisions remain independent of display LOD.
|
||||||
|
- The next implementation boundary is a packed, chunked terrain contract plus
|
||||||
|
footprint-aware states; no physical connection or command path is introduced.
|
||||||
|
- Physical authority remains blocked by controller/E-stop, measured braking,
|
||||||
|
vehicle geometry, sensor extrinsics and negative-obstacle coverage.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Seal a local M49 playback without contacting Worker 006."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from k1link.laboratory.m49_physical_safety_playback import (
|
||||||
|
seal_m49_physical_safety_playback,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Build an autonomous sealed M49 physical-safety playback artifact."
|
||||||
|
)
|
||||||
|
parser.add_argument("--source-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--destination-root", type=Path, required=True)
|
||||||
|
parser.add_argument(
|
||||||
|
"--profile",
|
||||||
|
type=Path,
|
||||||
|
default=Path("config/perception/m49-physical-safety-shadow-v0.json"),
|
||||||
|
)
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
result = seal_m49_physical_safety_playback(
|
||||||
|
source_root=arguments.source_root,
|
||||||
|
destination_root=arguments.destination_root,
|
||||||
|
profile_path=arguments.profile,
|
||||||
|
)
|
||||||
|
print(result.result_id)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
"""Build and verify autonomous chunked M49 physical-safety playback artifacts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import shutil
|
||||||
|
import struct
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Final
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
SCHEMA: Final = "missioncore.m49-physical-safety-playback/v1"
|
||||||
|
PROFILE_SCHEMA: Final = "missioncore.m49-physical-safety-shadow-profile/v1"
|
||||||
|
PREFIX: Final = "m49-physical-safety-playback-"
|
||||||
|
CHUNK_MAGIC: Final = b"MCPSCH01"
|
||||||
|
CHUNK_HEADER: Final = struct.Struct("<8sIIIII")
|
||||||
|
SOURCE_TRACKS: Final = (
|
||||||
|
"costmap-cell-centers-xy-m.npy",
|
||||||
|
"costmap-states.npy",
|
||||||
|
"costmap-z-bounds-m.npy",
|
||||||
|
"frames.ndjson",
|
||||||
|
)
|
||||||
|
_HASH_BLOCK_BYTES: Final = 1024 * 1024
|
||||||
|
_MAX_JSON_BYTES: Final = 4 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class M49PhysicalSafetyPlaybackError(RuntimeError):
|
||||||
|
"""The autonomous playback artifact is missing or violates its contract."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class M49PhysicalSafetyPlayback:
|
||||||
|
result_id: str
|
||||||
|
root: Path
|
||||||
|
manifest: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
for block in iter(lambda: stream.read(_HASH_BLOCK_BYTES), b""):
|
||||||
|
digest.update(block)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_sha256(value: object) -> str:
|
||||||
|
content = json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(content).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _json(path: Path, label: str) -> dict[str, Any]:
|
||||||
|
if path.is_symlink() or not path.is_file() or path.stat().st_size > _MAX_JSON_BYTES:
|
||||||
|
raise M49PhysicalSafetyPlaybackError(f"{label} is unavailable")
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||||
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||||
|
raise M49PhysicalSafetyPlaybackError(f"{label} is invalid") from error
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise M49PhysicalSafetyPlaybackError(f"{label} is invalid")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _artifact(path: Path, *, media_type: str, role: str) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"path": path.name,
|
||||||
|
"media_type": media_type,
|
||||||
|
"role": role,
|
||||||
|
"byte_length": path.stat().st_size,
|
||||||
|
"sha256": _sha256(path),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _source_artifacts(source: Path, manifest: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||||
|
artifacts = manifest.get("artifacts")
|
||||||
|
if not isinstance(artifacts, list):
|
||||||
|
raise M49PhysicalSafetyPlaybackError("source artifact catalog is unavailable")
|
||||||
|
result: dict[str, dict[str, Any]] = {}
|
||||||
|
for value in artifacts:
|
||||||
|
if not isinstance(value, dict) or not isinstance(value.get("path"), str):
|
||||||
|
raise M49PhysicalSafetyPlaybackError("source artifact catalog is invalid")
|
||||||
|
name = value["path"]
|
||||||
|
if name in (*SOURCE_TRACKS, "report.json"):
|
||||||
|
path = source / name
|
||||||
|
if (
|
||||||
|
path.parent != source
|
||||||
|
or path.is_symlink()
|
||||||
|
or not path.is_file()
|
||||||
|
or value.get("byte_length") != path.stat().st_size
|
||||||
|
or value.get("sha256") != _sha256(path)
|
||||||
|
):
|
||||||
|
raise M49PhysicalSafetyPlaybackError(f"source artifact changed: {name}")
|
||||||
|
result[name] = value
|
||||||
|
if set(result) != {*SOURCE_TRACKS, "report.json"}:
|
||||||
|
raise M49PhysicalSafetyPlaybackError("source spatial tracks are incomplete")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _state_codes(report: dict[str, Any]) -> dict[str, int]:
|
||||||
|
candidates = (
|
||||||
|
report.get("state_codes"),
|
||||||
|
report.get("visual_review", {}).get("state_codes")
|
||||||
|
if isinstance(report.get("visual_review"), dict)
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
for candidate in candidates:
|
||||||
|
if not isinstance(candidate, dict):
|
||||||
|
continue
|
||||||
|
parsed = {
|
||||||
|
str(name): int(code)
|
||||||
|
for name, code in candidate.items()
|
||||||
|
if isinstance(name, str)
|
||||||
|
and isinstance(code, int)
|
||||||
|
and not isinstance(code, bool)
|
||||||
|
and 0 <= code <= 255
|
||||||
|
}
|
||||||
|
if len(parsed) == len(candidate) and parsed.get("UNOBSERVED") == 0:
|
||||||
|
return parsed
|
||||||
|
raise M49PhysicalSafetyPlaybackError("source state codes are unavailable")
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_rows(path: Path) -> tuple[dict[str, Any], ...]:
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
with path.open("r", encoding="utf-8") as stream:
|
||||||
|
for sequence, line in enumerate(stream):
|
||||||
|
value = json.loads(line)
|
||||||
|
if (
|
||||||
|
not isinstance(value, dict)
|
||||||
|
or not isinstance(value.get("sample_available"), bool)
|
||||||
|
or not isinstance(value.get("source_frame_index"), int)
|
||||||
|
or isinstance(value.get("source_frame_index"), bool)
|
||||||
|
or not isinstance(value.get("session_seconds"), (int, float))
|
||||||
|
or isinstance(value.get("session_seconds"), bool)
|
||||||
|
or not math.isfinite(float(value["session_seconds"]))
|
||||||
|
):
|
||||||
|
raise M49PhysicalSafetyPlaybackError(
|
||||||
|
f"source frame catalog changed at sequence {sequence}"
|
||||||
|
)
|
||||||
|
rows.append(value)
|
||||||
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||||
|
raise M49PhysicalSafetyPlaybackError("source frame catalog is invalid") from error
|
||||||
|
if not rows:
|
||||||
|
raise M49PhysicalSafetyPlaybackError("source frame catalog is empty")
|
||||||
|
return tuple(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _source_geometry(report: dict[str, Any]) -> tuple[float, float, str]:
|
||||||
|
configuration = report.get("configuration")
|
||||||
|
if not isinstance(configuration, dict):
|
||||||
|
raise M49PhysicalSafetyPlaybackError("source geometry is unavailable")
|
||||||
|
cell_size = configuration.get("cell_size_m")
|
||||||
|
radius = configuration.get("radius_m")
|
||||||
|
coordinate_frame = configuration.get("coordinate_frame")
|
||||||
|
if (
|
||||||
|
not isinstance(cell_size, (int, float))
|
||||||
|
or isinstance(cell_size, bool)
|
||||||
|
or not math.isfinite(float(cell_size))
|
||||||
|
or float(cell_size) <= 0
|
||||||
|
or not isinstance(radius, (int, float))
|
||||||
|
or isinstance(radius, bool)
|
||||||
|
or not math.isfinite(float(radius))
|
||||||
|
or float(radius) <= 0
|
||||||
|
or coordinate_frame != "map-gravity-local"
|
||||||
|
):
|
||||||
|
raise M49PhysicalSafetyPlaybackError("source geometry changed")
|
||||||
|
return float(cell_size), float(radius), coordinate_frame
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_policy(profile: dict[str, Any], cell_size_m: float) -> dict[str, int]:
|
||||||
|
visual = profile.get("operator_visual")
|
||||||
|
terrain = profile.get("terrain_evidence")
|
||||||
|
if (
|
||||||
|
profile.get("schema_version") != PROFILE_SCHEMA
|
||||||
|
or not isinstance(visual, dict)
|
||||||
|
or not isinstance(terrain, dict)
|
||||||
|
or visual.get("worker_role") != "realtime-only"
|
||||||
|
or visual.get("worker_runtime_dependency") is not False
|
||||||
|
or visual.get("sealed_artifact_required") is not True
|
||||||
|
):
|
||||||
|
raise M49PhysicalSafetyPlaybackError("physical-safety profile changed")
|
||||||
|
playback = visual.get("playback_buffer")
|
||||||
|
admitted_sizes = [
|
||||||
|
terrain.get("accepted_baseline_cell_size_m"),
|
||||||
|
*(terrain.get("challenger_cell_sizes_m") or []),
|
||||||
|
]
|
||||||
|
if (
|
||||||
|
not isinstance(playback, dict)
|
||||||
|
or playback.get("chunk_frame_count") != 32
|
||||||
|
or playback.get("startup_prebuffer_chunk_count") != 2
|
||||||
|
or playback.get("resident_chunk_count_max") != 3
|
||||||
|
or not any(
|
||||||
|
isinstance(value, (int, float))
|
||||||
|
and not isinstance(value, bool)
|
||||||
|
and math.isclose(float(value), cell_size_m, abs_tol=1e-9)
|
||||||
|
for value in admitted_sizes
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise M49PhysicalSafetyPlaybackError("physical-safety playback policy changed")
|
||||||
|
return {
|
||||||
|
"chunk_frame_count": 32,
|
||||||
|
"startup_prebuffer_chunk_count": 2,
|
||||||
|
"resident_chunk_count_max": 3,
|
||||||
|
"forward_prefetch_chunk_count": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk_payload(
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
start: int,
|
||||||
|
states: np.ndarray,
|
||||||
|
z_bounds: np.ndarray,
|
||||||
|
) -> None:
|
||||||
|
frame_count, cell_count = states.shape
|
||||||
|
state_values = np.ascontiguousarray(states, dtype=np.uint8)
|
||||||
|
z_values = np.ascontiguousarray(z_bounds, dtype="<f4")
|
||||||
|
state_bytes = state_values.nbytes
|
||||||
|
z_bytes = z_values.nbytes
|
||||||
|
padding = (4 - ((CHUNK_HEADER.size + state_bytes) % 4)) % 4
|
||||||
|
with path.open("wb") as stream:
|
||||||
|
stream.write(
|
||||||
|
CHUNK_HEADER.pack(
|
||||||
|
CHUNK_MAGIC,
|
||||||
|
start,
|
||||||
|
frame_count,
|
||||||
|
cell_count,
|
||||||
|
state_bytes,
|
||||||
|
z_bytes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stream.write(memoryview(state_values).cast("B"))
|
||||||
|
if padding:
|
||||||
|
stream.write(b"\0" * padding)
|
||||||
|
stream.write(memoryview(z_values).cast("B"))
|
||||||
|
|
||||||
|
|
||||||
|
def seal_m49_physical_safety_playback(
|
||||||
|
*,
|
||||||
|
source_root: Path,
|
||||||
|
destination_root: Path,
|
||||||
|
profile_path: Path,
|
||||||
|
created_at_utc: str | None = None,
|
||||||
|
) -> M49PhysicalSafetyPlayback:
|
||||||
|
"""Sequentially seal local tracks without starting or contacting Worker 006."""
|
||||||
|
|
||||||
|
raw_source = source_root.expanduser().absolute()
|
||||||
|
if raw_source.is_symlink() or not raw_source.is_dir():
|
||||||
|
raise M49PhysicalSafetyPlaybackError("sealed source root is unavailable")
|
||||||
|
source = raw_source.resolve(strict=True)
|
||||||
|
destination = destination_root.expanduser().absolute()
|
||||||
|
destination.mkdir(parents=True, exist_ok=True)
|
||||||
|
if destination.is_symlink():
|
||||||
|
raise M49PhysicalSafetyPlaybackError("destination must not be a symlink")
|
||||||
|
raw_profile = profile_path.expanduser().absolute()
|
||||||
|
if raw_profile.is_symlink() or not raw_profile.is_file():
|
||||||
|
raise M49PhysicalSafetyPlaybackError("physical-safety profile is unavailable")
|
||||||
|
profile_file = raw_profile.resolve(strict=True)
|
||||||
|
|
||||||
|
source_manifest = _json(source / "manifest.json", "source manifest")
|
||||||
|
source_report = _json(source / "report.json", "source report")
|
||||||
|
source_result_id = source_manifest.get("result_id")
|
||||||
|
source_identity = source_manifest.get("identity")
|
||||||
|
source_identity_sha256 = source_manifest.get("identity_sha256")
|
||||||
|
if (
|
||||||
|
not isinstance(source_result_id, str)
|
||||||
|
or source_report.get("result_id") != source_result_id
|
||||||
|
or not isinstance(source_identity, dict)
|
||||||
|
or not isinstance(source_identity_sha256, str)
|
||||||
|
or source_identity_sha256 != _canonical_sha256(source_identity)
|
||||||
|
or not source_result_id.endswith(source_identity_sha256)
|
||||||
|
):
|
||||||
|
raise M49PhysicalSafetyPlaybackError("source result identity changed")
|
||||||
|
source_artifacts = _source_artifacts(source, source_manifest)
|
||||||
|
state_codes = _state_codes(source_report)
|
||||||
|
cell_size_m, radius_m, coordinate_frame = _source_geometry(source_report)
|
||||||
|
profile = _json(profile_file, "physical-safety profile")
|
||||||
|
policy = _profile_policy(profile, cell_size_m)
|
||||||
|
profile_sha256 = _sha256(profile_file)
|
||||||
|
source_manifest_sha256 = _sha256(source / "manifest.json")
|
||||||
|
|
||||||
|
try:
|
||||||
|
centers = np.load(
|
||||||
|
source / "costmap-cell-centers-xy-m.npy", mmap_mode="r", allow_pickle=False
|
||||||
|
)
|
||||||
|
states = np.load(source / "costmap-states.npy", mmap_mode="r", allow_pickle=False)
|
||||||
|
z_bounds = np.load(
|
||||||
|
source / "costmap-z-bounds-m.npy", mmap_mode="r", allow_pickle=False
|
||||||
|
)
|
||||||
|
except (OSError, ValueError) as error:
|
||||||
|
raise M49PhysicalSafetyPlaybackError("source numeric tracks are invalid") from error
|
||||||
|
frames = _frame_rows(source / "frames.ndjson")
|
||||||
|
if (
|
||||||
|
centers.dtype != np.dtype("<f4")
|
||||||
|
or states.dtype != np.dtype("uint8")
|
||||||
|
or z_bounds.dtype != np.dtype("<f4")
|
||||||
|
or centers.ndim != 2
|
||||||
|
or centers.shape[1] != 2
|
||||||
|
or states.ndim != 2
|
||||||
|
or z_bounds.ndim != 3
|
||||||
|
or z_bounds.shape[2] != 2
|
||||||
|
or states.shape[0] != len(frames)
|
||||||
|
or states.shape[1] != centers.shape[0]
|
||||||
|
or z_bounds.shape != (states.shape[0], states.shape[1], 2)
|
||||||
|
or not np.isfinite(centers).all()
|
||||||
|
):
|
||||||
|
raise M49PhysicalSafetyPlaybackError("source numeric shape or dtype changed")
|
||||||
|
frame_count, cell_count = states.shape
|
||||||
|
admitted_codes = np.asarray(sorted(set(state_codes.values())), dtype=np.uint8)
|
||||||
|
|
||||||
|
identity = {
|
||||||
|
"schema_version": SCHEMA,
|
||||||
|
"source_result_id": source_result_id,
|
||||||
|
"source_manifest_sha256": source_manifest_sha256,
|
||||||
|
"source_track_sha256": {
|
||||||
|
name: source_artifacts[name]["sha256"] for name in SOURCE_TRACKS
|
||||||
|
},
|
||||||
|
"profile_id": profile.get("profile_id"),
|
||||||
|
"profile_sha256": profile_sha256,
|
||||||
|
"coordinate_frame": coordinate_frame,
|
||||||
|
"frame_count": frame_count,
|
||||||
|
"cell_count": cell_count,
|
||||||
|
"cell_size_m": cell_size_m,
|
||||||
|
"radius_m": radius_m,
|
||||||
|
"state_codes": state_codes,
|
||||||
|
"playback_policy": policy,
|
||||||
|
"authority": {
|
||||||
|
"commands_enabled": False,
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
"actuation_accepted": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
identity_sha256 = _canonical_sha256(identity)
|
||||||
|
result_id = f"{PREFIX}{identity_sha256}"
|
||||||
|
target = destination / result_id
|
||||||
|
if target.exists():
|
||||||
|
existing = read_m49_physical_safety_playback(target)
|
||||||
|
existing_playback = existing.manifest["playback"]
|
||||||
|
assert isinstance(existing_playback, dict)
|
||||||
|
existing_chunks = existing_playback["chunks"]
|
||||||
|
assert isinstance(existing_chunks, list)
|
||||||
|
for value in (
|
||||||
|
existing_playback["centers"],
|
||||||
|
existing_playback["frames"],
|
||||||
|
*existing_chunks,
|
||||||
|
):
|
||||||
|
assert isinstance(value, dict)
|
||||||
|
verify_m49_physical_safety_artifact(existing, value)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
created = created_at_utc or datetime.now(tz=UTC).isoformat().replace("+00:00", "Z")
|
||||||
|
chunk_frame_count = int(policy["chunk_frame_count"])
|
||||||
|
with tempfile.TemporaryDirectory(
|
||||||
|
prefix="mission-core-m49-physical-safety-", dir=destination
|
||||||
|
) as raw_staging:
|
||||||
|
staging = Path(raw_staging) / result_id
|
||||||
|
staging.mkdir()
|
||||||
|
centers_path = staging / "centers.f32"
|
||||||
|
np.ascontiguousarray(centers, dtype="<f4").tofile(centers_path)
|
||||||
|
frames_path = staging / "frames.ndjson"
|
||||||
|
shutil.copyfile(source / "frames.ndjson", frames_path)
|
||||||
|
centers_artifact = _artifact(
|
||||||
|
centers_path,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
role="costmap-cell-centers-f32le",
|
||||||
|
)
|
||||||
|
frames_artifact = _artifact(
|
||||||
|
frames_path,
|
||||||
|
media_type="application/x-ndjson",
|
||||||
|
role="frame-catalog",
|
||||||
|
)
|
||||||
|
chunks: list[dict[str, object]] = []
|
||||||
|
for index, start in enumerate(range(0, frame_count, chunk_frame_count)):
|
||||||
|
count = min(chunk_frame_count, frame_count - start)
|
||||||
|
state_slice = states[start : start + count]
|
||||||
|
if not np.isin(state_slice, admitted_codes).all():
|
||||||
|
raise M49PhysicalSafetyPlaybackError(
|
||||||
|
f"source state code changed in chunk {index}"
|
||||||
|
)
|
||||||
|
for local_index, frame in enumerate(frames[start : start + count]):
|
||||||
|
if frame["sample_available"] is False and (
|
||||||
|
np.any(state_slice[local_index] != 0)
|
||||||
|
or np.isfinite(z_bounds[start + local_index]).any()
|
||||||
|
):
|
||||||
|
raise M49PhysicalSafetyPlaybackError(
|
||||||
|
"missing source sample did not remain fail-closed"
|
||||||
|
)
|
||||||
|
chunk_path = staging / f"chunk-{index:06d}.bin"
|
||||||
|
_chunk_payload(
|
||||||
|
chunk_path,
|
||||||
|
start=start,
|
||||||
|
states=state_slice,
|
||||||
|
z_bounds=z_bounds[start : start + count],
|
||||||
|
)
|
||||||
|
descriptor = _artifact(
|
||||||
|
chunk_path,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
role="states-u8-and-z-bounds-f32le",
|
||||||
|
)
|
||||||
|
descriptor.update(
|
||||||
|
{
|
||||||
|
"index": index,
|
||||||
|
"start": start,
|
||||||
|
"count": count,
|
||||||
|
"header_bytes": CHUNK_HEADER.size,
|
||||||
|
"format": "mcpsch01-states-u8-aligned-z-bounds-f32le",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
chunks.append(descriptor)
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"schema_version": SCHEMA,
|
||||||
|
"result_id": result_id,
|
||||||
|
"created_at_utc": created,
|
||||||
|
"identity_sha256": identity_sha256,
|
||||||
|
"identity": identity,
|
||||||
|
"execution": {
|
||||||
|
"execution_class": "local-sequential-offline",
|
||||||
|
"worker_role": "realtime-only",
|
||||||
|
"worker_runtime_dependency": False,
|
||||||
|
"worker_requests_required": 0,
|
||||||
|
},
|
||||||
|
"playback": {
|
||||||
|
"coordinate_frame": coordinate_frame,
|
||||||
|
"source_pace_hz": profile["operator_visual"]["source_pace_hz"],
|
||||||
|
"frame_count": frame_count,
|
||||||
|
"cell_count": cell_count,
|
||||||
|
"cell_size_m": cell_size_m,
|
||||||
|
"radius_m": radius_m,
|
||||||
|
"state_codes": state_codes,
|
||||||
|
**policy,
|
||||||
|
"centers": {
|
||||||
|
**centers_artifact,
|
||||||
|
"dtype": "<f4",
|
||||||
|
"shape": [cell_count, 2],
|
||||||
|
},
|
||||||
|
"frames": {
|
||||||
|
**frames_artifact,
|
||||||
|
"dtype": "ndjson",
|
||||||
|
"shape": [frame_count],
|
||||||
|
},
|
||||||
|
"chunks": chunks,
|
||||||
|
},
|
||||||
|
"authority": identity["authority"],
|
||||||
|
}
|
||||||
|
(staging / "manifest.json").write_text(
|
||||||
|
json.dumps(manifest, indent=2, sort_keys=True, allow_nan=False) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
staging.replace(target)
|
||||||
|
return read_m49_physical_safety_playback(target)
|
||||||
|
|
||||||
|
|
||||||
|
def read_m49_physical_safety_playback(root: Path) -> M49PhysicalSafetyPlayback:
|
||||||
|
raw_candidate = root.expanduser().absolute()
|
||||||
|
if raw_candidate.is_symlink() or not raw_candidate.is_dir():
|
||||||
|
raise M49PhysicalSafetyPlaybackError("physical-safety playback root is invalid")
|
||||||
|
candidate = raw_candidate.resolve(strict=True)
|
||||||
|
manifest = _json(candidate / "manifest.json", "physical-safety playback manifest")
|
||||||
|
identity = manifest.get("identity")
|
||||||
|
execution = manifest.get("execution")
|
||||||
|
if (
|
||||||
|
manifest.get("schema_version") != SCHEMA
|
||||||
|
or not isinstance(identity, dict)
|
||||||
|
or not isinstance(execution, dict)
|
||||||
|
or manifest.get("identity_sha256") != _canonical_sha256(identity)
|
||||||
|
or manifest.get("result_id") != candidate.name
|
||||||
|
or candidate.name != f"{PREFIX}{manifest.get('identity_sha256')}"
|
||||||
|
or execution.get("worker_requests_required") != 0
|
||||||
|
or execution.get("worker_runtime_dependency") is not False
|
||||||
|
):
|
||||||
|
raise M49PhysicalSafetyPlaybackError("physical-safety playback identity changed")
|
||||||
|
playback = manifest.get("playback")
|
||||||
|
if not isinstance(playback, dict) or not isinstance(playback.get("chunks"), list):
|
||||||
|
raise M49PhysicalSafetyPlaybackError("physical-safety playback catalog changed")
|
||||||
|
descriptors = [playback.get("centers"), playback.get("frames"), *playback["chunks"]]
|
||||||
|
seen: set[str] = set()
|
||||||
|
for descriptor in descriptors:
|
||||||
|
if not isinstance(descriptor, dict) or not isinstance(descriptor.get("path"), str):
|
||||||
|
raise M49PhysicalSafetyPlaybackError("physical-safety artifact entry changed")
|
||||||
|
name = descriptor["path"]
|
||||||
|
path = candidate / name
|
||||||
|
if (
|
||||||
|
name in seen
|
||||||
|
or path.parent != candidate
|
||||||
|
or path.is_symlink()
|
||||||
|
or not path.is_file()
|
||||||
|
or descriptor.get("byte_length") != path.stat().st_size
|
||||||
|
or not isinstance(descriptor.get("sha256"), str)
|
||||||
|
or len(descriptor["sha256"]) != 64
|
||||||
|
or any(character not in "0123456789abcdef" for character in descriptor["sha256"])
|
||||||
|
):
|
||||||
|
raise M49PhysicalSafetyPlaybackError("physical-safety artifact changed")
|
||||||
|
seen.add(name)
|
||||||
|
return M49PhysicalSafetyPlayback(candidate.name, candidate, manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_m49_physical_safety_artifact(
|
||||||
|
result: M49PhysicalSafetyPlayback,
|
||||||
|
descriptor: dict[str, Any],
|
||||||
|
) -> Path:
|
||||||
|
name = descriptor.get("path")
|
||||||
|
if not isinstance(name, str):
|
||||||
|
raise M49PhysicalSafetyPlaybackError("physical-safety artifact path changed")
|
||||||
|
path = result.root / name
|
||||||
|
if (
|
||||||
|
path.parent != result.root
|
||||||
|
or path.is_symlink()
|
||||||
|
or not path.is_file()
|
||||||
|
or descriptor.get("byte_length") != path.stat().st_size
|
||||||
|
or descriptor.get("sha256") != _sha256(path)
|
||||||
|
):
|
||||||
|
raise M49PhysicalSafetyPlaybackError("physical-safety artifact digest changed")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CHUNK_HEADER",
|
||||||
|
"CHUNK_MAGIC",
|
||||||
|
"M49PhysicalSafetyPlayback",
|
||||||
|
"M49PhysicalSafetyPlaybackError",
|
||||||
|
"PREFIX",
|
||||||
|
"SCHEMA",
|
||||||
|
"read_m49_physical_safety_playback",
|
||||||
|
"seal_m49_physical_safety_playback",
|
||||||
|
"verify_m49_physical_safety_artifact",
|
||||||
|
]
|
||||||
@@ -452,6 +452,32 @@ def build_threat_replay(
|
|||||||
|
|
||||||
|
|
||||||
def read_threat_replay_result(root: Path) -> ThreatReplayResult:
|
def read_threat_replay_result(root: Path) -> ThreatReplayResult:
|
||||||
|
"""Read and deeply verify a sealed threat replay result.
|
||||||
|
|
||||||
|
This is the acceptance/build seam. It deliberately walks every ledger row
|
||||||
|
before returning and therefore must not sit on the synchronous LAB open
|
||||||
|
path for a hundreds-of-megabytes recorded result.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return _read_threat_replay_result(root, validate_ledgers=True)
|
||||||
|
|
||||||
|
|
||||||
|
def read_threat_replay_result_metadata(root: Path) -> ThreatReplayResult:
|
||||||
|
"""Read verified result metadata without eagerly parsing the full ledger.
|
||||||
|
|
||||||
|
Artifact bytes are still digest checked against the sealed manifest. The
|
||||||
|
bounded timeline reader validates every requested row before projection;
|
||||||
|
only the redundant all-4489-row JSON accounting pass is deferred.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return _read_threat_replay_result(root, validate_ledgers=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_threat_replay_result(
|
||||||
|
root: Path,
|
||||||
|
*,
|
||||||
|
validate_ledgers: bool,
|
||||||
|
) -> ThreatReplayResult:
|
||||||
resolved = root.resolve(strict=True)
|
resolved = root.resolve(strict=True)
|
||||||
if resolved.is_symlink() or not resolved.name.startswith(THREAT_REPLAY_RESULT_PREFIX):
|
if resolved.is_symlink() or not resolved.name.startswith(THREAT_REPLAY_RESULT_PREFIX):
|
||||||
raise ThreatReplayError("threat replay result root is invalid")
|
raise ThreatReplayError("threat replay result root is invalid")
|
||||||
@@ -498,7 +524,8 @@ def read_threat_replay_result(root: Path) -> ThreatReplayResult:
|
|||||||
for role, (name, identity_key) in expected.items():
|
for role, (name, identity_key) in expected.items():
|
||||||
path = _validated_artifact(resolved, by_role[role], name)
|
path = _validated_artifact(resolved, by_role[role], name)
|
||||||
paths[role] = path
|
paths[role] = path
|
||||||
if identity_key is not None and _file_sha256(path) != identity.get(identity_key):
|
artifact = _object(by_role[role], "threat artifact")
|
||||||
|
if identity_key is not None and artifact.get("sha256") != identity.get(identity_key):
|
||||||
raise ThreatReplayError("threat artifact identity changed")
|
raise ThreatReplayError("threat artifact identity changed")
|
||||||
report = _read_json(paths["threat-replay-report"])
|
report = _read_json(paths["threat-replay-report"])
|
||||||
metrics = _object(identity.get("metrics"), "threat metrics")
|
metrics = _object(identity.get("metrics"), "threat metrics")
|
||||||
@@ -522,12 +549,13 @@ def read_threat_replay_result(root: Path) -> ThreatReplayResult:
|
|||||||
or requirements != expected_requirements
|
or requirements != expected_requirements
|
||||||
):
|
):
|
||||||
raise ThreatReplayError("threat replay report or acceptance changed")
|
raise ThreatReplayError("threat replay report or acceptance changed")
|
||||||
_validate_ledgers(
|
if validate_ledgers:
|
||||||
paths["threat-replay-frames"],
|
_validate_ledgers(
|
||||||
paths["threat-visual-frames"],
|
paths["threat-replay-frames"],
|
||||||
metrics,
|
paths["threat-visual-frames"],
|
||||||
is_v2=is_v2,
|
metrics,
|
||||||
)
|
is_v2=is_v2,
|
||||||
|
)
|
||||||
return ThreatReplayResult(
|
return ThreatReplayResult(
|
||||||
result_id=resolved.name,
|
result_id=resolved.name,
|
||||||
result_root=resolved,
|
result_root=resolved,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from pathlib import Path
|
|||||||
from threading import RLock
|
from threading import RLock
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
from .geometry import RecordedGeometryStore
|
from .geometry import RecordedGeometryStore
|
||||||
from .recorded_source import RECORDED_REPRESENTATION_ID
|
from .recorded_source import RECORDED_REPRESENTATION_ID
|
||||||
from .spatial_evidence import (
|
from .spatial_evidence import (
|
||||||
@@ -78,24 +80,56 @@ class RecordedThreatTimeline:
|
|||||||
}
|
}
|
||||||
if any(identity.get(key) != value for key, value in expected_identity.items()):
|
if any(identity.get(key) != value for key, value in expected_identity.items()):
|
||||||
raise RecordedThreatTimelineError("recorded timeline escaped the threat profile")
|
raise RecordedThreatTimelineError("recorded timeline escaped the threat profile")
|
||||||
self.store = RecordedGeometryStore.from_repository(self.repository_root)
|
self._maximum_source_points_per_frame = _read_source_point_bound(
|
||||||
if (
|
self.repository_root,
|
||||||
self.store.profile.source_pack_id != self.profile.source_pack_id
|
source_pack_id=self.profile.source_pack_id,
|
||||||
or self.store.profile.source_pack_sha256 != self.profile.source_pack_sha256
|
source_pack_sha256=self.profile.source_pack_sha256,
|
||||||
or self.store.profile.frame_count != _EXPECTED_FRAME_COUNT
|
|
||||||
):
|
|
||||||
raise RecordedThreatTimelineError("recorded timeline geometry identity changed")
|
|
||||||
if self.store.maximum_current_point_count > RECORDED_SPATIAL_POINT_LIMIT:
|
|
||||||
raise RecordedThreatTimelineError(
|
|
||||||
"recorded timeline exact point delivery exceeds its declared bound"
|
|
||||||
)
|
|
||||||
self.body_frames = RecordedReplayBodyFrameResolver(
|
|
||||||
self.store,
|
|
||||||
profile=self.profile.body_frame,
|
|
||||||
)
|
)
|
||||||
|
self._store: RecordedGeometryStore | None = None
|
||||||
|
self._body_frames: RecordedReplayBodyFrameResolver | None = None
|
||||||
self.index = _index_frame_ledger(self.frames_path)
|
self.index = _index_frame_ledger(self.frames_path)
|
||||||
self._lock = RLock()
|
self._lock = RLock()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def store(self) -> RecordedGeometryStore:
|
||||||
|
"""Load and deeply verify the large geometry archives on first use.
|
||||||
|
|
||||||
|
Timeline metadata, camera playback and the independent TGS artifact can
|
||||||
|
become visible without waiting for the source cloud archive. Any
|
||||||
|
endpoint that actually delivers source points still crosses the full
|
||||||
|
digest and shape validation in ``RecordedGeometryStore``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
if self._store is None:
|
||||||
|
store = RecordedGeometryStore.from_repository(self.repository_root)
|
||||||
|
if (
|
||||||
|
store.profile.source_pack_id != self.profile.source_pack_id
|
||||||
|
or store.profile.source_pack_sha256 != self.profile.source_pack_sha256
|
||||||
|
or store.profile.frame_count != _EXPECTED_FRAME_COUNT
|
||||||
|
or store.maximum_current_point_count
|
||||||
|
!= self._maximum_source_points_per_frame
|
||||||
|
):
|
||||||
|
raise RecordedThreatTimelineError(
|
||||||
|
"recorded timeline geometry identity changed"
|
||||||
|
)
|
||||||
|
if store.maximum_current_point_count > RECORDED_SPATIAL_POINT_LIMIT:
|
||||||
|
raise RecordedThreatTimelineError(
|
||||||
|
"recorded timeline exact point delivery exceeds its declared bound"
|
||||||
|
)
|
||||||
|
self._store = store
|
||||||
|
return self._store
|
||||||
|
|
||||||
|
@property
|
||||||
|
def body_frames(self) -> RecordedReplayBodyFrameResolver:
|
||||||
|
with self._lock:
|
||||||
|
if self._body_frames is None:
|
||||||
|
self._body_frames = RecordedReplayBodyFrameResolver(
|
||||||
|
self.store,
|
||||||
|
profile=self.profile.body_frame,
|
||||||
|
)
|
||||||
|
return self._body_frames
|
||||||
|
|
||||||
def metadata(self) -> dict[str, object]:
|
def metadata(self) -> dict[str, object]:
|
||||||
times = self.index.source_times_ns
|
times = self.index.source_times_ns
|
||||||
intervals = [(current - previous) / 1_000_000_000 for previous, current in pairwise(times)]
|
intervals = [(current - previous) / 1_000_000_000 for previous, current in pairwise(times)]
|
||||||
@@ -120,7 +154,7 @@ class RecordedThreatTimeline:
|
|||||||
"max_chunk_frames": RECORDED_SPATIAL_MAX_CHUNK_FRAMES,
|
"max_chunk_frames": RECORDED_SPATIAL_MAX_CHUNK_FRAMES,
|
||||||
"point_sample_limit": RECORDED_SPATIAL_POINT_LIMIT,
|
"point_sample_limit": RECORDED_SPATIAL_POINT_LIMIT,
|
||||||
"point_delivery": "exact-current-increment",
|
"point_delivery": "exact-current-increment",
|
||||||
"maximum_source_points_per_frame": self.store.maximum_current_point_count,
|
"maximum_source_points_per_frame": self._maximum_source_points_per_frame,
|
||||||
"local_surface_visualization": {
|
"local_surface_visualization": {
|
||||||
"derivation": "bounded-registered-increment-accumulation",
|
"derivation": "bounded-registered-increment-accumulation",
|
||||||
"window_seconds": RECORDED_LOCAL_SURFACE_WINDOW_SECONDS,
|
"window_seconds": RECORDED_LOCAL_SURFACE_WINDOW_SECONDS,
|
||||||
@@ -258,6 +292,82 @@ class RecordedThreatTimeline:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_source_point_bound(
|
||||||
|
repository_root: Path,
|
||||||
|
*,
|
||||||
|
source_pack_id: str,
|
||||||
|
source_pack_sha256: str,
|
||||||
|
) -> int:
|
||||||
|
"""Read the small offsets member without inflating the 70 MiB source pack.
|
||||||
|
|
||||||
|
This is metadata only. The exact archive digest, every array shape and the
|
||||||
|
local-surface binding are still verified lazily by ``RecordedGeometryStore``
|
||||||
|
before any source point is delivered.
|
||||||
|
"""
|
||||||
|
|
||||||
|
base = (
|
||||||
|
repository_root / ".runtime/compute-experiments/e10/lidar-packs"
|
||||||
|
).resolve(strict=True)
|
||||||
|
pack_root = (base / source_pack_id).resolve(strict=True)
|
||||||
|
manifest_path = (pack_root / "manifest.json").resolve(strict=True)
|
||||||
|
pack_path = (pack_root / "lidar-pack.npz").resolve(strict=True)
|
||||||
|
if (
|
||||||
|
pack_root.parent != base
|
||||||
|
or pack_root.is_symlink()
|
||||||
|
or manifest_path.parent != pack_root
|
||||||
|
or manifest_path.is_symlink()
|
||||||
|
or pack_path.parent != pack_root
|
||||||
|
or pack_path.is_symlink()
|
||||||
|
or not pack_path.is_file()
|
||||||
|
):
|
||||||
|
raise RecordedThreatTimelineError("recorded timeline source pack path changed")
|
||||||
|
try:
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(manifest, dict):
|
||||||
|
raise RecordedThreatTimelineError(
|
||||||
|
"recorded timeline source pack manifest is invalid"
|
||||||
|
)
|
||||||
|
identity = manifest.get("identity")
|
||||||
|
artifact = manifest.get("artifact")
|
||||||
|
if not isinstance(identity, dict) or not isinstance(artifact, dict):
|
||||||
|
raise RecordedThreatTimelineError(
|
||||||
|
"recorded timeline source pack manifest is invalid"
|
||||||
|
)
|
||||||
|
point_count = identity.get("point_count")
|
||||||
|
if (
|
||||||
|
manifest.get("pack_id") != source_pack_id
|
||||||
|
or identity.get("frame_count") != _EXPECTED_FRAME_COUNT
|
||||||
|
or not isinstance(point_count, int)
|
||||||
|
or isinstance(point_count, bool)
|
||||||
|
or point_count < 0
|
||||||
|
or artifact.get("path") != "lidar-pack.npz"
|
||||||
|
or artifact.get("sha256") != source_pack_sha256
|
||||||
|
or artifact.get("byte_length") != pack_path.stat().st_size
|
||||||
|
):
|
||||||
|
raise RecordedThreatTimelineError(
|
||||||
|
"recorded timeline source pack identity changed"
|
||||||
|
)
|
||||||
|
with np.load(pack_path, allow_pickle=False) as archive:
|
||||||
|
offsets = np.asarray(archive["cloud_offsets"], dtype=np.int64)
|
||||||
|
except (KeyError, OSError, ValueError, json.JSONDecodeError) as error:
|
||||||
|
raise RecordedThreatTimelineError(
|
||||||
|
"recorded timeline source point offsets are unavailable"
|
||||||
|
) from error
|
||||||
|
if (
|
||||||
|
offsets.shape != (_EXPECTED_FRAME_COUNT + 1,)
|
||||||
|
or int(offsets[0]) != 0
|
||||||
|
or int(offsets[-1]) != point_count
|
||||||
|
or np.any(np.diff(offsets) < 0)
|
||||||
|
):
|
||||||
|
raise RecordedThreatTimelineError("recorded timeline source point offsets changed")
|
||||||
|
maximum = int(np.diff(offsets).max(initial=0))
|
||||||
|
if maximum > RECORDED_SPATIAL_POINT_LIMIT:
|
||||||
|
raise RecordedThreatTimelineError(
|
||||||
|
"recorded timeline exact point delivery exceeds its declared bound"
|
||||||
|
)
|
||||||
|
return maximum
|
||||||
|
|
||||||
|
|
||||||
def _index_frame_ledger(path: Path) -> RecordedThreatTimelineIndex:
|
def _index_frame_ledger(path: Path) -> RecordedThreatTimelineIndex:
|
||||||
offsets: list[int] = []
|
offsets: list[int] = []
|
||||||
source_times: list[int] = []
|
source_times: list[int] = []
|
||||||
|
|||||||
@@ -133,6 +133,9 @@ from k1link.web.m48s_fixed_class_detector_lab_api import (
|
|||||||
build_m48s_fixed_class_detector_lab_router,
|
build_m48s_fixed_class_detector_lab_router,
|
||||||
)
|
)
|
||||||
from k1link.web.m48t_risk_quality_lab_api import build_m48t_risk_quality_lab_router
|
from k1link.web.m48t_risk_quality_lab_api import build_m48t_risk_quality_lab_router
|
||||||
|
from k1link.web.m49_physical_safety_playback_api import (
|
||||||
|
build_m49_physical_safety_playback_router,
|
||||||
|
)
|
||||||
from k1link.web.m49_tgs_fail_closed_api import build_m49_tgs_fail_closed_router
|
from k1link.web.m49_tgs_fail_closed_api import build_m49_tgs_fail_closed_router
|
||||||
from k1link.web.m49_tgs_full_shadow_api import build_m49_tgs_full_shadow_router
|
from k1link.web.m49_tgs_full_shadow_api import build_m49_tgs_full_shadow_router
|
||||||
from k1link.web.map_api import (
|
from k1link.web.map_api import (
|
||||||
@@ -1016,6 +1019,17 @@ app.include_router(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
app.include_router(
|
||||||
|
build_m49_physical_safety_playback_router(
|
||||||
|
root_provider=lambda: (
|
||||||
|
REPOSITORY_ROOT
|
||||||
|
/ ".runtime"
|
||||||
|
/ "compute-experiments"
|
||||||
|
/ "m49"
|
||||||
|
/ "physical-safety-playback-results"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
app.include_router(
|
app.include_router(
|
||||||
build_m48s_fixed_class_detector_lab_router(
|
build_m48s_fixed_class_detector_lab_router(
|
||||||
root_provider=lambda: (
|
root_provider=lambda: (
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"""Read-only local API for autonomous M49 physical-safety playback artifacts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import re
|
||||||
|
from collections.abc import Callable
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Final
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from k1link.laboratory.m49_physical_safety_playback import (
|
||||||
|
PREFIX,
|
||||||
|
M49PhysicalSafetyPlayback,
|
||||||
|
M49PhysicalSafetyPlaybackError,
|
||||||
|
read_m49_physical_safety_playback,
|
||||||
|
verify_m49_physical_safety_artifact,
|
||||||
|
)
|
||||||
|
|
||||||
|
RootProvider = Callable[[], Path | None]
|
||||||
|
RESULT_ID: Final = re.compile(rf"^{re.escape(PREFIX)}[a-f0-9]{{64}}$")
|
||||||
|
SOURCE_RESULT_ID: Final = re.compile(r"^m49-tgs-full-shadow-[a-f0-9]{64}$")
|
||||||
|
ENDPOINT_ROOT: Final = "/api/v1/laboratory/m49/physical-safety-playback"
|
||||||
|
|
||||||
|
|
||||||
|
def build_m49_physical_safety_playback_router(
|
||||||
|
*, root_provider: RootProvider = lambda: None
|
||||||
|
) -> APIRouter:
|
||||||
|
router = APIRouter(prefix=ENDPOINT_ROOT, tags=["laboratory"])
|
||||||
|
|
||||||
|
def sealed(result_id: str) -> M49PhysicalSafetyPlayback:
|
||||||
|
root = _configured_root(root_provider)
|
||||||
|
if root is None or RESULT_ID.fullmatch(result_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="M49 physical-safety playback not found")
|
||||||
|
candidate = root / result_id
|
||||||
|
if candidate.is_symlink() or not candidate.is_dir():
|
||||||
|
raise HTTPException(status_code=404, detail="M49 physical-safety playback not found")
|
||||||
|
try:
|
||||||
|
resolved = candidate.resolve(strict=True)
|
||||||
|
if resolved.parent != root:
|
||||||
|
raise ValueError("result escaped configured root")
|
||||||
|
return _read_cached(str(resolved), _signature(resolved))
|
||||||
|
except (M49PhysicalSafetyPlaybackError, OSError, ValueError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail="M49 physical-safety playback not found"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
@router.get("/results")
|
||||||
|
def list_results(
|
||||||
|
limit: int = Query(default=10, ge=1, le=25),
|
||||||
|
source_result_id: str | None = Query(default=None),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if source_result_id is not None and SOURCE_RESULT_ID.fullmatch(source_result_id) is None:
|
||||||
|
raise HTTPException(status_code=422, detail="M49 source result identity is invalid")
|
||||||
|
root = _configured_root(root_provider)
|
||||||
|
if root is None:
|
||||||
|
return _catalog([], configured=False, invalid_total=0)
|
||||||
|
items: list[dict[str, object]] = []
|
||||||
|
invalid = 0
|
||||||
|
for candidate in sorted(root.iterdir()):
|
||||||
|
if candidate.is_symlink() or not candidate.is_dir() or not RESULT_ID.fullmatch(
|
||||||
|
candidate.name
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
summary = _summary(
|
||||||
|
_read_cached(str(candidate.resolve()), _signature(candidate))
|
||||||
|
)
|
||||||
|
if source_result_id is None or summary["source_result_id"] == source_result_id:
|
||||||
|
items.append(summary)
|
||||||
|
except (M49PhysicalSafetyPlaybackError, OSError, ValueError):
|
||||||
|
invalid += 1
|
||||||
|
items.sort(
|
||||||
|
key=lambda value: (str(value["created_at_utc"]), str(value["result_id"])),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
return _catalog(items[:limit], configured=True, invalid_total=invalid)
|
||||||
|
|
||||||
|
@router.get("/{result_id}/manifest")
|
||||||
|
def get_manifest(result_id: str) -> dict[str, object]:
|
||||||
|
return _project_manifest(sealed(result_id))
|
||||||
|
|
||||||
|
@router.get("/{result_id}/tracks/{track_id}")
|
||||||
|
def get_track(result_id: str, track_id: str) -> FileResponse:
|
||||||
|
result = sealed(result_id)
|
||||||
|
playback = _playback(result)
|
||||||
|
if track_id == "centers":
|
||||||
|
descriptor = _descriptor(playback.get("centers"), "centers")
|
||||||
|
elif track_id == "frames":
|
||||||
|
descriptor = _descriptor(playback.get("frames"), "frames")
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=404, detail="M49 physical-safety track not found")
|
||||||
|
return _file_response(result, descriptor)
|
||||||
|
|
||||||
|
@router.get("/{result_id}/chunks/{chunk_index}")
|
||||||
|
def get_chunk(result_id: str, chunk_index: int) -> FileResponse:
|
||||||
|
result = sealed(result_id)
|
||||||
|
chunks = _playback(result).get("chunks")
|
||||||
|
if not isinstance(chunks, list) or chunk_index < 0 or chunk_index >= len(chunks):
|
||||||
|
raise HTTPException(status_code=404, detail="M49 physical-safety chunk not found")
|
||||||
|
descriptor = _descriptor(chunks[chunk_index], f"chunk {chunk_index}")
|
||||||
|
if descriptor.get("index") != chunk_index:
|
||||||
|
raise HTTPException(status_code=503, detail="M49 physical-safety chunk catalog changed")
|
||||||
|
return _file_response(result, descriptor)
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=8)
|
||||||
|
def _read_cached(root: str, signature: tuple[int, ...]) -> M49PhysicalSafetyPlayback:
|
||||||
|
del signature
|
||||||
|
return read_m49_physical_safety_playback(Path(root))
|
||||||
|
|
||||||
|
|
||||||
|
def _file_response(
|
||||||
|
result: M49PhysicalSafetyPlayback,
|
||||||
|
descriptor: dict[str, Any],
|
||||||
|
) -> FileResponse:
|
||||||
|
try:
|
||||||
|
path = verify_m49_physical_safety_artifact(result, descriptor)
|
||||||
|
except (KeyError, OSError, TypeError, ValueError, M49PhysicalSafetyPlaybackError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="M49 physical-safety playback artifact failed verification",
|
||||||
|
) from None
|
||||||
|
return FileResponse(
|
||||||
|
path,
|
||||||
|
media_type=str(descriptor.get("media_type") or "application/octet-stream"),
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "private, max-age=31536000, immutable",
|
||||||
|
"Content-Encoding": "identity",
|
||||||
|
"ETag": f'"{descriptor["sha256"]}"',
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"X-Mission-Core-Worker-Dependency": "none",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _playback(result: M49PhysicalSafetyPlayback) -> dict[str, Any]:
|
||||||
|
playback = result.manifest.get("playback")
|
||||||
|
if not isinstance(playback, dict):
|
||||||
|
raise HTTPException(status_code=503, detail="M49 physical-safety playback changed")
|
||||||
|
return playback
|
||||||
|
|
||||||
|
|
||||||
|
def _descriptor(value: object, label: str) -> dict[str, Any]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise HTTPException(status_code=503, detail=f"M49 physical-safety {label} changed")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _project_manifest(result: M49PhysicalSafetyPlayback) -> dict[str, object]:
|
||||||
|
payload = copy.deepcopy(result.manifest)
|
||||||
|
playback = payload["playback"]
|
||||||
|
assert isinstance(playback, dict)
|
||||||
|
centers = playback["centers"]
|
||||||
|
frames = playback["frames"]
|
||||||
|
chunks = playback["chunks"]
|
||||||
|
assert isinstance(centers, dict) and isinstance(frames, dict) and isinstance(chunks, list)
|
||||||
|
centers["url"] = f"{ENDPOINT_ROOT}/{result.result_id}/tracks/centers"
|
||||||
|
frames["url"] = f"{ENDPOINT_ROOT}/{result.result_id}/tracks/frames"
|
||||||
|
for index, value in enumerate(chunks):
|
||||||
|
assert isinstance(value, dict)
|
||||||
|
value["url"] = f"{ENDPOINT_ROOT}/{result.result_id}/chunks/{index}"
|
||||||
|
payload["access"] = "read-only-sealed-local"
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _summary(result: M49PhysicalSafetyPlayback) -> dict[str, object]:
|
||||||
|
playback = _playback(result)
|
||||||
|
return {
|
||||||
|
"schema_version": "missioncore.m49-physical-safety-playback-summary/v1",
|
||||||
|
"result_id": result.result_id,
|
||||||
|
"created_at_utc": result.manifest.get("created_at_utc"),
|
||||||
|
"source_result_id": result.manifest["identity"]["source_result_id"],
|
||||||
|
"frame_count": playback.get("frame_count"),
|
||||||
|
"cell_count": playback.get("cell_count"),
|
||||||
|
"cell_size_m": playback.get("cell_size_m"),
|
||||||
|
"radius_m": playback.get("radius_m"),
|
||||||
|
"chunk_count": len(playback.get("chunks", [])),
|
||||||
|
"worker_runtime_dependency": False,
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog(
|
||||||
|
items: list[dict[str, object]], *, configured: bool, invalid_total: int
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"schema_version": "missioncore.m49-physical-safety-playback-catalog/v1",
|
||||||
|
"configured": configured,
|
||||||
|
"items": items,
|
||||||
|
"candidate_total": len(items) + invalid_total,
|
||||||
|
"invalid_total": invalid_total,
|
||||||
|
"worker_runtime_dependency": False,
|
||||||
|
"access": "read-only-sealed-local",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_root(provider: RootProvider) -> Path | None:
|
||||||
|
value = provider()
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
raw = value.expanduser().absolute()
|
||||||
|
if raw.is_symlink() or not raw.is_dir():
|
||||||
|
return None
|
||||||
|
return raw.resolve(strict=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _signature(root: Path) -> tuple[int, ...]:
|
||||||
|
path = root / "manifest.json"
|
||||||
|
if path.is_symlink() or not path.is_file():
|
||||||
|
raise ValueError("physical-safety manifest unavailable")
|
||||||
|
stat = path.stat()
|
||||||
|
return (stat.st_size, stat.st_mtime_ns)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["build_m49_physical_safety_playback_router"]
|
||||||
@@ -22,7 +22,7 @@ from k1link.perception.threat_replay import (
|
|||||||
THREAT_REPLAY_VISUAL_SCHEMA_V2,
|
THREAT_REPLAY_VISUAL_SCHEMA_V2,
|
||||||
ThreatReplayError,
|
ThreatReplayError,
|
||||||
ThreatReplayResult,
|
ThreatReplayResult,
|
||||||
read_threat_replay_result,
|
read_threat_replay_result_metadata,
|
||||||
)
|
)
|
||||||
from k1link.perception.threat_timeline import (
|
from k1link.perception.threat_timeline import (
|
||||||
RECORDED_SPATIAL_MAX_CHUNK_FRAMES,
|
RECORDED_SPATIAL_MAX_CHUNK_FRAMES,
|
||||||
@@ -34,6 +34,7 @@ from k1link.sessions import RecordedCameraFrame, SessionIntegrityError
|
|||||||
M4_THREAT_CATALOG_SCHEMA: Final = "missioncore.m4-threat-replay-catalog/v1"
|
M4_THREAT_CATALOG_SCHEMA: Final = "missioncore.m4-threat-replay-catalog/v1"
|
||||||
M4_THREAT_VIEW_SCHEMA: Final = "missioncore.m4-threat-replay-view/v1"
|
M4_THREAT_VIEW_SCHEMA: Final = "missioncore.m4-threat-replay-view/v1"
|
||||||
M4_THREAT_VISUAL_CATALOG_SCHEMA: Final = "missioncore.m4-threat-visual-catalog/v1"
|
M4_THREAT_VISUAL_CATALOG_SCHEMA: Final = "missioncore.m4-threat-visual-catalog/v1"
|
||||||
|
M4_THREAT_PLAYBACK_CHUNK_FRAMES: Final = 24
|
||||||
_RESULT_ID = re.compile(rf"^{THREAT_REPLAY_RESULT_PREFIX}[a-f0-9]{{64}}$")
|
_RESULT_ID = re.compile(rf"^{THREAT_REPLAY_RESULT_PREFIX}[a-f0-9]{{64}}$")
|
||||||
RootProvider = Callable[[], Path | None]
|
RootProvider = Callable[[], Path | None]
|
||||||
CameraFrameProvider = Callable[[str, int], RecordedCameraFrame]
|
CameraFrameProvider = Callable[[str, int], RecordedCameraFrame]
|
||||||
@@ -212,13 +213,19 @@ def build_m4_threat_replay_router(
|
|||||||
projected = timeline(result_id)
|
projected = timeline(result_id)
|
||||||
points = projected.store.playback_points_map()
|
points = projected.store.playback_points_map()
|
||||||
offsets = projected.store.playback_point_offsets()
|
offsets = projected.store.playback_point_offsets()
|
||||||
content_sha256 = hashlib.sha256(memoryview(points).cast("B")).hexdigest()
|
points_view = memoryview(points).cast("B")
|
||||||
|
content_sha256 = hashlib.sha256(points_view).hexdigest()
|
||||||
|
chunks = _playback_chunk_catalog(result_id, points_view, offsets)
|
||||||
return {
|
return {
|
||||||
"schema_version": "missioncore.recorded-spatial-playback/v1",
|
"schema_version": "missioncore.recorded-spatial-playback/v1",
|
||||||
"result_id": result_id,
|
"result_id": result_id,
|
||||||
"frame_count": len(offsets) - 1,
|
"frame_count": len(offsets) - 1,
|
||||||
"point_count": int(points.shape[0]),
|
"point_count": int(points.shape[0]),
|
||||||
"point_offsets": list(offsets),
|
"point_offsets": list(offsets),
|
||||||
|
"chunk_frame_count": M4_THREAT_PLAYBACK_CHUNK_FRAMES,
|
||||||
|
"resident_chunk_count_max": 4,
|
||||||
|
"forward_prefetch_chunk_count": 1,
|
||||||
|
"chunks": chunks,
|
||||||
"track": {
|
"track": {
|
||||||
"id": "points-map-f32",
|
"id": "points-map-f32",
|
||||||
"url": (
|
"url": (
|
||||||
@@ -238,6 +245,42 @@ def build_m4_threat_replay_router(
|
|||||||
"access": "read-only-sealed-binary-playback",
|
"access": "read-only-sealed-binary-playback",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/results/{result_id}/timeline/playback/chunks/{chunk_index}",
|
||||||
|
response_class=Response,
|
||||||
|
)
|
||||||
|
def get_timeline_playback_chunk(result_id: str, chunk_index: int) -> Response:
|
||||||
|
projected = timeline(result_id)
|
||||||
|
points = projected.store.playback_points_map()
|
||||||
|
offsets = projected.store.playback_point_offsets()
|
||||||
|
points_view = memoryview(points).cast("B")
|
||||||
|
descriptor = _playback_chunk_descriptor(
|
||||||
|
result_id,
|
||||||
|
points_view,
|
||||||
|
offsets,
|
||||||
|
chunk_index,
|
||||||
|
)
|
||||||
|
if descriptor is None:
|
||||||
|
raise HTTPException(status_code=404, detail="M4.6 playback chunk не найден")
|
||||||
|
point_start = descriptor["point_start"]
|
||||||
|
byte_length = descriptor["bytes"]
|
||||||
|
assert isinstance(point_start, int)
|
||||||
|
assert isinstance(byte_length, int)
|
||||||
|
byte_start = point_start * 3 * 4
|
||||||
|
byte_stop = byte_start + byte_length
|
||||||
|
return Response(
|
||||||
|
content=bytes(points_view[byte_start:byte_stop]),
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "private, max-age=31536000, immutable",
|
||||||
|
"Content-Encoding": "identity",
|
||||||
|
"Content-Length": str(byte_length),
|
||||||
|
"ETag": f'"{descriptor["sha256"]}"',
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"X-Uncompressed-Content-Length": str(byte_length),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/results/{result_id}/timeline/playback/tracks/points-map-f32",
|
"/results/{result_id}/timeline/playback/tracks/points-map-f32",
|
||||||
response_class=StreamingResponse,
|
response_class=StreamingResponse,
|
||||||
@@ -276,13 +319,71 @@ def _binary_chunks(view: memoryview, chunk_size: int = 1024 * 1024) -> Iterator[
|
|||||||
yield bytes(view[start : start + chunk_size])
|
yield bytes(view[start : start + chunk_size])
|
||||||
|
|
||||||
|
|
||||||
|
def _playback_chunk_catalog(
|
||||||
|
result_id: str,
|
||||||
|
points_view: memoryview,
|
||||||
|
offsets: tuple[int, ...],
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
frame_count = len(offsets) - 1
|
||||||
|
chunk_count = (
|
||||||
|
frame_count + M4_THREAT_PLAYBACK_CHUNK_FRAMES - 1
|
||||||
|
) // M4_THREAT_PLAYBACK_CHUNK_FRAMES
|
||||||
|
return [
|
||||||
|
descriptor
|
||||||
|
for chunk_index in range(chunk_count)
|
||||||
|
if (
|
||||||
|
descriptor := _playback_chunk_descriptor(
|
||||||
|
result_id,
|
||||||
|
points_view,
|
||||||
|
offsets,
|
||||||
|
chunk_index,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _playback_chunk_descriptor(
|
||||||
|
result_id: str,
|
||||||
|
points_view: memoryview,
|
||||||
|
offsets: tuple[int, ...],
|
||||||
|
chunk_index: int,
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
frame_count = len(offsets) - 1
|
||||||
|
start = chunk_index * M4_THREAT_PLAYBACK_CHUNK_FRAMES
|
||||||
|
if chunk_index < 0 or start >= frame_count:
|
||||||
|
return None
|
||||||
|
count = min(M4_THREAT_PLAYBACK_CHUNK_FRAMES, frame_count - start)
|
||||||
|
point_start = offsets[start]
|
||||||
|
point_stop = offsets[start + count]
|
||||||
|
byte_start = point_start * 3 * 4
|
||||||
|
byte_stop = point_stop * 3 * 4
|
||||||
|
payload = points_view[byte_start:byte_stop]
|
||||||
|
return {
|
||||||
|
"index": chunk_index,
|
||||||
|
"start": start,
|
||||||
|
"count": count,
|
||||||
|
"point_start": point_start,
|
||||||
|
"point_count": point_stop - point_start,
|
||||||
|
"url": (
|
||||||
|
f"/api/v1/laboratory/m4-threat/results/{result_id}"
|
||||||
|
f"/timeline/playback/chunks/{chunk_index}"
|
||||||
|
),
|
||||||
|
"media_type": "application/octet-stream",
|
||||||
|
"dtype": "<f4",
|
||||||
|
"shape": [point_stop - point_start, 3],
|
||||||
|
"bytes": payload.nbytes,
|
||||||
|
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=4)
|
@lru_cache(maxsize=4)
|
||||||
def _read_threat_result_cached(
|
def _read_threat_result_cached(
|
||||||
root_value: str,
|
root_value: str,
|
||||||
signature: tuple[int, ...],
|
signature: tuple[int, ...],
|
||||||
) -> ThreatReplayResult:
|
) -> ThreatReplayResult:
|
||||||
del signature
|
del signature
|
||||||
return read_threat_replay_result(Path(root_value))
|
return read_threat_replay_result_metadata(Path(root_value))
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=4)
|
@lru_cache(maxsize=4)
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from k1link.laboratory.m49_physical_safety_playback import (
|
||||||
|
CHUNK_HEADER,
|
||||||
|
CHUNK_MAGIC,
|
||||||
|
read_m49_physical_safety_playback,
|
||||||
|
seal_m49_physical_safety_playback,
|
||||||
|
)
|
||||||
|
from k1link.web.m49_physical_safety_playback_api import (
|
||||||
|
build_m49_physical_safety_playback_router,
|
||||||
|
)
|
||||||
|
|
||||||
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _sealed_source(root: Path, *, frame_count: int = 65, cell_count: int = 8) -> Path:
|
||||||
|
root.mkdir()
|
||||||
|
centers = np.column_stack(
|
||||||
|
(
|
||||||
|
np.arange(cell_count, dtype=np.float32) * np.float32(0.15),
|
||||||
|
np.zeros(cell_count, dtype=np.float32),
|
||||||
|
)
|
||||||
|
).astype(np.float32)
|
||||||
|
states = np.ones((frame_count, cell_count), dtype=np.uint8)
|
||||||
|
z_bounds = np.zeros((frame_count, cell_count, 2), dtype=np.float32)
|
||||||
|
z_bounds[..., 1] = np.float32(0.12)
|
||||||
|
missing_sequence = 17
|
||||||
|
states[missing_sequence] = 0
|
||||||
|
z_bounds[missing_sequence] = np.nan
|
||||||
|
np.save(root / "costmap-cell-centers-xy-m.npy", centers, allow_pickle=False)
|
||||||
|
np.save(root / "costmap-states.npy", states, allow_pickle=False)
|
||||||
|
np.save(root / "costmap-z-bounds-m.npy", z_bounds, allow_pickle=False)
|
||||||
|
frames = [
|
||||||
|
{
|
||||||
|
"source_frame_index": sequence,
|
||||||
|
"session_seconds": sequence / 10,
|
||||||
|
"sample_available": sequence != missing_sequence,
|
||||||
|
"eligible_point_count": 0 if sequence == missing_sequence else cell_count,
|
||||||
|
"ground_point_count": 0 if sequence == missing_sequence else cell_count,
|
||||||
|
"nonground_point_count": 0,
|
||||||
|
"rejected_point_count": 0,
|
||||||
|
"occupied_cell_count": 0,
|
||||||
|
}
|
||||||
|
for sequence in range(frame_count)
|
||||||
|
]
|
||||||
|
(root / "frames.ndjson").write_text(
|
||||||
|
"".join(json.dumps(frame, sort_keys=True) + "\n" for frame in frames),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
source_identity = {"schema_version": "missioncore.test-sealed-source/v1"}
|
||||||
|
source_identity_sha256 = hashlib.sha256(
|
||||||
|
json.dumps(source_identity, sort_keys=True, separators=(",", ":")).encode()
|
||||||
|
).hexdigest()
|
||||||
|
result_id = "m49-tgs-full-shadow-" + source_identity_sha256
|
||||||
|
report = {
|
||||||
|
"schema_version": "missioncore.m49-tgs-full-shadow-report/v1",
|
||||||
|
"result_id": result_id,
|
||||||
|
"configuration": {
|
||||||
|
"coordinate_frame": "map-gravity-local",
|
||||||
|
"cell_size_m": 0.15,
|
||||||
|
"radius_m": 12.0,
|
||||||
|
},
|
||||||
|
"visual_review": {
|
||||||
|
"state_codes": {
|
||||||
|
"UNOBSERVED": 0,
|
||||||
|
"GROUND_SUPPORT": 1,
|
||||||
|
"NONGROUND_OCCUPIED": 2,
|
||||||
|
"UNKNOWN_REJECTED": 3,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
(root / "report.json").write_text(json.dumps(report), encoding="utf-8")
|
||||||
|
artifacts = []
|
||||||
|
for name in (
|
||||||
|
"costmap-cell-centers-xy-m.npy",
|
||||||
|
"costmap-states.npy",
|
||||||
|
"costmap-z-bounds-m.npy",
|
||||||
|
"frames.ndjson",
|
||||||
|
"report.json",
|
||||||
|
):
|
||||||
|
path = root / name
|
||||||
|
artifacts.append(
|
||||||
|
{
|
||||||
|
"path": name,
|
||||||
|
"byte_length": path.stat().st_size,
|
||||||
|
"sha256": _sha256(path),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
(root / "manifest.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema_version": "missioncore.m49-tgs-full-shadow-lab/v1",
|
||||||
|
"result_id": result_id,
|
||||||
|
"identity": source_identity,
|
||||||
|
"identity_sha256": source_identity_sha256,
|
||||||
|
"artifacts": artifacts,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_builder_seals_bounded_chunks_without_worker_network(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
source = _sealed_source(tmp_path / "source")
|
||||||
|
|
||||||
|
def forbid_network(*_args: object, **_kwargs: object) -> None:
|
||||||
|
raise AssertionError("local LAB builder attempted network access")
|
||||||
|
|
||||||
|
monkeypatch.setattr(socket, "create_connection", forbid_network)
|
||||||
|
sealed = seal_m49_physical_safety_playback(
|
||||||
|
source_root=source,
|
||||||
|
destination_root=tmp_path / "results",
|
||||||
|
profile_path=(
|
||||||
|
REPOSITORY_ROOT / "config" / "perception" / "m49-physical-safety-shadow-v0.json"
|
||||||
|
),
|
||||||
|
created_at_utc="2026-08-27T12:00:00Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sealed.manifest["execution"] == {
|
||||||
|
"execution_class": "local-sequential-offline",
|
||||||
|
"worker_role": "realtime-only",
|
||||||
|
"worker_runtime_dependency": False,
|
||||||
|
"worker_requests_required": 0,
|
||||||
|
}
|
||||||
|
playback = sealed.manifest["playback"]
|
||||||
|
assert playback["cell_size_m"] == 0.15
|
||||||
|
assert playback["chunk_frame_count"] == 32
|
||||||
|
assert playback["startup_prebuffer_chunk_count"] == 2
|
||||||
|
assert playback["resident_chunk_count_max"] == 3
|
||||||
|
assert [(chunk["start"], chunk["count"]) for chunk in playback["chunks"]] == [
|
||||||
|
(0, 32),
|
||||||
|
(32, 32),
|
||||||
|
(64, 1),
|
||||||
|
]
|
||||||
|
first = (sealed.root / playback["chunks"][0]["path"]).read_bytes()
|
||||||
|
magic, start, count, cells, state_bytes, z_bytes = CHUNK_HEADER.unpack_from(first)
|
||||||
|
assert (magic, start, count, cells) == (CHUNK_MAGIC, 0, 32, 8)
|
||||||
|
assert state_bytes == 32 * 8
|
||||||
|
assert z_bytes == 32 * 8 * 2 * 4
|
||||||
|
assert read_m49_physical_safety_playback(sealed.root).result_id == sealed.result_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_api_serves_only_hash_verified_sealed_tracks(tmp_path: Path) -> None:
|
||||||
|
source = _sealed_source(tmp_path / "source")
|
||||||
|
destination = tmp_path / "results"
|
||||||
|
sealed = seal_m49_physical_safety_playback(
|
||||||
|
source_root=source,
|
||||||
|
destination_root=destination,
|
||||||
|
profile_path=(
|
||||||
|
REPOSITORY_ROOT / "config" / "perception" / "m49-physical-safety-shadow-v0.json"
|
||||||
|
),
|
||||||
|
created_at_utc="2026-08-27T12:00:00Z",
|
||||||
|
)
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(
|
||||||
|
build_m49_physical_safety_playback_router(root_provider=lambda: destination)
|
||||||
|
)
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
manifest_response = client.get(
|
||||||
|
f"/api/v1/laboratory/m49/physical-safety-playback/{sealed.result_id}/manifest"
|
||||||
|
)
|
||||||
|
assert manifest_response.status_code == 200
|
||||||
|
manifest = manifest_response.json()
|
||||||
|
assert manifest["execution"]["worker_requests_required"] == 0
|
||||||
|
assert manifest["access"] == "read-only-sealed-local"
|
||||||
|
assert "worker" not in manifest["playback"]["centers"]["url"]
|
||||||
|
assert client.get(manifest["playback"]["centers"]["url"]).status_code == 200
|
||||||
|
assert client.get(manifest["playback"]["chunks"][0]["url"]).status_code == 200
|
||||||
|
source_result_id = sealed.manifest["identity"]["source_result_id"]
|
||||||
|
catalog = client.get(
|
||||||
|
"/api/v1/laboratory/m49/physical-safety-playback/results",
|
||||||
|
params={"source_result_id": source_result_id},
|
||||||
|
)
|
||||||
|
assert catalog.status_code == 200
|
||||||
|
assert [item["result_id"] for item in catalog.json()["items"]] == [sealed.result_id]
|
||||||
|
|
||||||
|
chunk_path = sealed.root / sealed.manifest["playback"]["chunks"][0]["path"]
|
||||||
|
damaged = bytearray(chunk_path.read_bytes())
|
||||||
|
damaged[-1] ^= 1
|
||||||
|
chunk_path.write_bytes(damaged)
|
||||||
|
assert client.get(manifest["playback"]["chunks"][0]["url"]).status_code == 503
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi.routing import APIRoute
|
from fastapi.routing import APIRoute
|
||||||
|
from pytest import MonkeyPatch
|
||||||
|
|
||||||
from k1link.perception.threat_replay import read_threat_replay_result
|
from k1link.perception.geometry import RecordedGeometryStore
|
||||||
|
from k1link.perception.threat_replay import (
|
||||||
|
read_threat_replay_result,
|
||||||
|
read_threat_replay_result_metadata,
|
||||||
|
)
|
||||||
|
from k1link.perception.threat_timeline import RecordedThreatTimeline
|
||||||
from k1link.sessions import RecordedCameraFrame
|
from k1link.sessions import RecordedCameraFrame
|
||||||
from k1link.web.m4_threat_replay_api import build_m4_threat_replay_router
|
from k1link.web.m4_threat_replay_api import build_m4_threat_replay_router
|
||||||
|
|
||||||
@@ -128,6 +135,9 @@ def test_m4_6_timeline_is_indexed_and_spatial_evidence_is_chunked() -> None:
|
|||||||
get_timeline = _endpoint("/api/v1/laboratory/m4-threat/results/{result_id}/timeline")
|
get_timeline = _endpoint("/api/v1/laboratory/m4-threat/results/{result_id}/timeline")
|
||||||
get_chunk = _endpoint("/api/v1/laboratory/m4-threat/results/{result_id}/timeline/chunk")
|
get_chunk = _endpoint("/api/v1/laboratory/m4-threat/results/{result_id}/timeline/chunk")
|
||||||
get_playback = _endpoint("/api/v1/laboratory/m4-threat/results/{result_id}/timeline/playback")
|
get_playback = _endpoint("/api/v1/laboratory/m4-threat/results/{result_id}/timeline/playback")
|
||||||
|
get_playback_chunk = _endpoint(
|
||||||
|
"/api/v1/laboratory/m4-threat/results/{result_id}/timeline/playback/chunks/{chunk_index}"
|
||||||
|
)
|
||||||
|
|
||||||
timeline = get_timeline(RESULT_ID)
|
timeline = get_timeline(RESULT_ID)
|
||||||
assert timeline["schema_version"] == "missioncore.recorded-spatial-evidence-timeline/v1"
|
assert timeline["schema_version"] == "missioncore.recorded-spatial-evidence-timeline/v1"
|
||||||
@@ -178,6 +188,33 @@ def test_m4_6_timeline_is_indexed_and_spatial_evidence_is_chunked() -> None:
|
|||||||
assert playback["track"]["shape"] == [9_207_270, 3]
|
assert playback["track"]["shape"] == [9_207_270, 3]
|
||||||
assert playback["track"]["bytes"] == 110_487_240
|
assert playback["track"]["bytes"] == 110_487_240
|
||||||
assert len(playback["track"]["sha256"]) == 64
|
assert len(playback["track"]["sha256"]) == 64
|
||||||
|
assert playback["chunk_frame_count"] == 24
|
||||||
|
assert playback["resident_chunk_count_max"] == 4
|
||||||
|
assert playback["forward_prefetch_chunk_count"] == 1
|
||||||
|
assert len(playback["chunks"]) == 188
|
||||||
|
first_chunk = playback["chunks"][0]
|
||||||
|
assert first_chunk["start"] == 0
|
||||||
|
assert first_chunk["count"] == 24
|
||||||
|
assert first_chunk["bytes"] < 3 * 1024 * 1024
|
||||||
|
chunk_response = get_playback_chunk(RESULT_ID, 0)
|
||||||
|
assert len(chunk_response.body) == first_chunk["bytes"]
|
||||||
|
assert hashlib.sha256(chunk_response.body).hexdigest() == first_chunk["sha256"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_m4_6_timeline_metadata_defers_large_geometry_archives(
|
||||||
|
monkeypatch: MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
result = read_threat_replay_result_metadata(RESULTS_ROOT / RESULT_ID)
|
||||||
|
|
||||||
|
def reject_eager_load(*_args: object, **_kwargs: object) -> RecordedGeometryStore:
|
||||||
|
raise AssertionError("timeline metadata eagerly loaded the geometry archives")
|
||||||
|
|
||||||
|
monkeypatch.setattr(RecordedGeometryStore, "from_repository", reject_eager_load)
|
||||||
|
timeline = RecordedThreatTimeline(repository_root=REPOSITORY_ROOT, result=result)
|
||||||
|
|
||||||
|
assert timeline.metadata()["frame_count"] == 4489
|
||||||
|
assert timeline.metadata()["maximum_source_points_per_frame"] == 3092
|
||||||
|
assert timeline._store is None
|
||||||
|
|
||||||
|
|
||||||
def test_m4_6_exact_camera_endpoint_is_bound_to_selected_visual_sequence() -> None:
|
def test_m4_6_exact_camera_endpoint_is_bound_to_selected_visual_sequence() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user