Compare commits
5
Commits
296cf610cd
...
7aeea4ed81
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7aeea4ed81 | ||
|
|
bf76304e00 | ||
|
|
b70dc4298a | ||
|
|
f153d671d3 | ||
|
|
7720594790 |
+233
-135
@@ -169,6 +169,47 @@ function positions(points: readonly LaboratoryMetricPoint3[]): Float32Array {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updatePointPositions(
|
||||||
|
geometry: THREE.BufferGeometry,
|
||||||
|
points: readonly LaboratoryMetricPoint3[],
|
||||||
|
): void {
|
||||||
|
const requiredValues = points.length * 3;
|
||||||
|
let attribute = geometry.getAttribute("position") as THREE.BufferAttribute | undefined;
|
||||||
|
if (!attribute || attribute.array.length < requiredValues) {
|
||||||
|
let capacity = 3;
|
||||||
|
while (capacity < requiredValues) capacity *= 2;
|
||||||
|
attribute = new THREE.BufferAttribute(new Float32Array(capacity), 3);
|
||||||
|
attribute.setUsage(THREE.DynamicDrawUsage);
|
||||||
|
geometry.setAttribute("position", attribute);
|
||||||
|
}
|
||||||
|
const values = attribute.array as Float32Array;
|
||||||
|
points.forEach((point, index) => {
|
||||||
|
const [x, y, z] = scenePoint(point);
|
||||||
|
const offset = index * 3;
|
||||||
|
values[offset] = x;
|
||||||
|
values[offset + 1] = y;
|
||||||
|
values[offset + 2] = z;
|
||||||
|
});
|
||||||
|
attribute.needsUpdate = true;
|
||||||
|
geometry.setDrawRange(0, points.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePointColors(
|
||||||
|
geometry: THREE.BufferGeometry,
|
||||||
|
pointCount: number,
|
||||||
|
): THREE.BufferAttribute {
|
||||||
|
const requiredValues = pointCount * 3;
|
||||||
|
let attribute = geometry.getAttribute("color") as THREE.BufferAttribute | undefined;
|
||||||
|
if (!attribute || attribute.array.length < requiredValues) {
|
||||||
|
let capacity = 3;
|
||||||
|
while (capacity < requiredValues) capacity *= 2;
|
||||||
|
attribute = new THREE.BufferAttribute(new Float32Array(capacity), 3);
|
||||||
|
attribute.setUsage(THREE.DynamicDrawUsage);
|
||||||
|
geometry.setAttribute("color", attribute);
|
||||||
|
}
|
||||||
|
return attribute;
|
||||||
|
}
|
||||||
|
|
||||||
function decisionColor(
|
function decisionColor(
|
||||||
host: HTMLElement,
|
host: HTMLElement,
|
||||||
decision: LaboratoryMetricDecision,
|
decision: LaboratoryMetricDecision,
|
||||||
@@ -244,6 +285,12 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
const controlsRef = useRef<OrbitControls | null>(null);
|
const controlsRef = useRef<OrbitControls | null>(null);
|
||||||
const staticContentRef = useRef<THREE.Group | null>(null);
|
const staticContentRef = useRef<THREE.Group | null>(null);
|
||||||
const dynamicContentRef = useRef<THREE.Group | null>(null);
|
const dynamicContentRef = useRef<THREE.Group | null>(null);
|
||||||
|
const classifiedContentRef = useRef<THREE.Group | null>(null);
|
||||||
|
const localSurfacePointsRef = useRef<THREE.Points | null>(null);
|
||||||
|
const currentIncrementPointsRef = useRef<THREE.Points | null>(null);
|
||||||
|
const classifiedMeshesRef = useRef<ReadonlyMap<LaboratoryMetricCellState, THREE.InstancedMesh>>(
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
const [renderError, setRenderError] = useState<string | null>(null);
|
const [renderError, setRenderError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -278,12 +325,48 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
controls.maxDistance = 80;
|
controls.maxDistance = 80;
|
||||||
const staticContent = new THREE.Group();
|
const staticContent = new THREE.Group();
|
||||||
const dynamicContent = new THREE.Group();
|
const dynamicContent = new THREE.Group();
|
||||||
scene.add(staticContent, dynamicContent);
|
const classifiedContent = new THREE.Group();
|
||||||
|
const localSurfacePoints = new THREE.Points(
|
||||||
|
new THREE.BufferGeometry(),
|
||||||
|
new THREE.PointsMaterial({
|
||||||
|
color: tokenColor(host, "--nodedc-accent-rgb", [247, 248, 244]),
|
||||||
|
size: 1.3,
|
||||||
|
sizeAttenuation: false,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.42,
|
||||||
|
depthWrite: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const currentIncrementPoints = new THREE.Points(
|
||||||
|
new THREE.BufferGeometry(),
|
||||||
|
new THREE.PointsMaterial({
|
||||||
|
color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
|
||||||
|
size: 1.55,
|
||||||
|
sizeAttenuation: false,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.58,
|
||||||
|
depthWrite: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
localSurfacePoints.visible = false;
|
||||||
|
localSurfacePoints.frustumCulled = false;
|
||||||
|
currentIncrementPoints.visible = false;
|
||||||
|
currentIncrementPoints.frustumCulled = false;
|
||||||
|
scene.add(
|
||||||
|
staticContent,
|
||||||
|
localSurfacePoints,
|
||||||
|
currentIncrementPoints,
|
||||||
|
dynamicContent,
|
||||||
|
classifiedContent,
|
||||||
|
);
|
||||||
sceneRef.current = scene;
|
sceneRef.current = scene;
|
||||||
cameraRef.current = camera;
|
cameraRef.current = camera;
|
||||||
controlsRef.current = controls;
|
controlsRef.current = controls;
|
||||||
staticContentRef.current = staticContent;
|
staticContentRef.current = staticContent;
|
||||||
dynamicContentRef.current = dynamicContent;
|
dynamicContentRef.current = dynamicContent;
|
||||||
|
classifiedContentRef.current = classifiedContent;
|
||||||
|
localSurfacePointsRef.current = localSurfacePoints;
|
||||||
|
currentIncrementPointsRef.current = currentIncrementPoints;
|
||||||
|
|
||||||
const resize = () => {
|
const resize = () => {
|
||||||
const width = Math.max(host.clientWidth, 1);
|
const width = Math.max(host.clientWidth, 1);
|
||||||
@@ -315,6 +398,10 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
controlsRef.current = null;
|
controlsRef.current = null;
|
||||||
staticContentRef.current = null;
|
staticContentRef.current = null;
|
||||||
dynamicContentRef.current = null;
|
dynamicContentRef.current = null;
|
||||||
|
classifiedContentRef.current = null;
|
||||||
|
localSurfacePointsRef.current = null;
|
||||||
|
currentIncrementPointsRef.current = null;
|
||||||
|
classifiedMeshesRef.current = new Map();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -323,137 +410,71 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
if (canvas) canvas.setAttribute("aria-label", label);
|
if (canvas) canvas.setAttribute("aria-label", label);
|
||||||
}, [label]);
|
}, [label]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const points = localSurfacePointsRef.current;
|
||||||
|
if (!points) return;
|
||||||
|
points.visible = showLocalSurface && localSurfaceBodyXyzM.length > 0;
|
||||||
|
if (points.visible) updatePointPositions(points.geometry, localSurfaceBodyXyzM);
|
||||||
|
}, [localSurfaceBodyXyzM, showLocalSurface]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const host = hostRef.current;
|
||||||
|
const points = currentIncrementPointsRef.current;
|
||||||
|
if (!host || !points) return;
|
||||||
|
points.visible = showCurrentIncrement && pointCloudBodyXyzM.length > 0;
|
||||||
|
if (!points.visible) return;
|
||||||
|
updatePointPositions(points.geometry, pointCloudBodyXyzM);
|
||||||
|
const material = points.material as THREE.PointsMaterial;
|
||||||
|
const hasAlignedSemanticClasses =
|
||||||
|
pointSemanticClassIds !== undefined
|
||||||
|
&& pointSemanticClassIds.length === pointCloudBodyXyzM.length
|
||||||
|
&& semanticClasses !== undefined
|
||||||
|
&& semanticPalette !== undefined;
|
||||||
|
if (!hasAlignedSemanticClasses) {
|
||||||
|
if (material.vertexColors) {
|
||||||
|
material.vertexColors = false;
|
||||||
|
material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
material.color.copy(tokenColor(host, "--nodedc-text-muted", [147, 151, 159]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const declaredIds = new Set(semanticClasses.map((item) => item.id));
|
||||||
|
const colorsByClassId = new Map<number, readonly [number, number, number]>();
|
||||||
|
for (const entry of semanticPalette) {
|
||||||
|
if (!declaredIds.has(entry.classId)) continue;
|
||||||
|
const rgb = resolveRecordedEvidenceSemanticRgb(host, entry.color);
|
||||||
|
if (rgb) colorsByClassId.set(entry.classId, rgb);
|
||||||
|
}
|
||||||
|
const context = tokenColor(host, "--nodedc-text-muted", [147, 151, 159]);
|
||||||
|
const colorAttribute = ensurePointColors(points.geometry, pointCloudBodyXyzM.length);
|
||||||
|
const pointColors = colorAttribute.array as Float32Array;
|
||||||
|
pointSemanticClassIds.forEach((classId, index) => {
|
||||||
|
const rgb = classId === null ? undefined : colorsByClassId.get(classId);
|
||||||
|
const offset = index * 3;
|
||||||
|
pointColors[offset] = rgb ? rgb[0] / 255 : context.r;
|
||||||
|
pointColors[offset + 1] = rgb ? rgb[1] / 255 : context.g;
|
||||||
|
pointColors[offset + 2] = rgb ? rgb[2] / 255 : context.b;
|
||||||
|
});
|
||||||
|
colorAttribute.needsUpdate = true;
|
||||||
|
if (!material.vertexColors) {
|
||||||
|
material.vertexColors = true;
|
||||||
|
material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
material.color.setRGB(1, 1, 1);
|
||||||
|
}, [
|
||||||
|
pointCloudBodyXyzM,
|
||||||
|
pointSemanticClassIds,
|
||||||
|
semanticClasses,
|
||||||
|
semanticPalette,
|
||||||
|
showCurrentIncrement,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const host = hostRef.current;
|
const host = hostRef.current;
|
||||||
const content = dynamicContentRef.current;
|
const content = dynamicContentRef.current;
|
||||||
if (!host || !content) return;
|
if (!host || !content) return;
|
||||||
clearGroup(content);
|
clearGroup(content);
|
||||||
|
|
||||||
if (showLocalSurface) {
|
|
||||||
const localSurfaceGeometry = new THREE.BufferGeometry();
|
|
||||||
localSurfaceGeometry.setAttribute(
|
|
||||||
"position",
|
|
||||||
new THREE.BufferAttribute(positions(localSurfaceBodyXyzM), 3),
|
|
||||||
);
|
|
||||||
content.add(new THREE.Points(
|
|
||||||
localSurfaceGeometry,
|
|
||||||
new THREE.PointsMaterial({
|
|
||||||
color: tokenColor(host, "--nodedc-accent-rgb", [247, 248, 244]),
|
|
||||||
size: 1.3,
|
|
||||||
sizeAttenuation: false,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0.42,
|
|
||||||
depthWrite: false,
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showCurrentIncrement) {
|
|
||||||
const contextGeometry = new THREE.BufferGeometry();
|
|
||||||
contextGeometry.setAttribute(
|
|
||||||
"position",
|
|
||||||
new THREE.BufferAttribute(positions(pointCloudBodyXyzM), 3),
|
|
||||||
);
|
|
||||||
const hasAlignedSemanticClasses =
|
|
||||||
pointSemanticClassIds !== undefined
|
|
||||||
&& pointSemanticClassIds.length === pointCloudBodyXyzM.length
|
|
||||||
&& semanticClasses !== undefined
|
|
||||||
&& semanticPalette !== undefined;
|
|
||||||
if (hasAlignedSemanticClasses) {
|
|
||||||
const declaredIds = new Set(semanticClasses.map((item) => item.id));
|
|
||||||
const colorsByClassId = new Map<number, readonly [number, number, number]>();
|
|
||||||
for (const entry of semanticPalette) {
|
|
||||||
if (!declaredIds.has(entry.classId)) continue;
|
|
||||||
const rgb = resolveRecordedEvidenceSemanticRgb(host, entry.color);
|
|
||||||
if (rgb) colorsByClassId.set(entry.classId, rgb);
|
|
||||||
}
|
|
||||||
const context = tokenColor(host, "--nodedc-text-muted", [147, 151, 159]);
|
|
||||||
const pointColors = new Float32Array(pointCloudBodyXyzM.length * 3);
|
|
||||||
pointSemanticClassIds.forEach((classId, index) => {
|
|
||||||
const rgb = classId === null ? undefined : colorsByClassId.get(classId);
|
|
||||||
const offset = index * 3;
|
|
||||||
pointColors[offset] = rgb ? rgb[0] / 255 : context.r;
|
|
||||||
pointColors[offset + 1] = rgb ? rgb[1] / 255 : context.g;
|
|
||||||
pointColors[offset + 2] = rgb ? rgb[2] / 255 : context.b;
|
|
||||||
});
|
|
||||||
contextGeometry.setAttribute(
|
|
||||||
"color",
|
|
||||||
new THREE.BufferAttribute(pointColors, 3),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
content.add(new THREE.Points(
|
|
||||||
contextGeometry,
|
|
||||||
new THREE.PointsMaterial({
|
|
||||||
color: hasAlignedSemanticClasses
|
|
||||||
? new THREE.Color(1, 1, 1)
|
|
||||||
: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
|
|
||||||
vertexColors: hasAlignedSemanticClasses,
|
|
||||||
size: 1.55,
|
|
||||||
sizeAttenuation: false,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0.58,
|
|
||||||
depthWrite: false,
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showClassifiedCells && classifiedCells.length) {
|
|
||||||
const cellsByState = new Map<LaboratoryMetricCellState, LaboratoryMetricCellEvidence[]>();
|
|
||||||
for (const cell of classifiedCells) {
|
|
||||||
const cells = cellsByState.get(cell.state) ?? [];
|
|
||||||
cells.push(cell);
|
|
||||||
cellsByState.set(cell.state, cells);
|
|
||||||
}
|
|
||||||
for (const [state, cells] of cellsByState) {
|
|
||||||
const color = state === "ground-support"
|
|
||||||
? tokenColor(host, "--nodedc-success-rgb", [181, 255, 90])
|
|
||||||
: state === "nonground-occupied"
|
|
||||||
? tokenColor(host, "--nodedc-danger-rgb", [255, 104, 112])
|
|
||||||
: state === "unknown-rejected"
|
|
||||||
? tokenColor(host, "--nodedc-warning-rgb", [255, 197, 92])
|
|
||||||
: tokenColor(host, "--nodedc-text-muted", [96, 99, 106]);
|
|
||||||
const geometry = new THREE.BoxGeometry(
|
|
||||||
classifiedCellSizeM * 0.92,
|
|
||||||
1,
|
|
||||||
classifiedCellSizeM * 0.92,
|
|
||||||
);
|
|
||||||
const material = new THREE.MeshBasicMaterial({
|
|
||||||
color,
|
|
||||||
transparent: true,
|
|
||||||
opacity: state === "unobserved" ? 0.035 : state === "ground-support" ? 0.12 : 0.24,
|
|
||||||
depthWrite: false,
|
|
||||||
});
|
|
||||||
const mesh = new THREE.InstancedMesh(geometry, material, cells.length);
|
|
||||||
const matrix = new THREE.Matrix4();
|
|
||||||
const scale = new THREE.Vector3(1, 1, 1);
|
|
||||||
const rotation = new THREE.Quaternion();
|
|
||||||
cells.forEach((cell, index) => {
|
|
||||||
const minimum = cell.zBoundsM[0];
|
|
||||||
const maximum = cell.zBoundsM[1];
|
|
||||||
const height = minimum === null || maximum === null
|
|
||||||
? 0.018
|
|
||||||
: Math.max(0.018, maximum - minimum);
|
|
||||||
const centerZ = minimum === null || maximum === null
|
|
||||||
? -0.012
|
|
||||||
: (minimum + maximum) / 2;
|
|
||||||
const [sceneX, sceneY, sceneZ] = scenePoint([
|
|
||||||
cell.centerBodyXyM[0],
|
|
||||||
cell.centerBodyXyM[1],
|
|
||||||
centerZ,
|
|
||||||
]);
|
|
||||||
scale.set(1, height, 1);
|
|
||||||
matrix.compose(
|
|
||||||
new THREE.Vector3(sceneX, sceneY, sceneZ),
|
|
||||||
rotation,
|
|
||||||
scale,
|
|
||||||
);
|
|
||||||
mesh.setMatrixAt(index, matrix);
|
|
||||||
});
|
|
||||||
mesh.instanceMatrix.needsUpdate = true;
|
|
||||||
content.add(mesh);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const obstacle of obstacles) {
|
for (const obstacle of obstacles) {
|
||||||
if (
|
if (
|
||||||
(
|
(
|
||||||
@@ -526,20 +547,97 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
}, [
|
}, [
|
||||||
obstacles,
|
obstacles,
|
||||||
occupiedVoxelSizeM,
|
occupiedVoxelSizeM,
|
||||||
localSurfaceBodyXyzM,
|
|
||||||
pointCloudBodyXyzM,
|
|
||||||
pointSemanticClassIds,
|
|
||||||
semanticClasses,
|
|
||||||
semanticPalette,
|
|
||||||
classifiedCells,
|
|
||||||
classifiedCellSizeM,
|
|
||||||
showCurrentIncrement,
|
showCurrentIncrement,
|
||||||
showClassifiedCells,
|
|
||||||
showLocalSurface,
|
|
||||||
showRollingMap,
|
showRollingMap,
|
||||||
showLowStep,
|
showLowStep,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const host = hostRef.current;
|
||||||
|
const content = classifiedContentRef.current;
|
||||||
|
if (!host || !content) return;
|
||||||
|
content.visible = showClassifiedCells && classifiedCells.length > 0;
|
||||||
|
if (!content.visible) return;
|
||||||
|
|
||||||
|
const requiredCapacity = classifiedCells.length;
|
||||||
|
const currentMeshes = classifiedMeshesRef.current;
|
||||||
|
const firstMesh = currentMeshes.values().next().value as THREE.InstancedMesh | undefined;
|
||||||
|
const needsAllocation = !firstMesh
|
||||||
|
|| Number(firstMesh.userData.capacity ?? 0) < requiredCapacity
|
||||||
|
|| Number(firstMesh.userData.cellSizeM ?? 0) !== classifiedCellSizeM;
|
||||||
|
if (needsAllocation) {
|
||||||
|
clearGroup(content);
|
||||||
|
const meshes = new Map<LaboratoryMetricCellState, THREE.InstancedMesh>();
|
||||||
|
const states: readonly LaboratoryMetricCellState[] = [
|
||||||
|
"unobserved",
|
||||||
|
"ground-support",
|
||||||
|
"nonground-occupied",
|
||||||
|
"unknown-rejected",
|
||||||
|
];
|
||||||
|
for (const state of states) {
|
||||||
|
const color = state === "ground-support"
|
||||||
|
? tokenColor(host, "--nodedc-success-rgb", [181, 255, 90])
|
||||||
|
: state === "nonground-occupied"
|
||||||
|
? tokenColor(host, "--nodedc-danger-rgb", [255, 104, 112])
|
||||||
|
: state === "unknown-rejected"
|
||||||
|
? tokenColor(host, "--nodedc-warning-rgb", [255, 197, 92])
|
||||||
|
: tokenColor(host, "--nodedc-text-muted", [96, 99, 106]);
|
||||||
|
const mesh = new THREE.InstancedMesh(
|
||||||
|
new THREE.BoxGeometry(classifiedCellSizeM * 0.92, 1, classifiedCellSizeM * 0.92),
|
||||||
|
new THREE.MeshBasicMaterial({
|
||||||
|
color,
|
||||||
|
transparent: true,
|
||||||
|
opacity: state === "unobserved" ? 0.035 : state === "ground-support" ? 0.12 : 0.24,
|
||||||
|
depthWrite: false,
|
||||||
|
}),
|
||||||
|
requiredCapacity,
|
||||||
|
);
|
||||||
|
mesh.count = 0;
|
||||||
|
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||||
|
mesh.frustumCulled = false;
|
||||||
|
mesh.userData.capacity = requiredCapacity;
|
||||||
|
mesh.userData.cellSizeM = classifiedCellSizeM;
|
||||||
|
meshes.set(state, mesh);
|
||||||
|
content.add(mesh);
|
||||||
|
}
|
||||||
|
classifiedMeshesRef.current = meshes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meshes = classifiedMeshesRef.current;
|
||||||
|
const counts = new Map<LaboratoryMetricCellState, number>();
|
||||||
|
const matrix = new THREE.Matrix4();
|
||||||
|
const position = new THREE.Vector3();
|
||||||
|
const scale = new THREE.Vector3(1, 1, 1);
|
||||||
|
const rotation = new THREE.Quaternion();
|
||||||
|
for (const cell of classifiedCells) {
|
||||||
|
const mesh = meshes.get(cell.state);
|
||||||
|
if (!mesh) continue;
|
||||||
|
const index = counts.get(cell.state) ?? 0;
|
||||||
|
const minimum = cell.zBoundsM[0];
|
||||||
|
const maximum = cell.zBoundsM[1];
|
||||||
|
const height = minimum === null || maximum === null
|
||||||
|
? 0.018
|
||||||
|
: Math.max(0.018, maximum - minimum);
|
||||||
|
const centerZ = minimum === null || maximum === null
|
||||||
|
? -0.012
|
||||||
|
: (minimum + maximum) / 2;
|
||||||
|
const [sceneX, sceneY, sceneZ] = scenePoint([
|
||||||
|
cell.centerBodyXyM[0],
|
||||||
|
cell.centerBodyXyM[1],
|
||||||
|
centerZ,
|
||||||
|
]);
|
||||||
|
position.set(sceneX, sceneY, sceneZ);
|
||||||
|
scale.set(1, height, 1);
|
||||||
|
matrix.compose(position, rotation, scale);
|
||||||
|
mesh.setMatrixAt(index, matrix);
|
||||||
|
counts.set(cell.state, index + 1);
|
||||||
|
}
|
||||||
|
for (const [state, mesh] of meshes) {
|
||||||
|
mesh.count = counts.get(state) ?? 0;
|
||||||
|
mesh.instanceMatrix.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}, [classifiedCellSizeM, classifiedCells, showClassifiedCells]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const host = hostRef.current;
|
const host = hostRef.current;
|
||||||
const content = staticContentRef.current;
|
const content = staticContentRef.current;
|
||||||
|
|||||||
+8
-3
@@ -510,16 +510,21 @@ export function RecordedEvidenceSemanticMaskOverlay({
|
|||||||
}) {
|
}) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const rendererRef = useRef<SemanticMaskRenderer | null | undefined>(undefined);
|
const rendererRef = useRef<SemanticMaskRenderer | null | undefined>(undefined);
|
||||||
|
const lastReadyMaskRef = useRef<{ series: string; mask: DecodedSemanticMask } | null>(null);
|
||||||
const [rendererMode, setRendererMode] = useState<"webgl" | "2d">("webgl");
|
const [rendererMode, setRendererMode] = useState<"webgl" | "2d">("webgl");
|
||||||
const [mask, setMask] = useState<DecodedSemanticMask | null>(null);
|
const [mask, setMask] = useState<DecodedSemanticMask | null>(null);
|
||||||
const [failure, setFailure] = useState<string | null>(null);
|
const [failure, setFailure] = useState<string | null>(null);
|
||||||
const expectedKey = semanticMaskKey(src, imageWidth, imageHeight);
|
const expectedKey = semanticMaskKey(src, imageWidth, imageHeight);
|
||||||
const prefetchSignature = prefetchSrcs.join("\n");
|
const prefetchSignature = prefetchSrcs.join("\n");
|
||||||
const renderMask = failure
|
const series = src.slice(0, Math.max(src.lastIndexOf("/"), 0));
|
||||||
|
if (lastReadyMaskRef.current?.series !== series) lastReadyMaskRef.current = null;
|
||||||
|
const exactMask = failure
|
||||||
? null
|
? null
|
||||||
: mask?.key === expectedKey
|
: mask?.key === expectedKey
|
||||||
? mask
|
? mask
|
||||||
: decodedMaskCache.get(expectedKey) ?? null;
|
: decodedMaskCache.get(expectedKey) ?? null;
|
||||||
|
if (exactMask) lastReadyMaskRef.current = { series, mask: exactMask };
|
||||||
|
const renderMask = exactMask ?? lastReadyMaskRef.current?.mask ?? null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFailure(null);
|
setFailure(null);
|
||||||
@@ -627,8 +632,8 @@ export function RecordedEvidenceSemanticMaskOverlay({
|
|||||||
className="recorded-evidence-semantic-mask-overlay"
|
className="recorded-evidence-semantic-mask-overlay"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-busy={!failure && !renderMask}
|
aria-busy={!failure && !exactMask}
|
||||||
data-state={failure ? "error" : renderMask ? "ready" : "loading"}
|
data-state={failure ? "error" : exactMask ? "ready" : renderMask ? "stale" : "loading"}
|
||||||
data-renderer={rendererMode}
|
data-renderer={rendererMode}
|
||||||
style={{ zIndex: 1 }}
|
style={{ zIndex: 1 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -84,6 +84,48 @@ export interface M49TgsFullShadowSpatialChunk {
|
|||||||
frames: readonly M49TgsFullShadowSpatial[];
|
frames: readonly M49TgsFullShadowSpatial[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type M49TgsFullShadowPlaybackTrackId =
|
||||||
|
| "frames"
|
||||||
|
| "centers"
|
||||||
|
| "states"
|
||||||
|
| "z-bounds";
|
||||||
|
|
||||||
|
export interface M49TgsFullShadowPlaybackProgress {
|
||||||
|
phase: "manifest" | "download" | "verify" | "ready";
|
||||||
|
trackId: M49TgsFullShadowPlaybackTrackId | null;
|
||||||
|
loadedBytes: number;
|
||||||
|
totalBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface M49TgsFullShadowPlaybackTrack {
|
||||||
|
id: M49TgsFullShadowPlaybackTrackId;
|
||||||
|
url: string;
|
||||||
|
mediaType: string;
|
||||||
|
dtype: "ndjson" | "<f4" | "|u1";
|
||||||
|
shape: readonly number[];
|
||||||
|
byteLength: number;
|
||||||
|
sha256: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M49TgsFullShadowPlaybackFrame {
|
||||||
|
sourceSequence: number;
|
||||||
|
sourceFrameIndex: number;
|
||||||
|
sessionSeconds: number;
|
||||||
|
sampleAvailable: boolean;
|
||||||
|
metrics: M49TgsFullShadowSpatial["metrics"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M49TgsFullShadowPlaybackPack {
|
||||||
|
resultId: string;
|
||||||
|
frameCount: 4489;
|
||||||
|
cellCount: 2244;
|
||||||
|
totalByteLength: number;
|
||||||
|
centersXyM: readonly (readonly [number, number])[];
|
||||||
|
states: Uint8Array;
|
||||||
|
zBoundsM: Float32Array;
|
||||||
|
frames: readonly M49TgsFullShadowPlaybackFrame[];
|
||||||
|
}
|
||||||
|
|
||||||
export class M49TgsFullShadowContractError extends Error {}
|
export class M49TgsFullShadowContractError extends Error {}
|
||||||
|
|
||||||
function objectValue(value: unknown, label: string): Record<string, unknown> {
|
function objectValue(value: unknown, label: string): Record<string, unknown> {
|
||||||
@@ -335,3 +377,285 @@ export async function fetchM49TgsFullShadowSpatialChunk(
|
|||||||
)),
|
)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function playbackTrackId(value: unknown, label: string): M49TgsFullShadowPlaybackTrackId {
|
||||||
|
const parsed = text(value, label);
|
||||||
|
if (parsed !== "frames" && parsed !== "centers" && parsed !== "states" && parsed !== "z-bounds") {
|
||||||
|
throw new M49TgsFullShadowContractError(`${label}: неизвестная playback-дорожка.`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function playbackDtype(value: unknown, label: string): M49TgsFullShadowPlaybackTrack["dtype"] {
|
||||||
|
const parsed = text(value, label);
|
||||||
|
if (parsed !== "ndjson" && parsed !== "<f4" && parsed !== "|u1") {
|
||||||
|
throw new M49TgsFullShadowContractError(`${label}: неизвестный dtype.`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function playbackShape(value: unknown, label: string): readonly number[] {
|
||||||
|
if (!Array.isArray(value) || !value.length) {
|
||||||
|
throw new M49TgsFullShadowContractError(`${label}: ожидалась размерность.`);
|
||||||
|
}
|
||||||
|
return value.map((item, index) => integer(item, `${label}.${index}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPlaybackManifest(
|
||||||
|
id: string,
|
||||||
|
fetcher: LaboratoryFetch,
|
||||||
|
signal: AbortSignal | undefined,
|
||||||
|
): Promise<{
|
||||||
|
frameCount: 4489;
|
||||||
|
cellCount: 2244;
|
||||||
|
totalByteLength: number;
|
||||||
|
tracks: ReadonlyMap<M49TgsFullShadowPlaybackTrackId, M49TgsFullShadowPlaybackTrack>;
|
||||||
|
}> {
|
||||||
|
const response = await fetcher(
|
||||||
|
`/api/v1/laboratory/m49/tgs-full-shadow/${encodeURIComponent(id)}/playback/manifest`,
|
||||||
|
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new M49TgsFullShadowContractError(`M49 playback manifest недоступен: HTTP ${response.status}.`);
|
||||||
|
}
|
||||||
|
const payload = objectValue(await response.json(), "M49 playback manifest");
|
||||||
|
exact(payload.schema_version, "missioncore.m49-tgs-full-shadow-playback/v1", "M49 playback schema");
|
||||||
|
exact(payload.result_id, id, "M49 playback result");
|
||||||
|
exact(payload.coordinate_frame, "map-gravity-local", "M49 playback frame");
|
||||||
|
exact(payload.access, "read-only", "M49 playback access");
|
||||||
|
const frameCount = exact(integer(payload.frame_count, "M49 playback frames"), 4489, "M49 playback frames");
|
||||||
|
const cellCount = exact(integer(payload.cell_count, "M49 playback cells"), 2244, "M49 playback cells");
|
||||||
|
const totalByteLength = integer(payload.total_byte_length, "M49 playback bytes");
|
||||||
|
if (!Array.isArray(payload.tracks) || payload.tracks.length !== 4) {
|
||||||
|
throw new M49TgsFullShadowContractError("M49 playback tracks: неверная размерность.");
|
||||||
|
}
|
||||||
|
const tracks = new Map<M49TgsFullShadowPlaybackTrackId, M49TgsFullShadowPlaybackTrack>();
|
||||||
|
for (const [index, value] of payload.tracks.entries()) {
|
||||||
|
const row = objectValue(value, `M49 playback track ${index}`);
|
||||||
|
const trackId = playbackTrackId(row.id, `M49 playback track ${index}.id`);
|
||||||
|
const sha256 = text(row.sha256, `M49 playback track ${trackId}.sha256`);
|
||||||
|
if (!/^[a-f0-9]{64}$/.test(sha256) || tracks.has(trackId)) {
|
||||||
|
throw new M49TgsFullShadowContractError(`M49 playback track ${trackId}: нарушена идентичность.`);
|
||||||
|
}
|
||||||
|
tracks.set(trackId, {
|
||||||
|
id: trackId,
|
||||||
|
url: text(row.url, `M49 playback track ${trackId}.url`),
|
||||||
|
mediaType: text(row.media_type, `M49 playback track ${trackId}.media_type`),
|
||||||
|
dtype: playbackDtype(row.dtype, `M49 playback track ${trackId}.dtype`),
|
||||||
|
shape: playbackShape(row.shape, `M49 playback track ${trackId}.shape`),
|
||||||
|
byteLength: integer(row.byte_length, `M49 playback track ${trackId}.byte_length`),
|
||||||
|
sha256,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ([...tracks.values()].reduce((sum, track) => sum + track.byteLength, 0) !== totalByteLength) {
|
||||||
|
throw new M49TgsFullShadowContractError("M49 playback bytes: нарушен accounting.");
|
||||||
|
}
|
||||||
|
return { frameCount, cellCount, totalByteLength, tracks };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256Hex(buffer: ArrayBuffer): Promise<string> {
|
||||||
|
if (!globalThis.crypto?.subtle) {
|
||||||
|
throw new M49TgsFullShadowContractError("Браузер не поддерживает проверку playback SHA-256.");
|
||||||
|
}
|
||||||
|
return globalThis.crypto.subtle.digest("SHA-256", buffer).then((digest) => (
|
||||||
|
[...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPlaybackTrack(
|
||||||
|
track: M49TgsFullShadowPlaybackTrack,
|
||||||
|
fetcher: LaboratoryFetch,
|
||||||
|
signal: AbortSignal | undefined,
|
||||||
|
completedBytes: number,
|
||||||
|
totalBytes: number,
|
||||||
|
onProgress: ((progress: M49TgsFullShadowPlaybackProgress) => void) | undefined,
|
||||||
|
): Promise<ArrayBuffer> {
|
||||||
|
const response = await fetcher(track.url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: { Accept: track.mediaType },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new M49TgsFullShadowContractError(`M49 playback ${track.id} недоступен: HTTP ${response.status}.`);
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(track.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 M49TgsFullShadowContractError(`M49 playback ${track.id}: превышен объявленный размер.`);
|
||||||
|
}
|
||||||
|
bytes.set(value, offset);
|
||||||
|
offset += value.byteLength;
|
||||||
|
onProgress?.({
|
||||||
|
phase: "download",
|
||||||
|
trackId: track.id,
|
||||||
|
loadedBytes: completedBytes + offset,
|
||||||
|
totalBytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (offset !== bytes.byteLength) {
|
||||||
|
throw new M49TgsFullShadowContractError(`M49 playback ${track.id}: получен неполный файл.`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const fallback = new Uint8Array(await response.arrayBuffer());
|
||||||
|
if (fallback.byteLength !== bytes.byteLength) {
|
||||||
|
throw new M49TgsFullShadowContractError(`M49 playback ${track.id}: получен неверный размер.`);
|
||||||
|
}
|
||||||
|
bytes.set(fallback);
|
||||||
|
}
|
||||||
|
onProgress?.({
|
||||||
|
phase: "verify",
|
||||||
|
trackId: track.id,
|
||||||
|
loadedBytes: completedBytes + bytes.byteLength,
|
||||||
|
totalBytes,
|
||||||
|
});
|
||||||
|
if (await sha256Hex(bytes.buffer) !== track.sha256) {
|
||||||
|
throw new M49TgsFullShadowContractError(`M49 playback ${track.id}: SHA-256 не совпал.`);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function equalShape(actual: readonly number[], expected: readonly number[]): boolean {
|
||||||
|
return actual.length === expected.length && actual.every((value, index) => value === expected[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseM49TgsNpyTrack(
|
||||||
|
buffer: ArrayBuffer,
|
||||||
|
expectedDtype: "<f4" | "|u1",
|
||||||
|
expectedShape: readonly number[],
|
||||||
|
): Float32Array | Uint8Array {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
if (bytes.byteLength < 12
|
||||||
|
|| bytes[0] !== 0x93
|
||||||
|
|| String.fromCharCode(...bytes.subarray(1, 6)) !== "NUMPY") {
|
||||||
|
throw new M49TgsFullShadowContractError("M49 playback NPY: неверная сигнатура.");
|
||||||
|
}
|
||||||
|
const major = bytes[6];
|
||||||
|
const headerLength = major === 1
|
||||||
|
? new DataView(buffer).getUint16(8, true)
|
||||||
|
: major === 2 || major === 3
|
||||||
|
? new DataView(buffer).getUint32(8, true)
|
||||||
|
: -1;
|
||||||
|
const headerOffset = major === 1 ? 10 : 12;
|
||||||
|
if (headerLength < 0 || headerOffset + headerLength > bytes.byteLength) {
|
||||||
|
throw new M49TgsFullShadowContractError("M49 playback NPY: неверный заголовок.");
|
||||||
|
}
|
||||||
|
const header = new TextDecoder("latin1").decode(bytes.subarray(headerOffset, headerOffset + headerLength));
|
||||||
|
const dtype = header.match(/["']descr["']\s*:\s*["']([^"']+)["']/)?.[1];
|
||||||
|
const fortran = header.match(/["']fortran_order["']\s*:\s*(True|False)/)?.[1];
|
||||||
|
const rawShape = header.match(/["']shape["']\s*:\s*\(([^)]*)\)/)?.[1];
|
||||||
|
const shape = rawShape?.split(",").map((value) => value.trim()).filter(Boolean).map(Number) ?? [];
|
||||||
|
if (dtype !== expectedDtype || fortran !== "False" || !equalShape(shape, expectedShape)) {
|
||||||
|
throw new M49TgsFullShadowContractError("M49 playback NPY: dtype или shape изменились.");
|
||||||
|
}
|
||||||
|
const count = expectedShape.reduce((product, value) => product * value, 1);
|
||||||
|
const dataOffset = headerOffset + headerLength;
|
||||||
|
const bytesPerValue = expectedDtype === "<f4" ? 4 : 1;
|
||||||
|
if (dataOffset + count * bytesPerValue !== buffer.byteLength) {
|
||||||
|
throw new M49TgsFullShadowContractError("M49 playback NPY: длина payload изменилась.");
|
||||||
|
}
|
||||||
|
return expectedDtype === "<f4"
|
||||||
|
? new Float32Array(buffer, dataOffset, count)
|
||||||
|
: new Uint8Array(buffer, dataOffset, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePlaybackFrames(buffer: ArrayBuffer): readonly M49TgsFullShadowPlaybackFrame[] {
|
||||||
|
const lines = new TextDecoder().decode(buffer).trim().split("\n");
|
||||||
|
if (lines.length !== 4489) {
|
||||||
|
throw new M49TgsFullShadowContractError("M49 playback frames: неверная размерность.");
|
||||||
|
}
|
||||||
|
return lines.map((line, sourceSequence) => {
|
||||||
|
const row = objectValue(JSON.parse(line), `M49 playback frame ${sourceSequence}`);
|
||||||
|
return {
|
||||||
|
sourceSequence,
|
||||||
|
sourceFrameIndex: integer(row.source_frame_index, `M49 playback frame ${sourceSequence}.source`),
|
||||||
|
sessionSeconds: numberValue(row.session_seconds, `M49 playback frame ${sourceSequence}.time`),
|
||||||
|
sampleAvailable: booleanValue(row.sample_available, `M49 playback frame ${sourceSequence}.available`),
|
||||||
|
metrics: {
|
||||||
|
eligiblePointCount: integer(row.eligible_point_count, `M49 playback frame ${sourceSequence}.eligible`),
|
||||||
|
groundPointCount: integer(row.ground_point_count, `M49 playback frame ${sourceSequence}.ground`),
|
||||||
|
nongroundPointCount: integer(row.nonground_point_count, `M49 playback frame ${sourceSequence}.nonground`),
|
||||||
|
rejectedPointCount: integer(row.rejected_point_count, `M49 playback frame ${sourceSequence}.rejected`),
|
||||||
|
occupiedCellCount: integer(row.occupied_cell_count, `M49 playback frame ${sourceSequence}.occupied`),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchM49TgsFullShadowPlaybackPack(
|
||||||
|
id: string,
|
||||||
|
{
|
||||||
|
fetcher = fetch,
|
||||||
|
signal,
|
||||||
|
onProgress,
|
||||||
|
}: {
|
||||||
|
fetcher?: LaboratoryFetch;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onProgress?: (progress: M49TgsFullShadowPlaybackProgress) => void;
|
||||||
|
} = {},
|
||||||
|
): Promise<M49TgsFullShadowPlaybackPack> {
|
||||||
|
if (!RESULT_ID.test(id)) {
|
||||||
|
throw new M49TgsFullShadowContractError("M49 playback identity недопустима.");
|
||||||
|
}
|
||||||
|
onProgress?.({ phase: "manifest", trackId: null, loadedBytes: 0, totalBytes: 0 });
|
||||||
|
const manifest = await fetchPlaybackManifest(id, fetcher, signal);
|
||||||
|
const buffers = new Map<M49TgsFullShadowPlaybackTrackId, ArrayBuffer>();
|
||||||
|
let completedBytes = 0;
|
||||||
|
const order: readonly M49TgsFullShadowPlaybackTrackId[] = ["frames", "centers", "states", "z-bounds"];
|
||||||
|
for (const trackId of order) {
|
||||||
|
const track = manifest.tracks.get(trackId);
|
||||||
|
if (!track) throw new M49TgsFullShadowContractError(`M49 playback ${trackId}: дорожка отсутствует.`);
|
||||||
|
const buffer = await fetchPlaybackTrack(
|
||||||
|
track,
|
||||||
|
fetcher,
|
||||||
|
signal,
|
||||||
|
completedBytes,
|
||||||
|
manifest.totalByteLength,
|
||||||
|
onProgress,
|
||||||
|
);
|
||||||
|
buffers.set(trackId, buffer);
|
||||||
|
completedBytes += track.byteLength;
|
||||||
|
}
|
||||||
|
const centersTrack = manifest.tracks.get("centers")!;
|
||||||
|
const statesTrack = manifest.tracks.get("states")!;
|
||||||
|
const zBoundsTrack = manifest.tracks.get("z-bounds")!;
|
||||||
|
const centersRaw = parseM49TgsNpyTrack(
|
||||||
|
buffers.get("centers")!,
|
||||||
|
"<f4",
|
||||||
|
centersTrack.shape,
|
||||||
|
) as Float32Array;
|
||||||
|
const centersXyM = Array.from({ length: manifest.cellCount }, (_, index) => (
|
||||||
|
[centersRaw[index * 2]!, centersRaw[index * 2 + 1]!] as const
|
||||||
|
));
|
||||||
|
const states = parseM49TgsNpyTrack(
|
||||||
|
buffers.get("states")!,
|
||||||
|
"|u1",
|
||||||
|
statesTrack.shape,
|
||||||
|
) as Uint8Array;
|
||||||
|
const zBoundsM = parseM49TgsNpyTrack(
|
||||||
|
buffers.get("z-bounds")!,
|
||||||
|
"<f4",
|
||||||
|
zBoundsTrack.shape,
|
||||||
|
) as Float32Array;
|
||||||
|
const frames = parsePlaybackFrames(buffers.get("frames")!);
|
||||||
|
onProgress?.({
|
||||||
|
phase: "ready",
|
||||||
|
trackId: null,
|
||||||
|
loadedBytes: manifest.totalByteLength,
|
||||||
|
totalBytes: manifest.totalByteLength,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
resultId: id,
|
||||||
|
frameCount: manifest.frameCount,
|
||||||
|
cellCount: manifest.cellCount,
|
||||||
|
totalByteLength: manifest.totalByteLength,
|
||||||
|
centersXyM,
|
||||||
|
states,
|
||||||
|
zBoundsM,
|
||||||
|
frames,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -226,6 +226,20 @@ export interface M4ThreatTimelineChunk {
|
|||||||
frames: readonly M4ThreatTimelineFrame[];
|
frames: readonly M4ThreatTimelineFrame[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface M4ThreatPlaybackProgress {
|
||||||
|
phase: "manifest" | "download" | "verify" | "ready";
|
||||||
|
loadedBytes: number;
|
||||||
|
totalBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface M4ThreatPlaybackPointPack {
|
||||||
|
resultId: string;
|
||||||
|
frameCount: 4489;
|
||||||
|
pointCount: number;
|
||||||
|
pointOffsets: Uint32Array;
|
||||||
|
pointsMapXyzM: Float32Array;
|
||||||
|
}
|
||||||
|
|
||||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||||
export const M4_THREAT_TIMELINE_ENDPOINT_ROOT = "/api/v1/laboratory/m4-threat/results";
|
export const M4_THREAT_TIMELINE_ENDPOINT_ROOT = "/api/v1/laboratory/m4-threat/results";
|
||||||
class M4ThreatContractError extends Error {}
|
class M4ThreatContractError extends Error {}
|
||||||
@@ -803,11 +817,13 @@ export async function fetchM4ThreatTimelineChunk(
|
|||||||
signal,
|
signal,
|
||||||
endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT,
|
endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT,
|
||||||
cameraObstacleProjectionDelivery = null,
|
cameraObstacleProjectionDelivery = null,
|
||||||
|
playbackPointPack,
|
||||||
}: {
|
}: {
|
||||||
fetcher?: LaboratoryFetch;
|
fetcher?: LaboratoryFetch;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
endpointRoot?: string;
|
endpointRoot?: string;
|
||||||
cameraObstacleProjectionDelivery?: M4ThreatTimeline["cameraObstacleProjectionDelivery"];
|
cameraObstacleProjectionDelivery?: M4ThreatTimeline["cameraObstacleProjectionDelivery"];
|
||||||
|
playbackPointPack?: M4ThreatPlaybackPointPack;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<M4ThreatTimelineChunk> {
|
): Promise<M4ThreatTimelineChunk> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -817,6 +833,7 @@ export async function fetchM4ThreatTimelineChunk(
|
|||||||
if (cameraObstacleProjectionDelivery !== null) {
|
if (cameraObstacleProjectionDelivery !== null) {
|
||||||
params.set("obstacle_projection", cameraObstacleProjectionDelivery);
|
params.set("obstacle_projection", cameraObstacleProjectionDelivery);
|
||||||
}
|
}
|
||||||
|
if (playbackPointPack) params.set("include_points", "false");
|
||||||
const response = await fetcher(
|
const response = await fetcher(
|
||||||
`${endpointRoot}/${result}/timeline/chunk?${params}`,
|
`${endpointRoot}/${result}/timeline/chunk?${params}`,
|
||||||
{ headers: { Accept: "application/json" }, signal },
|
{ headers: { Accept: "application/json" }, signal },
|
||||||
@@ -834,8 +851,12 @@ export async function fetchM4ThreatTimelineChunk(
|
|||||||
if (parsedStart !== startSequence) {
|
if (parsedStart !== startSequence) {
|
||||||
throw new M4ThreatContractError("M4.6 timeline chunk start: нарушен контракт.");
|
throw new M4ThreatContractError("M4.6 timeline chunk start: нарушен контракт.");
|
||||||
}
|
}
|
||||||
const frames = array(payload.frames, "M4.6 timeline frames").map((raw, offset) =>
|
const frames = array(payload.frames, "M4.6 timeline frames").map((raw, offset) => {
|
||||||
parseTimelineFrame(raw, result, parsedStart + offset, endpointRoot));
|
const parsed = parseTimelineFrame(raw, result, parsedStart + offset, endpointRoot);
|
||||||
|
return playbackPointPack
|
||||||
|
? hydrateM4ThreatTimelineFrame(parsed, playbackPointPack)
|
||||||
|
: parsed;
|
||||||
|
});
|
||||||
const parsedCount = integer(payload.frame_count, "M4.6 timeline chunk count");
|
const parsedCount = integer(payload.frame_count, "M4.6 timeline chunk count");
|
||||||
if (parsedCount !== frames.length || parsedCount > frameCount) {
|
if (parsedCount !== frames.length || parsedCount > frameCount) {
|
||||||
throw new M4ThreatContractError("M4.6 timeline chunk count: нарушен контракт.");
|
throw new M4ThreatContractError("M4.6 timeline chunk count: нарушен контракт.");
|
||||||
@@ -851,6 +872,159 @@ export async function fetchM4ThreatTimelineChunk(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function playbackSha256Hex(buffer: ArrayBuffer): Promise<string> {
|
||||||
|
if (!globalThis.crypto?.subtle) {
|
||||||
|
throw new M4ThreatContractError("Браузер не поддерживает проверку playback SHA-256.");
|
||||||
|
}
|
||||||
|
return globalThis.crypto.subtle.digest("SHA-256", buffer).then((digest) => (
|
||||||
|
[...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundedBodyCoordinate(value: number): number {
|
||||||
|
return Math.round(value * 1_000_000) / 1_000_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hydrateM4ThreatTimelineFrame(
|
||||||
|
frame: M4ThreatTimelineFrame,
|
||||||
|
pack: M4ThreatPlaybackPointPack,
|
||||||
|
): M4ThreatTimelineFrame {
|
||||||
|
if (frame.sequence >= pack.frameCount || pack.resultId === "") {
|
||||||
|
throw new M4ThreatContractError("M4.6 binary playback frame: нарушена идентичность.");
|
||||||
|
}
|
||||||
|
if (!frame.sourceAvailable || !frame.spatialAvailable || !frame.bodyFrame) {
|
||||||
|
if (frame.pointCloudSourceCount !== 0) {
|
||||||
|
throw new M4ThreatContractError("M4.6 binary playback unavailable frame содержит точки.");
|
||||||
|
}
|
||||||
|
return { ...frame, pointCloudBodyXyzM: [], pointCloudSampleCount: 0 };
|
||||||
|
}
|
||||||
|
const start = pack.pointOffsets[frame.sequence];
|
||||||
|
const stop = pack.pointOffsets[frame.sequence + 1];
|
||||||
|
if (start === undefined || stop === undefined || stop < start || stop > pack.pointCount) {
|
||||||
|
throw new M4ThreatContractError("M4.6 binary playback offsets: нарушена размерность.");
|
||||||
|
}
|
||||||
|
const count = stop - start;
|
||||||
|
if (frame.pointCloudSourceCount !== count || frame.pointCloudSampleCount !== count) {
|
||||||
|
throw new M4ThreatContractError("M4.6 binary playback point accounting: нарушен контракт.");
|
||||||
|
}
|
||||||
|
const origin = frame.bodyFrame.originMapXyzM;
|
||||||
|
const basis = frame.bodyFrame.basisMapFromBody;
|
||||||
|
const points = new Array<M4Point3>(count);
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const sourceOffset = (start + index) * 3;
|
||||||
|
const dx = pack.pointsMapXyzM[sourceOffset]! - origin[0];
|
||||||
|
const dy = pack.pointsMapXyzM[sourceOffset + 1]! - origin[1];
|
||||||
|
const dz = pack.pointsMapXyzM[sourceOffset + 2]! - origin[2];
|
||||||
|
points[index] = [
|
||||||
|
roundedBodyCoordinate(dx * basis[0][0] + dy * basis[1][0] + dz * basis[2][0]),
|
||||||
|
roundedBodyCoordinate(dx * basis[0][1] + dy * basis[1][1] + dz * basis[2][1]),
|
||||||
|
roundedBodyCoordinate(dx * basis[0][2] + dy * basis[1][2] + dz * basis[2][2]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return { ...frame, pointCloudBodyXyzM: points, pointCloudSampleCount: 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> {
|
||||||
|
resultId(result);
|
||||||
|
onProgress?.({ phase: "manifest", loadedBytes: 0, totalBytes: 0 });
|
||||||
|
const manifestResponse = await fetcher(`${endpointRoot}/${result}/timeline/playback`, {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!manifestResponse.ok) {
|
||||||
|
throw new M4ThreatContractError(`M4.6 playback manifest: HTTP ${manifestResponse.status}.`);
|
||||||
|
}
|
||||||
|
const manifest = object(await manifestResponse.json(), "M4.6 playback manifest");
|
||||||
|
exact(manifest.schema_version, "missioncore.recorded-spatial-playback/v1", "M4.6 playback schema");
|
||||||
|
exact(manifest.result_id, result, "M4.6 playback result");
|
||||||
|
exact(manifest.coordinate_frame, "map", "M4.6 playback coordinate frame");
|
||||||
|
exact(manifest.access, "read-only-sealed-binary-playback", "M4.6 playback access");
|
||||||
|
const frameCount = exact(integer(manifest.frame_count, "M4.6 playback frames"), 4489, "M4.6 playback frames");
|
||||||
|
const pointCount = integer(manifest.point_count, "M4.6 playback points");
|
||||||
|
const offsetsRaw = array(manifest.point_offsets, "M4.6 playback offsets");
|
||||||
|
if (offsetsRaw.length !== frameCount + 1) {
|
||||||
|
throw new M4ThreatContractError("M4.6 playback offsets: неверная размерность.");
|
||||||
|
}
|
||||||
|
const pointOffsets = Uint32Array.from(offsetsRaw, (value) => integer(value, "M4.6 playback offset"));
|
||||||
|
if (pointOffsets[0] !== 0 || pointOffsets[pointOffsets.length - 1] !== pointCount) {
|
||||||
|
throw new M4ThreatContractError("M4.6 playback offsets: нарушено замыкание.");
|
||||||
|
}
|
||||||
|
for (let index = 1; index < pointOffsets.length; index += 1) {
|
||||||
|
if (pointOffsets[index]! < pointOffsets[index - 1]!) {
|
||||||
|
throw new M4ThreatContractError("M4.6 playback offsets: нарушена монотонность.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const track = object(manifest.track, "M4.6 playback track");
|
||||||
|
exact(track.id, "points-map-f32", "M4.6 playback track id");
|
||||||
|
exact(track.dtype, "<f4", "M4.6 playback dtype");
|
||||||
|
const shape = array(track.shape, "M4.6 playback shape").map((value) => integer(value, "M4.6 playback shape"));
|
||||||
|
if (shape.length !== 2 || shape[0] !== pointCount || shape[1] !== 3) {
|
||||||
|
throw new M4ThreatContractError("M4.6 playback shape: нарушена размерность.");
|
||||||
|
}
|
||||||
|
const byteLength = integer(track.bytes, "M4.6 playback bytes");
|
||||||
|
if (byteLength !== pointCount * 3 * Float32Array.BYTES_PER_ELEMENT) {
|
||||||
|
throw new M4ThreatContractError("M4.6 playback bytes: нарушен accounting.");
|
||||||
|
}
|
||||||
|
const sha256 = text(track.sha256, "M4.6 playback SHA-256");
|
||||||
|
if (!/^[a-f0-9]{64}$/.test(sha256)) {
|
||||||
|
throw new M4ThreatContractError("M4.6 playback SHA-256: нарушен контракт.");
|
||||||
|
}
|
||||||
|
const response = await fetcher(text(track.url, "M4.6 playback URL"), {
|
||||||
|
headers: { Accept: "application/octet-stream" },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
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 {
|
||||||
|
resultId: result,
|
||||||
|
frameCount,
|
||||||
|
pointCount,
|
||||||
|
pointOffsets,
|
||||||
|
pointsMapXyzM: new Float32Array(bytes.buffer),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchM4ThreatCameraPointOverlay(
|
export async function fetchM4ThreatCameraPointOverlay(
|
||||||
result: string,
|
result: string,
|
||||||
sequence: number,
|
sequence: number,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
RecordedEvidenceSemanticClass,
|
RecordedEvidenceSemanticClass,
|
||||||
@@ -9,10 +9,11 @@ import {
|
|||||||
type E47SemanticSlamResult,
|
type E47SemanticSlamResult,
|
||||||
} from "../../core/laboratory/e47SemanticSlam";
|
} from "../../core/laboratory/e47SemanticSlam";
|
||||||
import {
|
import {
|
||||||
fetchM49TgsFullShadowSpatialChunk,
|
fetchM49TgsFullShadowPlaybackPack,
|
||||||
type M49TgsFullShadowResult,
|
type M49TgsFullShadowResult,
|
||||||
|
type M49TgsFullShadowPlaybackPack,
|
||||||
|
type M49TgsFullShadowPlaybackProgress,
|
||||||
type M49TgsFullShadowSpatial,
|
type M49TgsFullShadowSpatial,
|
||||||
type M49TgsFullShadowSpatialChunk,
|
|
||||||
type M49TgsFullShadowStateCode,
|
type M49TgsFullShadowStateCode,
|
||||||
} from "../../core/laboratory/m49TgsFullShadow";
|
} from "../../core/laboratory/m49TgsFullShadow";
|
||||||
import {
|
import {
|
||||||
@@ -32,9 +33,6 @@ const PALETTE: readonly RecordedEvidenceSemanticPaletteEntry[] = [
|
|||||||
{ classId: 3, color: { kind: "token", token: "--nodedc-warning-rgb" } },
|
{ classId: 3, color: { kind: "token", token: "--nodedc-warning-rgb" } },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CHUNK_FRAMES = 24;
|
|
||||||
const RETAINED_CHUNKS = 4;
|
|
||||||
|
|
||||||
function cellState(code: M49TgsFullShadowStateCode): M4ReplayClassifiedSpatialFrame["cellsMapGravityLocal"][number]["state"] {
|
function cellState(code: M49TgsFullShadowStateCode): M4ReplayClassifiedSpatialFrame["cellsMapGravityLocal"][number]["state"] {
|
||||||
if (code === 1) return "ground-support";
|
if (code === 1) return "ground-support";
|
||||||
if (code === 2) return "nonground-occupied";
|
if (code === 2) return "nonground-occupied";
|
||||||
@@ -52,16 +50,14 @@ export function M49TgsFullShadowEvidence({ result }: { result: M49TgsFullShadowR
|
|||||||
const [activeSequence, setActiveSequence] = useState<number | null>(null);
|
const [activeSequence, setActiveSequence] = useState<number | null>(null);
|
||||||
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 [chunks, setChunks] = useState<ReadonlyMap<number, M49TgsFullShadowSpatialChunk>>(
|
const [playbackPack, setPlaybackPack] = useState<M49TgsFullShadowPlaybackPack | null>(null);
|
||||||
() => new Map(),
|
const [playbackProgress, setPlaybackProgress] = useState<M49TgsFullShadowPlaybackProgress>({
|
||||||
);
|
phase: "manifest",
|
||||||
|
trackId: null,
|
||||||
|
loadedBytes: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
});
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const inFlightRef = useRef(new Map<number, AbortController>());
|
|
||||||
const activeChunkStart = activeSequence === null
|
|
||||||
? null
|
|
||||||
: Math.floor(activeSequence / CHUNK_FRAMES) * CHUNK_FRAMES;
|
|
||||||
const activeChunkStartRef = useRef(activeChunkStart);
|
|
||||||
activeChunkStartRef.current = activeChunkStart;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -85,65 +81,64 @@ export function M49TgsFullShadowEvidence({ result }: { result: M49TgsFullShadowR
|
|||||||
}, [result.source.linkedSemanticResultId, result.source.linkedVisualResultId]);
|
}, [result.source.linkedSemanticResultId, result.source.linkedVisualResultId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
for (const controller of inFlightRef.current.values()) controller.abort();
|
const controller = new AbortController();
|
||||||
inFlightRef.current.clear();
|
setPlaybackPack(null);
|
||||||
setChunks(new Map());
|
setPlaybackProgress({ phase: "manifest", trackId: null, loadedBytes: 0, totalBytes: 0 });
|
||||||
setError(null);
|
setError(null);
|
||||||
return () => {
|
void fetchM49TgsFullShadowPlaybackPack(result.resultId, {
|
||||||
for (const controller of inFlightRef.current.values()) controller.abort();
|
signal: controller.signal,
|
||||||
inFlightRef.current.clear();
|
onProgress: setPlaybackProgress,
|
||||||
};
|
}).then((pack) => {
|
||||||
|
if (!controller.signal.aborted) setPlaybackPack(pack);
|
||||||
|
}).catch((caught: unknown) => {
|
||||||
|
if (!controller.signal.aborted) setError(message(caught));
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
}, [result.resultId]);
|
}, [result.resultId]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeChunkStart === null) return;
|
|
||||||
const desiredStarts = [activeChunkStart, activeChunkStart + CHUNK_FRAMES]
|
|
||||||
.filter((start) => start < result.timeline.frameCount);
|
|
||||||
const desired = new Set(desiredStarts);
|
|
||||||
for (const [start, controller] of inFlightRef.current) {
|
|
||||||
if (desired.has(start)) continue;
|
|
||||||
controller.abort();
|
|
||||||
inFlightRef.current.delete(start);
|
|
||||||
}
|
|
||||||
for (const start of desiredStarts) {
|
|
||||||
if (chunks.has(start) || inFlightRef.current.has(start)) continue;
|
|
||||||
const controller = new AbortController();
|
|
||||||
inFlightRef.current.set(start, controller);
|
|
||||||
void fetchM49TgsFullShadowSpatialChunk(result.resultId, start, CHUNK_FRAMES, {
|
|
||||||
signal: controller.signal,
|
|
||||||
})
|
|
||||||
.then((chunk) => {
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
setChunks((current) => {
|
|
||||||
const next = new Map(current);
|
|
||||||
next.set(start, chunk);
|
|
||||||
const center = activeChunkStartRef.current ?? start;
|
|
||||||
const retained = [...next.keys()]
|
|
||||||
.sort((left, right) => Math.abs(left - center) - Math.abs(right - center))
|
|
||||||
.slice(0, RETAINED_CHUNKS);
|
|
||||||
return new Map(retained.map((key) => [key, next.get(key)!]));
|
|
||||||
});
|
|
||||||
if (start === activeChunkStartRef.current) setError(null);
|
|
||||||
})
|
|
||||||
.catch((caught: unknown) => {
|
|
||||||
if (!controller.signal.aborted && start === activeChunkStartRef.current) {
|
|
||||||
setError(message(caught));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (inFlightRef.current.get(start) === controller) inFlightRef.current.delete(start);
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}, [activeChunkStart, chunks, result.resultId, result.timeline.frameCount]);
|
|
||||||
|
|
||||||
const spatial = useMemo<M49TgsFullShadowSpatial | null>(() => {
|
const spatial = useMemo<M49TgsFullShadowSpatial | null>(() => {
|
||||||
if (activeSequence === null || activeChunkStart === null) return null;
|
if (activeSequence === null || !playbackPack) return null;
|
||||||
return chunks.get(activeChunkStart)?.frames.find(
|
const frame = playbackPack.frames[activeSequence];
|
||||||
(frame) => frame.sourceSequence === activeSequence,
|
if (!frame) return null;
|
||||||
) ?? null;
|
const cellOffset = activeSequence * playbackPack.cellCount;
|
||||||
}, [activeChunkStart, activeSequence, chunks]);
|
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 {
|
||||||
|
resultId: playbackPack.resultId,
|
||||||
|
sourceSequence: activeSequence,
|
||||||
|
sourceFrameIndex: frame.sourceFrameIndex,
|
||||||
|
sessionSeconds: frame.sessionSeconds,
|
||||||
|
sampleAvailable: frame.sampleAvailable,
|
||||||
|
costmap: {
|
||||||
|
cellSizeM: result.configuration.cellSizeM,
|
||||||
|
radiusM: result.configuration.radiusM,
|
||||||
|
centersXyM: playbackPack.centersXyM,
|
||||||
|
states,
|
||||||
|
zBoundsM,
|
||||||
|
},
|
||||||
|
metrics: frame.metrics,
|
||||||
|
};
|
||||||
|
}, [activeSequence, playbackPack, result.configuration.cellSizeM, result.configuration.radiusM]);
|
||||||
const loading = activeSequence !== null && !spatial && !error;
|
const loading = activeSequence !== null && !spatial && !error;
|
||||||
|
const progressPercent = playbackProgress.totalBytes > 0
|
||||||
|
? Math.min(100, Math.round((playbackProgress.loadedBytes / playbackProgress.totalBytes) * 100))
|
||||||
|
: 0;
|
||||||
|
const loadingLabel = playbackProgress.phase === "manifest"
|
||||||
|
? "Проверяем playback manifest"
|
||||||
|
: playbackProgress.phase === "ready"
|
||||||
|
? "TGS playback готов"
|
||||||
|
: `Подготавливаем TGS playback · ${progressPercent}% · ${(playbackProgress.loadedBytes / 1_048_576).toFixed(1)}/${(playbackProgress.totalBytes / 1_048_576).toFixed(1)} МБ`;
|
||||||
|
|
||||||
const classifiedFrame = useMemo<M4ReplayClassifiedSpatialFrame | null>(() => {
|
const classifiedFrame = useMemo<M4ReplayClassifiedSpatialFrame | null>(() => {
|
||||||
if (!spatial) return null;
|
if (!spatial) return null;
|
||||||
@@ -188,6 +183,7 @@ export function M49TgsFullShadowEvidence({ result }: { result: M49TgsFullShadowR
|
|||||||
expectedAtSequence: true,
|
expectedAtSequence: true,
|
||||||
frame: classifiedFrame,
|
frame: classifiedFrame,
|
||||||
loading,
|
loading,
|
||||||
|
loadingLabel,
|
||||||
error,
|
error,
|
||||||
replacePointCloud: false,
|
replacePointCloud: false,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export interface M4ReplayClassifiedSpatialLayer {
|
|||||||
expectedAtSequence: boolean;
|
expectedAtSequence: boolean;
|
||||||
frame: M4ReplayClassifiedSpatialFrame | null;
|
frame: M4ReplayClassifiedSpatialFrame | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
loadingLabel?: string;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
replacePointCloud?: boolean;
|
replacePointCloud?: boolean;
|
||||||
}
|
}
|
||||||
@@ -850,7 +851,9 @@ export function M4ReplayThreatVisual({
|
|||||||
: 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 недоступен для этого кадра"
|
||||||
: classifiedSpatialLayer.error ?? `Открываем ${classifiedSpatialLayer.label}`
|
: classifiedSpatialLayer.error
|
||||||
|
?? classifiedSpatialLayer.loadingLabel
|
||||||
|
?? `Открываем ${classifiedSpatialLayer.label}`
|
||||||
: (
|
: (
|
||||||
<>
|
<>
|
||||||
{spatialFrame
|
{spatialFrame
|
||||||
@@ -1022,7 +1025,7 @@ export function M4ReplayThreatVisual({
|
|||||||
: <Icon name="alert" size={18} />}
|
: <Icon name="alert" size={18} />}
|
||||||
<span>{classifiedSpatialLayer.error
|
<span>{classifiedSpatialLayer.error
|
||||||
?? (classifiedSpatialLayer.loading || displayingBufferedFrame
|
?? (classifiedSpatialLayer.loading || displayingBufferedFrame
|
||||||
? `Открываем ${classifiedSpatialLayer.label}`
|
? classifiedSpatialLayer.loadingLabel ?? `Открываем ${classifiedSpatialLayer.label}`
|
||||||
: classifiedSpatialLayer.expectedAtSequence
|
: classifiedSpatialLayer.expectedAtSequence
|
||||||
? `Открываем ${classifiedSpatialLayer.label}`
|
? `Открываем ${classifiedSpatialLayer.label}`
|
||||||
: `${classifiedSpatialLayer.label} рассчитан только на 10 контрольных кадров.`)}</span>
|
: `${classifiedSpatialLayer.label} рассчитан только на 10 контрольных кадров.`)}</span>
|
||||||
@@ -1071,7 +1074,7 @@ export function M4ReplayThreatVisual({
|
|||||||
{timelineFrame.loading || displayingBufferedFrame ? (
|
{timelineFrame.loading || displayingBufferedFrame ? (
|
||||||
<div className="m4-replay-threat-visual__buffering" role="status">
|
<div className="m4-replay-threat-visual__buffering" role="status">
|
||||||
<span className="busy-indicator" aria-hidden="true" />
|
<span className="busy-indicator" aria-hidden="true" />
|
||||||
<span>Догружаем следующий spatial-буфер без сброса сцены</span>
|
<span>{timelineFrame.loadingLabel ?? "Догружаем следующий spatial-буфер без сброса сцены"}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{timelineFrame.error ? (
|
{timelineFrame.error ? (
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
fetchM4ThreatCameraPointOverlay,
|
fetchM4ThreatCameraPointOverlay,
|
||||||
|
fetchM4ThreatPlaybackPointPack,
|
||||||
fetchM4ThreatTimeline,
|
fetchM4ThreatTimeline,
|
||||||
fetchM4ThreatTimelineChunk,
|
fetchM4ThreatTimelineChunk,
|
||||||
|
M4_THREAT_TIMELINE_ENDPOINT_ROOT,
|
||||||
selectM4ThreatTimelineSequence,
|
selectM4ThreatTimelineSequence,
|
||||||
type M4ThreatCameraPointOverlay,
|
type M4ThreatCameraPointOverlay,
|
||||||
|
type M4ThreatPlaybackPointPack,
|
||||||
|
type M4ThreatPlaybackProgress,
|
||||||
type M4ThreatTimeline,
|
type M4ThreatTimeline,
|
||||||
type M4ThreatTimelineChunk,
|
type M4ThreatTimelineChunk,
|
||||||
type M4ThreatTimelineFrame,
|
type M4ThreatTimelineFrame,
|
||||||
@@ -82,11 +86,48 @@ 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 [playbackProgress, setPlaybackProgress] = useState<M4ThreatPlaybackProgress>({
|
||||||
|
phase: "manifest",
|
||||||
|
loadedBytes: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
});
|
||||||
|
const [playbackError, setPlaybackError] = useState<string | null>(null);
|
||||||
|
const binaryPlayback = endpointRoot === undefined
|
||||||
|
|| endpointRoot === M4_THREAT_TIMELINE_ENDPOINT_ROOT;
|
||||||
const inFlight = useRef(new Map<number, AbortController>());
|
const inFlight = useRef(new Map<number, AbortController>());
|
||||||
const chunksRef = useRef(chunks);
|
const chunksRef = useRef(chunks);
|
||||||
const activeChunkStartRef = useRef<number | null>(null);
|
const activeChunkStartRef = useRef<number | null>(null);
|
||||||
chunksRef.current = chunks;
|
chunksRef.current = chunks;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setPointPack(null);
|
||||||
|
setPlaybackError(null);
|
||||||
|
setPlaybackProgress({ phase: "manifest", loadedBytes: 0, totalBytes: 0 });
|
||||||
|
if (!timeline) return () => controller.abort();
|
||||||
|
if (!binaryPlayback) {
|
||||||
|
setPlaybackProgress({ phase: "ready", loadedBytes: 0, totalBytes: 0 });
|
||||||
|
return () => controller.abort();
|
||||||
|
}
|
||||||
|
void fetchM4ThreatPlaybackPointPack(resultId, {
|
||||||
|
signal: controller.signal,
|
||||||
|
endpointRoot,
|
||||||
|
onProgress: (progress) => {
|
||||||
|
if (!controller.signal.aborted) setPlaybackProgress(progress);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((pack) => {
|
||||||
|
if (!controller.signal.aborted) setPointPack(pack);
|
||||||
|
})
|
||||||
|
.catch((caught: unknown) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setPlaybackError(errorMessage(caught, "Бинарный spatial playback M4.6 недоступен."));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [binaryPlayback, endpointRoot, resultId, timeline]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
for (const controller of inFlight.current.values()) controller.abort();
|
for (const controller of inFlight.current.values()) controller.abort();
|
||||||
inFlight.current.clear();
|
inFlight.current.clear();
|
||||||
@@ -116,7 +157,7 @@ export function useM4ThreatTimelineFrame({
|
|||||||
activeChunkStartRef.current = activeChunkStart;
|
activeChunkStartRef.current = activeChunkStart;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!timeline || activeChunkStart === null) return;
|
if (!timeline || activeChunkStart === null || (binaryPlayback && !pointPack)) return;
|
||||||
const starts = m4ThreatChunkWindowStarts(
|
const starts = m4ThreatChunkWindowStarts(
|
||||||
activeChunkStart,
|
activeChunkStart,
|
||||||
chunkSize,
|
chunkSize,
|
||||||
@@ -131,6 +172,7 @@ export function useM4ThreatTimelineFrame({
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
endpointRoot,
|
endpointRoot,
|
||||||
cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery,
|
cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery,
|
||||||
|
playbackPointPack: binaryPlayback ? pointPack ?? undefined : undefined,
|
||||||
})
|
})
|
||||||
.then((chunk) => {
|
.then((chunk) => {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
@@ -161,7 +203,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, chunkSize, chunks, endpointRoot, resultId, timeline]);
|
}, [activeChunkStart, binaryPlayback, chunkSize, chunks, endpointRoot, pointPack, 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;
|
||||||
@@ -181,8 +223,17 @@ export function useM4ThreatTimelineFrame({
|
|||||||
activeSequence,
|
activeSequence,
|
||||||
activeFrame,
|
activeFrame,
|
||||||
availableFrames,
|
availableFrames,
|
||||||
loading: error === null && Boolean(timeline) && !activeFrame,
|
loading: error === null && playbackError === null && Boolean(timeline) && !activeFrame,
|
||||||
error,
|
loadingLabel: !binaryPlayback
|
||||||
|
? "Догружаем следующий spatial-буфер без сброса сцены"
|
||||||
|
: playbackProgress.phase === "manifest"
|
||||||
|
? "Проверяем M4 playback manifest"
|
||||||
|
: playbackProgress.phase === "ready"
|
||||||
|
? "Подготавливаем текущий spatial-кадр"
|
||||||
|
: `${playbackProgress.phase === "verify" ? "Проверяем" : "Загружаем"} M4 spatial playback · ${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)} МБ`
|
||||||
|
: "0%"}`,
|
||||||
|
error: playbackError ?? error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createServer } from "vite";
|
|||||||
|
|
||||||
let server;
|
let server;
|
||||||
let fetchM49TgsFullShadowSpatialChunk;
|
let fetchM49TgsFullShadowSpatialChunk;
|
||||||
|
let parseM49TgsNpyTrack;
|
||||||
|
|
||||||
const resultId = `m49-tgs-full-shadow-${"a".repeat(64)}`;
|
const resultId = `m49-tgs-full-shadow-${"a".repeat(64)}`;
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ before(async () => {
|
|||||||
logLevel: "silent",
|
logLevel: "silent",
|
||||||
server: { middlewareMode: true },
|
server: { middlewareMode: true },
|
||||||
});
|
});
|
||||||
({ fetchM49TgsFullShadowSpatialChunk } = await server.ssrLoadModule(
|
({ fetchM49TgsFullShadowSpatialChunk, parseM49TgsNpyTrack } = await server.ssrLoadModule(
|
||||||
"/src/core/laboratory/m49TgsFullShadow.ts",
|
"/src/core/laboratory/m49TgsFullShadow.ts",
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
@@ -73,7 +74,7 @@ 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 prefetches 24-frame immutable chunks", async () => {
|
test("M4.9T5 viewer preloads one immutable binary playback pack", async () => {
|
||||||
const [source, contract] = await Promise.all([
|
const [source, 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),
|
||||||
@@ -84,12 +85,37 @@ test("M4.9T5 viewer prefetches 24-frame immutable chunks", async () => {
|
|||||||
"utf8",
|
"utf8",
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
assert.match(source, /const CHUNK_FRAMES = 24/);
|
assert.match(source, /fetchM49TgsFullShadowPlaybackPack/);
|
||||||
assert.match(source, /activeChunkStart \+ CHUNK_FRAMES/);
|
assert.match(source, /playbackProgress/);
|
||||||
assert.match(source, /fetchM49TgsFullShadowSpatialChunk/);
|
assert.doesNotMatch(source, /fetchM49TgsFullShadowSpatialChunk/);
|
||||||
assert.match(source, /sampleAvailable: spatial\.sampleAvailable/);
|
assert.match(source, /sampleAvailable: spatial\.sampleAvailable/);
|
||||||
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(contract, /linked_semantic_result_id/);
|
assert.match(contract, /linked_semantic_result_id/);
|
||||||
|
assert.match(contract, /missioncore\.m49-tgs-full-shadow-playback\/v1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
function npyFloat32(values, shape) {
|
||||||
|
const shapeText = shape.length === 1 ? `${shape[0]},` : shape.join(", ");
|
||||||
|
const prefixLength = 10;
|
||||||
|
let header = `{'descr': '<f4', 'fortran_order': False, 'shape': (${shapeText}), }`;
|
||||||
|
const padding = (16 - ((prefixLength + header.length + 1) % 16)) % 16;
|
||||||
|
header += " ".repeat(padding) + "\n";
|
||||||
|
const buffer = new ArrayBuffer(prefixLength + header.length + values.length * 4);
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
bytes.set([0x93, 0x4e, 0x55, 0x4d, 0x50, 0x59, 1, 0]);
|
||||||
|
new DataView(buffer).setUint16(8, header.length, true);
|
||||||
|
bytes.set(new TextEncoder().encode(header), prefixLength);
|
||||||
|
new Float32Array(buffer, prefixLength + header.length, values.length).set(values);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("M4.9T5 parses sealed NPY tracks without JSON point arrays", () => {
|
||||||
|
const parsed = parseM49TgsNpyTrack(
|
||||||
|
npyFloat32([1.25, -2.5, 3.75, 4.5], [2, 2]),
|
||||||
|
"<f4",
|
||||||
|
[2, 2],
|
||||||
|
);
|
||||||
|
assert.deepEqual([...parsed], [1.25, -2.5, 3.75, 4.5]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ let fetchM4ThreatVisual;
|
|||||||
let fetchM4ThreatTimeline;
|
let fetchM4ThreatTimeline;
|
||||||
let fetchM4ThreatTimelineChunk;
|
let fetchM4ThreatTimelineChunk;
|
||||||
let fetchM4ThreatCameraPointOverlay;
|
let fetchM4ThreatCameraPointOverlay;
|
||||||
|
let hydrateM4ThreatTimelineFrame;
|
||||||
let selectM4ThreatTimelineFrame;
|
let selectM4ThreatTimelineFrame;
|
||||||
let selectM4ThreatTimelineSequence;
|
let selectM4ThreatTimelineSequence;
|
||||||
let advanceRecordedEvidencePlayback;
|
let advanceRecordedEvidencePlayback;
|
||||||
@@ -33,6 +34,7 @@ before(async () => {
|
|||||||
fetchM4ThreatTimeline,
|
fetchM4ThreatTimeline,
|
||||||
fetchM4ThreatTimelineChunk,
|
fetchM4ThreatTimelineChunk,
|
||||||
fetchM4ThreatCameraPointOverlay,
|
fetchM4ThreatCameraPointOverlay,
|
||||||
|
hydrateM4ThreatTimelineFrame,
|
||||||
selectM4ThreatTimelineFrame,
|
selectM4ThreatTimelineFrame,
|
||||||
selectM4ThreatTimelineSequence,
|
selectM4ThreatTimelineSequence,
|
||||||
} = await server.ssrLoadModule("/src/core/laboratory/m4ReplayThreat.ts"));
|
} = await server.ssrLoadModule("/src/core/laboratory/m4ReplayThreat.ts"));
|
||||||
@@ -332,6 +334,45 @@ test("M4.6 timeline keeps only a compact index and decodes bounded spatial chunk
|
|||||||
assert.equal(selectM4ThreatTimelineFrame(chunk.frames, 35.50).sequence, 1);
|
assert.equal(selectM4ThreatTimelineFrame(chunk.frames, 35.50).sequence, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("M4.6 hydrates a lightweight timeline frame from one retained binary point track", async () => {
|
||||||
|
const raw = timelineFrame(0, 35.421857292, {
|
||||||
|
body_frame: {
|
||||||
|
origin_map_xyz_m: [10, 20, 30],
|
||||||
|
basis_map_from_body: [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
|
||||||
|
},
|
||||||
|
point_cloud_body_xyz_m: [],
|
||||||
|
point_cloud_source_count: 2,
|
||||||
|
point_cloud_sample_count: 2,
|
||||||
|
});
|
||||||
|
const parsed = await fetchM4ThreatTimelineChunk(resultId, 0, 1, {
|
||||||
|
playbackPointPack: {
|
||||||
|
resultId,
|
||||||
|
frameCount: 4489,
|
||||||
|
pointCount: 2,
|
||||||
|
pointOffsets: new Uint32Array([0, 2, ...Array(4488).fill(2)]),
|
||||||
|
pointsMapXyzM: new Float32Array([11, 20, 30.25, 12, 19.5, 30]),
|
||||||
|
},
|
||||||
|
fetcher: async (input) => {
|
||||||
|
assert.match(String(input), /include_points=false/);
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.recorded-spatial-evidence-chunk/v1",
|
||||||
|
result_id: resultId,
|
||||||
|
start_sequence: 0,
|
||||||
|
frame_count: 1,
|
||||||
|
next_sequence: 1,
|
||||||
|
frames: [raw],
|
||||||
|
authority: "replay-simulated",
|
||||||
|
}), { status: 200 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(parsed.frames[0].pointCloudBodyXyzM, [
|
||||||
|
[1, 0, 0.25],
|
||||||
|
[2, -0.5, 0],
|
||||||
|
]);
|
||||||
|
assert.equal(parsed.frames[0].pointCloudSampleCount, 2);
|
||||||
|
assert.equal(typeof hydrateM4ThreatTimelineFrame, "function");
|
||||||
|
});
|
||||||
|
|
||||||
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";
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ test("semantic evidence mask is GPU-colored, bounded, cancelled and object-conta
|
|||||||
assert.match(source, /Math\.min\(width \/ mask\.width, height \/ mask\.height\)/);
|
assert.match(source, /Math\.min\(width \/ mask\.width, height \/ mask\.height\)/);
|
||||||
assert.match(source, /decoded\.key !== expectedKey/);
|
assert.match(source, /decoded\.key !== expectedKey/);
|
||||||
assert.match(source, /mask\?\.key === expectedKey/);
|
assert.match(source, /mask\?\.key === expectedKey/);
|
||||||
|
assert.match(source, /lastReadyMaskRef/);
|
||||||
|
assert.match(source, /data-state=\{failure \? "error" : exactMask \? "ready" : renderMask \? "stale" : "loading"\}/);
|
||||||
assert.match(source, /recorded-evidence-semantic-mask-overlay__error/);
|
assert.match(source, /recorded-evidence-semantic-mask-overlay__error/);
|
||||||
assert.match(source, /role="alert"/);
|
assert.match(source, /role="alert"/);
|
||||||
});
|
});
|
||||||
@@ -53,6 +55,9 @@ test("metric evidence keeps missing semantic assignments as context and exposes
|
|||||||
assert.match(source, /classId === null \? undefined : colorsByClassId\.get\(classId\)/);
|
assert.match(source, /classId === null \? undefined : colorsByClassId\.get\(classId\)/);
|
||||||
assert.match(source, /data-decision="semantic"/);
|
assert.match(source, /data-decision="semantic"/);
|
||||||
assert.match(source, /recordedEvidenceSemanticCssColor/);
|
assert.match(source, /recordedEvidenceSemanticCssColor/);
|
||||||
|
assert.match(source, /DynamicDrawUsage/);
|
||||||
|
assert.match(source, /classifiedMeshesRef/);
|
||||||
|
assert.match(source, /updatePointPositions/);
|
||||||
});
|
});
|
||||||
|
|
||||||
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,73 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "missioncore.m49-tgs-integrated-graph-shadow-profile/v1",
|
||||||
|
"profile_id": "m49-ravnoves00-tgs-native-risk-integrated-shadow/v1",
|
||||||
|
"source": {
|
||||||
|
"source_id": "RAVNOVES00",
|
||||||
|
"expected_timeline_frames": 4489,
|
||||||
|
"expected_available_lidar_frames": 3928,
|
||||||
|
"source_pack_sha256": "0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944",
|
||||||
|
"requested_source_rate_hz": 12.0,
|
||||||
|
"shared_start_barrier": true
|
||||||
|
},
|
||||||
|
"stages": {
|
||||||
|
"reference_graph": {
|
||||||
|
"graph_config": "m48n-rf-detr-native-reference-graph-shadow-v0.json",
|
||||||
|
"graph_config_sha256": "1db6f6fa0561d819505a5e4f62fe256dab6fe4d9da6b4f1a9a5073bfcf772c90",
|
||||||
|
"detector_profile": "rf-detr-large-native-kb4-risk-shadow-v0.json",
|
||||||
|
"detector_profile_sha256": "dbf4da5dbad6c3c22b1280b46ffcad81719bd183c81c263a4859847d829019b6",
|
||||||
|
"detector_provider_id": "triton-rf-detr-large-coco-native-kb4-risk-fp16-shadow/v0",
|
||||||
|
"single_inference_pass": true
|
||||||
|
},
|
||||||
|
"tgs": {
|
||||||
|
"profile": "m49-tgs-full-shadow-v1.json",
|
||||||
|
"profile_sha256": "c2e07010aaee78259d36c057962d6bfb885349251ff7356d867e5813e632881c",
|
||||||
|
"candidate_id": "travel-tgs-only-gravity-aligned",
|
||||||
|
"linked_accepted_result_id": "m49-tgs-full-shadow-ef98de7db7596d48e8c8c0549ce68e6704ee03e87c8c4bcf1e3e748b7ccb032e",
|
||||||
|
"aos_allowed": false,
|
||||||
|
"gpu_allowed": false,
|
||||||
|
"states_remain_separate": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"acceptance": {
|
||||||
|
"minimum_delivery_ratio": 1.0,
|
||||||
|
"reference_world_state_fps": 11.79902,
|
||||||
|
"maximum_world_state_fps_regression_fraction": 0.05,
|
||||||
|
"minimum_effective_world_state_fps": 11.209069,
|
||||||
|
"maximum_world_state_completion_p95_ms": 125.0,
|
||||||
|
"candidate_stage_p95_ms_max": 25.0,
|
||||||
|
"candidate_stage_p99_ms_max": 50.0,
|
||||||
|
"combined_output_age_p99_ms_max": 125.0,
|
||||||
|
"capacity_drop_count_max": 0,
|
||||||
|
"unaccounted_frame_count_max": 0
|
||||||
|
},
|
||||||
|
"telemetry": {
|
||||||
|
"sample_interval_seconds": 1.0,
|
||||||
|
"required_products": [
|
||||||
|
"graph_pipeline_timing",
|
||||||
|
"graph_queue_high_watermarks",
|
||||||
|
"tgs_stage_timing",
|
||||||
|
"host_container_cpu_memory",
|
||||||
|
"gpu_utilization_memory_power_temperature"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"invariants": {
|
||||||
|
"native_fisheye_raster_unchanged": true,
|
||||||
|
"camera_rectification_allowed": false,
|
||||||
|
"camera_resize_allowed": false,
|
||||||
|
"tgs_candidate_parameters_unchanged": true,
|
||||||
|
"tgs_modifies_reference_graph_state": false,
|
||||||
|
"missing_lidar_means_unobserved": true,
|
||||||
|
"future_frames_used": false,
|
||||||
|
"low_step_used": false,
|
||||||
|
"gauss_or_playcanvas_in_scope": false
|
||||||
|
},
|
||||||
|
"authority": {
|
||||||
|
"visual_quality_accepted": false,
|
||||||
|
"traversability_accepted": false,
|
||||||
|
"physical_free_space_accepted": false,
|
||||||
|
"commands_enabled": false,
|
||||||
|
"actuation_allowed": false,
|
||||||
|
"navigation_or_safety_accepted": false,
|
||||||
|
"production_accepted": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -572,6 +572,16 @@ Exit:
|
|||||||
|
|
||||||
### M4.9 — recorded-realtime release candidate and cutover
|
### M4.9 — recorded-realtime release candidate and cutover
|
||||||
|
|
||||||
|
The first complete integrated source-paced load gate passed on `2026-08-27`:
|
||||||
|
the frozen RF-DETR world-state graph and unchanged CPU TGS candidate accounted
|
||||||
|
for all `4,489` frames at `11.860865 FPS`, with joined output p95/p99 of
|
||||||
|
`46.825886/63.94485 ms` and zero capacity drops. The immutable identities and
|
||||||
|
resource envelope are recorded in
|
||||||
|
[`experiments/perception/M49_TGS_INTEGRATED_GRAPH_ACCEPTANCE_2026-08-27.md`](../experiments/perception/M49_TGS_INTEGRATED_GRAPH_ACCEPTANCE_2026-08-27.md).
|
||||||
|
This closes only the one-loop integrated load gate. Visual traversability,
|
||||||
|
uncapped parity, repeated-loop soak, restart/recovery, navigation, actuation
|
||||||
|
and production remain open.
|
||||||
|
|
||||||
Execute on Worker 006 with the frozen RAVNOVES00 source:
|
Execute on Worker 006 with the frozen RAVNOVES00 source:
|
||||||
|
|
||||||
1. one full `1.0×` 448.723-second replay;
|
1. one full `1.0×` 448.723-second replay;
|
||||||
|
|||||||
@@ -344,6 +344,17 @@ This accepts the CPU source-paced shadow and retains the candidate. It does not
|
|||||||
accept visual traversability quality, integrated graph performance, navigation
|
accept visual traversability quality, integrated graph performance, navigation
|
||||||
or actuation.
|
or actuation.
|
||||||
|
|
||||||
|
The product-integration load gate then passed on `2026-08-27`. The unchanged
|
||||||
|
TGS candidate ran concurrently with the frozen RF-DETR world-state graph for
|
||||||
|
all `4,489` frames at a requested `12 Hz`: every graph frame was delivered,
|
||||||
|
every TGS frame was accounted and capacity drops remained zero. The joined
|
||||||
|
output sustained `11.860865 FPS`, measured `46.825886/63.94485 ms` at p95/p99,
|
||||||
|
and the CPU-only TGS stage measured `1.735/2.14019 ms` at p95/p99 with a
|
||||||
|
`22.6 MiB` memory maximum. See
|
||||||
|
[`experiments/perception/M49_TGS_INTEGRATED_GRAPH_ACCEPTANCE_2026-08-27.md`](../experiments/perception/M49_TGS_INTEGRATED_GRAPH_ACCEPTANCE_2026-08-27.md).
|
||||||
|
Integrated runtime capacity is accepted; visual traversability, physical free
|
||||||
|
space, navigation, actuation and production remain unaccepted.
|
||||||
|
|
||||||
### T4 — Candidate C occupancy/ESDF probe
|
### T4 — Candidate C occupancy/ESDF probe
|
||||||
|
|
||||||
- Run nvblox separately with RF-DETR disabled for the isolated probe.
|
- Run nvblox separately with RF-DETR disabled for the isolated probe.
|
||||||
@@ -361,15 +372,16 @@ or actuation.
|
|||||||
|
|
||||||
## Immediate next action
|
## Immediate next action
|
||||||
|
|
||||||
Review the published full gravity-aligned TGS timeline in camera, metric 3D and
|
Keep the accepted integrated graph frozen and review the published full
|
||||||
costmap space. Preserve
|
gravity-aligned TGS timeline in camera, metric 3D and costmap space. Preserve
|
||||||
`GROUND_SUPPORT`, `NONGROUND_OCCUPIED`, `UNKNOWN_REJECTED` and `UNOBSERVED` as
|
`GROUND_SUPPORT`, `NONGROUND_OCCUPIED`, `UNKNOWN_REJECTED` and `UNOBSERVED` as
|
||||||
separate products; do not use AOS or infer free cells from absent
|
separate products; do not use AOS or infer free cells from absent
|
||||||
republication. Attach the unchanged candidate to the realtime world-state graph
|
republication. Integrated FPS, latency, resources and queue behavior are now
|
||||||
in non-authoritative shadow mode and measure complete-graph FPS, latency,
|
accepted for the one-loop source-paced gate. If occupied evidence carpets the
|
||||||
resource use and queue drops. If occupied evidence carpets the route, soft
|
route, soft traversable vegetation or usable gaps, reject TGS without tuning it
|
||||||
traversable vegetation or usable gaps, reject TGS without tuning it against the
|
against the review evidence. Close and induce-test the Worker inference-runtime
|
||||||
review evidence. Candidate A T2 replay remains blocked by the failed unmodified
|
restart guard, then proceed to repeated-loop soak only if visual review also passes.
|
||||||
T1 gate. No LOW-STEP tuning, new object model, camera resize, fisheye
|
Candidate A T2 replay remains blocked by the failed unmodified T1 gate. No
|
||||||
|
LOW-STEP tuning, new object model, camera resize, fisheye
|
||||||
rectification, manual dataset or parallel heavy Worker job is authorized by
|
rectification, manual dataset or parallel heavy Worker job is authorized by
|
||||||
this decision.
|
this decision.
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# M4.9T5 integrated TGS + reference graph shadow — 2026-08-27
|
||||||
|
|
||||||
|
Status: **complete source-paced integrated shadow accepted**. Integrated
|
||||||
|
runtime capacity is accepted for the frozen RAVNOVES00 graph. Visual
|
||||||
|
traversability quality, physical free space, navigation, actuation and
|
||||||
|
production remain unaccepted.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The unchanged gravity-aligned TGS candidate and the frozen RF-DETR
|
||||||
|
world-state graph completed the same `4,489`-frame RAVNOVES00 timeline from a
|
||||||
|
shared `12 Hz` start barrier on Worker 006. Every graph frame was delivered,
|
||||||
|
every TGS frame was accounted, all queues remained bounded and no capacity
|
||||||
|
drop or supersession occurred.
|
||||||
|
|
||||||
|
The integrated graph sustained `11.860865 FPS`, compared with the frozen
|
||||||
|
reference result at `11.79902 FPS`. TGS added `1.735 ms` at p95 and used at
|
||||||
|
most `22.6 MiB` of container memory; it did not add a GPU model. The exact
|
||||||
|
per-frame join of graph and TGS outputs measured `46.825886 ms` at p95 and
|
||||||
|
`63.94485 ms` at p99. This accepts the runtime integration gate without
|
||||||
|
changing any navigation or safety authority.
|
||||||
|
|
||||||
|
## Frozen identity
|
||||||
|
|
||||||
|
| Item | Identity |
|
||||||
|
| --- | --- |
|
||||||
|
| Integrated result | `m49-tgs-integrated-graph-shadow-75e48fd8acf0246be16613e087fcf26fe909f7834718d217c74785a57f494b24` |
|
||||||
|
| Worker run | `Worker 006 / ravnoves00-integrated-12fps-003` |
|
||||||
|
| Mission Core revision used by Worker | `f153d671d316ea7b2200c0edcb80206d896ffcd5` |
|
||||||
|
| Deterministic Worker artifact | `1818ea57887db819e9d986713b050bd15eae1a43ee4af70c5b8b5d39f4cd8522` |
|
||||||
|
| Worker wheel | `b9414a1f2f1998543aeb63824d3cdfe55bbc6e4eecc3146d65962ebc7da86b8b` |
|
||||||
|
| TGS native binary | `2c3b4c7e6b66d19d2e32ee3ef867fa8c14073be29441bcd19fe565d9632c6538` |
|
||||||
|
| Integrated profile | `b61e018b2d04eec58802e2d4186ce7a3dd3a15b254db106b57b609e903eeef80` |
|
||||||
|
| Frozen graph config | `1db6f6fa0561d819505a5e4f62fe256dab6fe4d9da6b4f1a9a5073bfcf772c90` |
|
||||||
|
| Frozen TGS profile | `c2e07010aaee78259d36c057962d6bfb885349251ff7356d867e5813e632881c` |
|
||||||
|
| Linked accepted TGS result | `m49-tgs-full-shadow-ef98de7db7596d48e8c8c0549ce68e6704ee03e87c8c4bcf1e3e748b7ccb032e` |
|
||||||
|
|
||||||
|
The release was built from `git archive` of the declared revision. Uncommitted
|
||||||
|
GAUSS and PlayCanvas files were neither packaged nor changed by the run.
|
||||||
|
|
||||||
|
## Full-run result
|
||||||
|
|
||||||
|
| Measurement | Result |
|
||||||
|
| --- | ---: |
|
||||||
|
| Graph admitted / delivered | `4,489 / 4,489` |
|
||||||
|
| TGS timeline accounted | `4,489 / 4,489` |
|
||||||
|
| LiDAR available | `3,928` |
|
||||||
|
| Delivery ratio | `1.0` |
|
||||||
|
| Capacity drops / supersessions / failures | `0 / 0 / 0` |
|
||||||
|
| Effective world-state rate | `11.860865 FPS` |
|
||||||
|
| Frozen reference rate | `11.79902 FPS` |
|
||||||
|
| World-state completion p50 / p95 / p99 / max | `28.437492 / 46.696395 / 63.94485 / 170.202343 ms` |
|
||||||
|
| Joined graph+TGS output p50 / p95 / p99 / max | `28.57367 / 46.825886 / 63.94485 / 170.202343 ms` |
|
||||||
|
| TGS stage p50 / p95 / p99 / max | `1.186 / 1.735 / 2.14019 / 6.151 ms` |
|
||||||
|
| TGS completion p50 / p95 / p99 / max | `17.605461 / 31.771832 / 42.957350 / 70.851977 ms` |
|
||||||
|
| Queue high-water marks | `2` for every bounded stage |
|
||||||
|
| Worker wrapper wall time | `544.887447 s` |
|
||||||
|
|
||||||
|
All nineteen formal identity, accounting, throughput, latency, telemetry,
|
||||||
|
capacity and false-authority checks passed.
|
||||||
|
|
||||||
|
## Resource envelope
|
||||||
|
|
||||||
|
| Resource | Result |
|
||||||
|
| --- | ---: |
|
||||||
|
| GPU utilization mean / p95 / max | `43.114973 / 53 / 57 %` |
|
||||||
|
| GPU memory mean / p95 / max | `9,737.144 / 9,744 / 9,751 MiB` |
|
||||||
|
| GPU power p95 / max | `149.342 / 153.81 W` |
|
||||||
|
| TGS CPU mean / p95 / max | `3.474793 / 5.27 / 6.04 %` |
|
||||||
|
| TGS memory mean / p95 / max | `20.571818 / 21.84 / 22.6 MiB` |
|
||||||
|
| Graph container memory max | `1,769.472 MiB` |
|
||||||
|
| Triton container memory max | `582.9 MiB` |
|
||||||
|
|
||||||
|
The measured GPU envelope belongs to the already accepted reference graph.
|
||||||
|
TGS is a CPU-only stage and does not request CUDA, Vulkan or a second model.
|
||||||
|
|
||||||
|
## LAB publication boundary
|
||||||
|
|
||||||
|
No third visual viewer is created for this gate. The TGS cells, source points,
|
||||||
|
camera frames and semantic archive are byte-identical to the already published
|
||||||
|
`m49-tgs-full-shadow` LAB. This run proves concurrent runtime behavior, so its
|
||||||
|
new evidence is the immutable timing, queue and resource report above. Visual
|
||||||
|
quality review continues in the existing full-shadow LAB.
|
||||||
|
|
||||||
|
## Runtime recovery finding
|
||||||
|
|
||||||
|
Preflight found the canonical `ndc-mission-core-triton` container stopped after
|
||||||
|
the Docker runtime restarted. Docker reported exit code `255`, no OOM event and
|
||||||
|
Compose configured `restart: "no"`. The accepted run began only after the exact
|
||||||
|
same container was started and healthy; its container ID remained
|
||||||
|
`4232fb040062a8384809e73612baa92b343ddcb60f00c23e7091eb4909959223`
|
||||||
|
throughout the run.
|
||||||
|
|
||||||
|
This is a deployment-lifecycle defect, not a perception overload result. The
|
||||||
|
container must use a durable restart policy and an unhealthy-state watchdog;
|
||||||
|
changing the TGS or detector graph cannot fix it.
|
||||||
|
|
||||||
|
The live container was subsequently changed in place to
|
||||||
|
`restart: unless-stopped`. Docker confirmed the service remained `running` and
|
||||||
|
`healthy` with the same container ID, so applying the mitigation caused no
|
||||||
|
inference restart. This protects the current container from process and Docker
|
||||||
|
daemon restarts. The versioned Worker Compose source still declares
|
||||||
|
`restart: "no"`; closing that source-level drift and inducing a controlled
|
||||||
|
recovery remain a separate deployment gate.
|
||||||
|
|
||||||
|
## Sealed compact evidence
|
||||||
|
|
||||||
|
| File | SHA-256 |
|
||||||
|
| --- | --- |
|
||||||
|
| `result.json` | `bfc881aa943d46582a248b6024a413c3c4b3a6959fa4a674f6ec33d658fdcbdf` |
|
||||||
|
| `graph-result.json` | `784d27b908137843f6adf08d267576a74b0676f5f98226923399ae59f538b78a` |
|
||||||
|
| `tgs-result.json` | `c0245744892b66c7f24af3cd7ea9c8cd8d543114fffa70e32d8d5fef81054716` |
|
||||||
|
| `worker-summary.json` | `c449246d14fb927b6490bcf657950b354f101fd3eb4b296bc0f6dd8543aafe10` |
|
||||||
|
|
||||||
|
## Next gate
|
||||||
|
|
||||||
|
Keep this integrated graph frozen. Complete the independent visual
|
||||||
|
traversability review in the existing TGS LAB, concentrating on vegetation,
|
||||||
|
compact obstacles, false occupied carpets, usable gaps and missing-LiDAR
|
||||||
|
behavior. In parallel, close the Worker inference-runtime restart policy and
|
||||||
|
verify an induced stop/recovery without changing result identity. Only after
|
||||||
|
both gates pass may the candidate advance to repeated-loop soak and recovery
|
||||||
|
acceptance; navigation and actuation remain off.
|
||||||
@@ -67,6 +67,27 @@ AUTHORITY: Final = {
|
|||||||
LOAD_PURPOSES: Final = ("production-rate", "reserve-gate", "limit-discovery")
|
LOAD_PURPOSES: Final = ("production-rate", "reserve-gate", "limit-discovery")
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_shared_start(
|
||||||
|
*,
|
||||||
|
ready_file: Path,
|
||||||
|
start_file: Path,
|
||||||
|
timeout_seconds: float,
|
||||||
|
) -> None:
|
||||||
|
"""Join an external source-admission barrier after local warmup is complete."""
|
||||||
|
|
||||||
|
if timeout_seconds <= 0:
|
||||||
|
raise RuntimeError("shared-start timeout must be positive")
|
||||||
|
if ready_file.exists():
|
||||||
|
raise RuntimeError("shared-start ready file already exists")
|
||||||
|
ready_file.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
ready_file.write_text("ready\n", encoding="utf-8")
|
||||||
|
deadline = time.monotonic() + timeout_seconds
|
||||||
|
while not start_file.is_file():
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise RuntimeError("shared-start barrier timed out")
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
class GpuTelemetry:
|
class GpuTelemetry:
|
||||||
def __init__(self, interval_seconds: float) -> None:
|
def __init__(self, interval_seconds: float) -> None:
|
||||||
self.interval_seconds = interval_seconds
|
self.interval_seconds = interval_seconds
|
||||||
@@ -394,6 +415,9 @@ def main() -> int:
|
|||||||
parser.add_argument("--runtime-artifact-sha256", required=True)
|
parser.add_argument("--runtime-artifact-sha256", required=True)
|
||||||
parser.add_argument("--runner-sha256", required=True)
|
parser.add_argument("--runner-sha256", required=True)
|
||||||
parser.add_argument("--telemetry-interval-seconds", type=float, default=1.0)
|
parser.add_argument("--telemetry-interval-seconds", type=float, default=1.0)
|
||||||
|
parser.add_argument("--shared-start-ready-file", type=Path)
|
||||||
|
parser.add_argument("--shared-start-file", type=Path)
|
||||||
|
parser.add_argument("--shared-start-timeout-seconds", type=float, default=600.0)
|
||||||
parser.add_argument("--output", type=Path, required=True)
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
parser.add_argument("--progress", type=Path, required=True)
|
parser.add_argument("--progress", type=Path, required=True)
|
||||||
parser.add_argument("--frame-ledger", type=Path, required=True)
|
parser.add_argument("--frame-ledger", type=Path, required=True)
|
||||||
@@ -404,6 +428,17 @@ def main() -> int:
|
|||||||
raise RuntimeError("maximum frame count must be positive")
|
raise RuntimeError("maximum frame count must be positive")
|
||||||
if arguments.telemetry_interval_seconds <= 0:
|
if arguments.telemetry_interval_seconds <= 0:
|
||||||
raise RuntimeError("telemetry interval must be positive")
|
raise RuntimeError("telemetry interval must be positive")
|
||||||
|
shared_start_requested = (
|
||||||
|
arguments.shared_start_ready_file is not None or arguments.shared_start_file is not None
|
||||||
|
)
|
||||||
|
if shared_start_requested and (
|
||||||
|
arguments.shared_start_ready_file is None or arguments.shared_start_file is None
|
||||||
|
):
|
||||||
|
raise RuntimeError("shared-start ready and start files must be configured together")
|
||||||
|
if shared_start_requested and arguments.loops != 1:
|
||||||
|
raise RuntimeError("shared-start barrier requires exactly one loop")
|
||||||
|
if arguments.shared_start_timeout_seconds <= 0:
|
||||||
|
raise RuntimeError("shared-start timeout must be positive")
|
||||||
if arguments.source_rate_hz is not None and (
|
if arguments.source_rate_hz is not None and (
|
||||||
not np.isfinite(arguments.source_rate_hz) or arguments.source_rate_hz <= 0
|
not np.isfinite(arguments.source_rate_hz) or arguments.source_rate_hz <= 0
|
||||||
):
|
):
|
||||||
@@ -506,6 +541,12 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
detector_warmup = runtime.warm_up_detector()
|
detector_warmup = runtime.warm_up_detector()
|
||||||
source_prefetch = runtime.prepare_source()
|
source_prefetch = runtime.prepare_source()
|
||||||
|
if shared_start_requested:
|
||||||
|
wait_for_shared_start(
|
||||||
|
ready_file=arguments.shared_start_ready_file,
|
||||||
|
start_file=arguments.shared_start_file,
|
||||||
|
timeout_seconds=arguments.shared_start_timeout_seconds,
|
||||||
|
)
|
||||||
gc_policy = CyclicGcHotLoopPolicy()
|
gc_policy = CyclicGcHotLoopPolicy()
|
||||||
with gc_policy:
|
with gc_policy:
|
||||||
runtime.mark_source_admission_started()
|
runtime.mark_source_admission_started()
|
||||||
|
|||||||
@@ -0,0 +1,455 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$ReleaseRoot,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$CandidateRoot,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidatePattern("^[a-f0-9]{64}$")]
|
||||||
|
[string]$ExpectedArtifactSha256,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidatePattern("^[A-Za-z0-9._-]{1,96}$")]
|
||||||
|
[string]$RunId,
|
||||||
|
[ValidateRange(1.0, 120.0)]
|
||||||
|
[double]$SourceRateHz = 12.0,
|
||||||
|
[string]$OutputRoot = (
|
||||||
|
"D:\NDC_MISSIONCORE\runtime\results\m49-tgs-integrated-graph-shadow"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$ProgressPreference = "SilentlyContinue"
|
||||||
|
$TravelImageTag = "ndc/mission-core-m49-t3-travel:20260826"
|
||||||
|
$TravelImageId = "sha256:7b412020f4d8392d1d1ed1b33beadc44140f0ea8f781e62dd69796042334300f"
|
||||||
|
$ParityImageTag = "ndc-mission-core-m48t-upstream-parity:1.9.4-cu130"
|
||||||
|
$ParityImageId = "sha256:ceb13548617e4bd3f619766bfdff00af3fa5160946b367828da6d2233dcdcba0"
|
||||||
|
$RuntimeImage = (
|
||||||
|
"nvcr.io/nvidia/tritonserver:26.06-py3@" +
|
||||||
|
"sha256:58df7489c3f2276f9591d500a012dee03e23d35543ce3c390b4c001e6bf90794"
|
||||||
|
)
|
||||||
|
|
||||||
|
function Assert-LastExitCode([string]$Operation) {
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "$Operation failed with exit code $LASTEXITCODE" }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-Sha256([string]$Path) {
|
||||||
|
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-DDirectory([string]$Path, [string]$Label, [bool]$Create) {
|
||||||
|
if ($Create -and -not (Test-Path -LiteralPath $Path)) {
|
||||||
|
$null = New-Item -ItemType Directory -Path $Path
|
||||||
|
}
|
||||||
|
$item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $Path).Path -Force
|
||||||
|
if (
|
||||||
|
-not $item.PSIsContainer -or
|
||||||
|
($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or
|
||||||
|
[IO.Path]::GetPathRoot($item.FullName).TrimEnd("\") -ine "D:"
|
||||||
|
) { throw "$Label must be a real D: directory" }
|
||||||
|
return $item.FullName
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-DFile([string]$Path, [string]$Label) {
|
||||||
|
$item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $Path).Path -Force
|
||||||
|
if (
|
||||||
|
$item.PSIsContainer -or
|
||||||
|
($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or
|
||||||
|
[IO.Path]::GetPathRoot($item.FullName).TrimEnd("\") -ine "D:"
|
||||||
|
) { throw "$Label must be a real D: file" }
|
||||||
|
return $item.FullName
|
||||||
|
}
|
||||||
|
|
||||||
|
function Convert-ToDockerPath([string]$Path) { return ($Path -replace "\\", "/") }
|
||||||
|
|
||||||
|
function Get-Container([string]$Name) {
|
||||||
|
$rows = @(((& docker inspect $Name) | ConvertFrom-Json))
|
||||||
|
Assert-LastExitCode "Docker inspection for $Name"
|
||||||
|
if ($rows.Count -ne 1) { throw "Container identity for $Name is not unique" }
|
||||||
|
return $rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-Image([string]$Tag, [string]$ExpectedId) {
|
||||||
|
$rows = @(((& docker image inspect $Tag) | ConvertFrom-Json))
|
||||||
|
Assert-LastExitCode "Docker image inspection for $Tag"
|
||||||
|
if ($rows.Count -ne 1 -or [string]$rows[0].Id -cne $ExpectedId) {
|
||||||
|
throw "Pinned image identity changed for $Tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Remove-ExactContainer([string]$Name) {
|
||||||
|
if (& docker ps -a --format "{{.Names}}" --filter "name=^/$Name$") {
|
||||||
|
& docker rm --force $Name *> $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-Healthy([string]$Name) {
|
||||||
|
foreach ($attempt in 1..60) {
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
$container = Get-Container $Name
|
||||||
|
if (-not $container.State.Running) {
|
||||||
|
& docker logs $Name
|
||||||
|
throw "$Name stopped during startup"
|
||||||
|
}
|
||||||
|
if ($container.State.Health.Status -ceq "healthy") { return }
|
||||||
|
}
|
||||||
|
throw "$Name did not become healthy"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-SharedReady(
|
||||||
|
[string]$GraphReady,
|
||||||
|
[string]$TgsReady,
|
||||||
|
[string]$GraphName,
|
||||||
|
[string]$TgsName
|
||||||
|
) {
|
||||||
|
$deadline = [DateTimeOffset]::UtcNow.AddMinutes(10)
|
||||||
|
while (-not ((Test-Path -LiteralPath $GraphReady) -and (Test-Path -LiteralPath $TgsReady))) {
|
||||||
|
foreach ($name in @($GraphName, $TgsName)) {
|
||||||
|
$container = Get-Container $name
|
||||||
|
if (-not $container.State.Running) {
|
||||||
|
& docker logs $name
|
||||||
|
throw "$name stopped before shared-start readiness"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ([DateTimeOffset]::UtcNow -ge $deadline) {
|
||||||
|
throw "M49 integrated shared-start readiness timed out"
|
||||||
|
}
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($env:COMPUTERNAME -cne "DESKTOP-OPJ8J04") {
|
||||||
|
throw "M49 integrated TGS graph shadow is pinned to Worker 006"
|
||||||
|
}
|
||||||
|
$release = Resolve-DDirectory $ReleaseRoot "M49 integrated release root" $false
|
||||||
|
$payload = Resolve-DDirectory (Join-Path $release "payload") "M49 integrated payload" $false
|
||||||
|
$candidate = Resolve-DDirectory $CandidateRoot "M49 native candidate root" $false
|
||||||
|
$output = Resolve-DDirectory $OutputRoot "M49 integrated output root" $true
|
||||||
|
$runCandidate = Join-Path $output $RunId
|
||||||
|
if (Test-Path -LiteralPath $runCandidate) { throw "M49 integrated output already exists" }
|
||||||
|
$null = New-Item -ItemType Directory -Path $runCandidate
|
||||||
|
$runOutput = Resolve-DDirectory $runCandidate "M49 integrated run output" $false
|
||||||
|
foreach ($directory in @("bin", "control", "graph", "tgs")) {
|
||||||
|
$null = New-Item -ItemType Directory -Path (Join-Path $runOutput $directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
$releaseDocument = Get-Content -LiteralPath (Join-Path $payload "release.json") -Raw | ConvertFrom-Json
|
||||||
|
if (
|
||||||
|
$releaseDocument.schema_version -cne "missioncore.m49-tgs-integrated-graph-worker-release/v1" -or
|
||||||
|
$releaseDocument.worker_id -cne "worker-006" -or
|
||||||
|
$releaseDocument.transition -cne "m49-tgs-native-risk-integrated-shadow/v1"
|
||||||
|
) { throw "M49 integrated release contract changed" }
|
||||||
|
foreach ($property in $releaseDocument.files.PSObject.Properties) {
|
||||||
|
$path = Join-Path $payload $property.Name
|
||||||
|
if ((Get-Sha256 $path) -cne [string]$property.Value.sha256) {
|
||||||
|
throw "M49 integrated payload digest changed: $($property.Name)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$wheelSha256 = [string]$releaseDocument.files."nodedc_mission_core-0.1.0-py3-none-any.whl".sha256
|
||||||
|
$runnerSha256 = [string]$releaseDocument.files."run_m48s_reference_graph_shadow_worker.py".sha256
|
||||||
|
|
||||||
|
$source = [ordered]@{
|
||||||
|
CameraIndex = (
|
||||||
|
"D:\NDC_MISSIONCORE\runtime\jobs\recorded-camera-602ac89026ed12978619801d" +
|
||||||
|
"\input\camera\sensor.camera.right\epoch-1\index.jsonl"
|
||||||
|
)
|
||||||
|
SourcePack = (
|
||||||
|
"D:\NDC_MISSIONCORE\runtime\derived" +
|
||||||
|
"\e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b" +
|
||||||
|
"\lidar-pack.npz"
|
||||||
|
)
|
||||||
|
LocalSurface = (
|
||||||
|
"D:\NDC_MISSIONCORE\runtime\derived" +
|
||||||
|
"\k1-local-surface-23762244c8bdb97de26fb721ac957d7a00bc9a63571ac4cfa4be19c4effc7d55" +
|
||||||
|
"\local-surface.npz"
|
||||||
|
)
|
||||||
|
Video = (
|
||||||
|
"D:\NDC_MISSIONCORE\runtime\experiments\e46e\inputs" +
|
||||||
|
"\right-cadd1696ff000904eb78633a0a8418104b8024f178b91f3421789021ccb160e8.mp4"
|
||||||
|
)
|
||||||
|
Mask = (
|
||||||
|
"D:\NDC_MISSIONCORE\runtime\inputs\e2" +
|
||||||
|
"\valid-fov-mask-b4dd8ddf2b87c1d520ee8a0868c4fea062d7c14d1bae73ccabd3abe1f3acbac2" +
|
||||||
|
"\mask.png"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
foreach ($entry in $source.GetEnumerator()) {
|
||||||
|
$null = Resolve-DFile $entry.Value "M49 source $($entry.Key)"
|
||||||
|
}
|
||||||
|
if ((Get-Sha256 $source.SourcePack) -cne [string]$releaseDocument.source_pack_sha256) {
|
||||||
|
throw "RAVNOVES00 source pack digest changed"
|
||||||
|
}
|
||||||
|
|
||||||
|
$nativeConfig = Resolve-DFile (
|
||||||
|
(Join-Path $payload "rf_detr_large_native_kb4_config.pbtxt")
|
||||||
|
) "native RF-DETR config"
|
||||||
|
$nativeEngine = Resolve-DFile (
|
||||||
|
(Join-Path $candidate "rf-detr-native-uint8.plan")
|
||||||
|
) "native RF-DETR engine"
|
||||||
|
if ((Get-Sha256 $nativeEngine) -cne "b8a40b3580edff001ec9680de68707242294ff590ab296000fae371f1083f695") {
|
||||||
|
throw "native RF-DETR engine SHA-256 changed"
|
||||||
|
}
|
||||||
|
$modelRoot = Join-Path $runOutput "triton-models"
|
||||||
|
$modelDirectory = Join-Path $modelRoot "rf_detr_large_native_kb4"
|
||||||
|
$modelVersionDirectory = Join-Path $modelDirectory "1"
|
||||||
|
$null = New-Item -ItemType Directory -Path $modelVersionDirectory
|
||||||
|
Copy-Item -LiteralPath $nativeConfig -Destination (Join-Path $modelDirectory "config.pbtxt")
|
||||||
|
Copy-Item -LiteralPath $nativeEngine -Destination (Join-Path $modelVersionDirectory "model.plan")
|
||||||
|
|
||||||
|
$media = Resolve-DDirectory (
|
||||||
|
"D:\NDC_MISSIONCORE\runtime\derived\perception-e15-media-pyav180-lz445-v1"
|
||||||
|
) "PyAV dependency" $false
|
||||||
|
$opencv = Resolve-DDirectory (
|
||||||
|
"D:\NDC_MISSIONCORE\runtime\derived\perception-e3-opencv413092-v1\packages"
|
||||||
|
) "OpenCV dependency" $false
|
||||||
|
$pillow = Resolve-DDirectory (
|
||||||
|
"D:\NDC_MISSIONCORE\runtime\derived\perception-p0-env-v1"
|
||||||
|
) "Pillow dependency" $false
|
||||||
|
|
||||||
|
Assert-Image $TravelImageTag $TravelImageId
|
||||||
|
Assert-Image $ParityImageTag $ParityImageId
|
||||||
|
& docker image inspect $RuntimeImage *> $null
|
||||||
|
Assert-LastExitCode "pinned runtime image inspection"
|
||||||
|
$os = Get-CimInstance Win32_OperatingSystem
|
||||||
|
$freeMemoryGiB = [double]$os.FreePhysicalMemory / 1MB
|
||||||
|
if ($freeMemoryGiB -lt 24.0) {
|
||||||
|
throw ("M49 integrated shadow requires 24 GiB free memory; observed {0:N2} GiB" -f $freeMemoryGiB)
|
||||||
|
}
|
||||||
|
$canonicalBefore = Get-Container "ndc-mission-core-triton"
|
||||||
|
if (-not $canonicalBefore.State.Running -or $canonicalBefore.State.Health.Status -cne "healthy") {
|
||||||
|
throw "Canonical Mission Core Triton must remain healthy"
|
||||||
|
}
|
||||||
|
$canonicalId = [string]$canonicalBefore.Id
|
||||||
|
|
||||||
|
$prepareName = "ndc-mission-core-m49-integrated-prepare-$RunId"
|
||||||
|
$compileName = "ndc-mission-core-m49-integrated-compile-$RunId"
|
||||||
|
$tritonName = "ndc-mission-core-m49-integrated-triton-$RunId"
|
||||||
|
$graphName = "ndc-mission-core-m49-integrated-graph-$RunId"
|
||||||
|
$tgsName = "ndc-mission-core-m49-integrated-tgs-$RunId"
|
||||||
|
$analyzeName = "ndc-mission-core-m49-integrated-analyze-$RunId"
|
||||||
|
$evidenceName = "ndc-mission-core-m49-integrated-evidence-$RunId"
|
||||||
|
$containers = @($prepareName, $compileName, $tritonName, $graphName, $tgsName, $analyzeName, $evidenceName)
|
||||||
|
foreach ($name in $containers) {
|
||||||
|
if (& docker ps -a --format "{{.Names}}" --filter "name=^/$name$") {
|
||||||
|
throw "M49 integrated container name already exists: $name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$started = [DateTimeOffset]::UtcNow
|
||||||
|
try {
|
||||||
|
& docker run --rm --name $prepareName --network none --cpus 8 --memory 16g `
|
||||||
|
--entrypoint python3 `
|
||||||
|
--volume ((Convert-ToDockerPath $source.SourcePack) + ":/source/lidar-pack.npz:ro") `
|
||||||
|
--volume ((Convert-ToDockerPath $payload) + ":/release:ro") `
|
||||||
|
--volume ((Convert-ToDockerPath (Join-Path $runOutput "tgs")) + ":/tgs") `
|
||||||
|
$ParityImageTag /release/prepare_tgs_full_shadow_inputs.py `
|
||||||
|
--source-pack /source/lidar-pack.npz `
|
||||||
|
--config /release/m49-tgs-full-shadow-v1.json `
|
||||||
|
--output-root /tgs/inputs
|
||||||
|
Assert-LastExitCode "M49 integrated TGS input preparation"
|
||||||
|
|
||||||
|
& docker run --rm --name $compileName --network none --cpus 8 --memory 8g `
|
||||||
|
--entrypoint /bin/bash `
|
||||||
|
--volume ((Convert-ToDockerPath $payload) + ":/release:ro") `
|
||||||
|
--volume ((Convert-ToDockerPath (Join-Path $runOutput "bin")) + ":/out") `
|
||||||
|
$TravelImageTag /release/build_tgs_full_shadow_binary.sh /out/run_tgs_full_shadow
|
||||||
|
Assert-LastExitCode "M49 integrated TGS binary build"
|
||||||
|
|
||||||
|
& docker create --name $tritonName `
|
||||||
|
--read-only --security-opt "no-new-privileges:true" --cap-drop ALL `
|
||||||
|
--pids-limit 512 --shm-size 1g --gpus all `
|
||||||
|
--tmpfs "/tmp:rw,noexec,nosuid,size=2g" `
|
||||||
|
--health-cmd "curl --fail --silent http://127.0.0.1:8000/v2/health/ready" `
|
||||||
|
--health-interval 5s --health-timeout 3s --health-start-period 20s --health-retries 24 `
|
||||||
|
-v ((Convert-ToDockerPath $modelRoot) + ":/models:ro") `
|
||||||
|
$RuntimeImage tritonserver --model-repository=/models `
|
||||||
|
--model-control-mode=explicit --load-model=rf_detr_large_native_kb4 `
|
||||||
|
--disable-auto-complete-config --strict-readiness=true --exit-on-error=true `
|
||||||
|
--allow-http=true --allow-grpc=false --allow-metrics=false *> $null
|
||||||
|
Assert-LastExitCode "M49 integrated Triton creation"
|
||||||
|
& docker start $tritonName *> $null
|
||||||
|
Assert-LastExitCode "M49 integrated Triton start"
|
||||||
|
Wait-Healthy $tritonName
|
||||||
|
|
||||||
|
$dockerRelease = Convert-ToDockerPath $payload
|
||||||
|
$dockerRun = Convert-ToDockerPath $runOutput
|
||||||
|
$rate = [string]::Format([Globalization.CultureInfo]::InvariantCulture, "{0:R}", $SourceRateHz)
|
||||||
|
$graphArguments = @(
|
||||||
|
"create", "--name", $graphName,
|
||||||
|
"--network", ("container:{0}" -f $tritonName),
|
||||||
|
"--read-only", "--security-opt", "no-new-privileges:true", "--cap-drop", "ALL",
|
||||||
|
"--pids-limit", "256", "--gpus", "all",
|
||||||
|
"--tmpfs", "/tmp:rw,noexec,nosuid,size=2g",
|
||||||
|
"-e", "PYTHONDONTWRITEBYTECODE=1",
|
||||||
|
"-e", "PYTHONPATH=/release/nodedc_mission_core-0.1.0-py3-none-any.whl:/opt/media:/opt/opencv:/opt/pillow",
|
||||||
|
"-v", ("{0}:/release:ro" -f $dockerRelease),
|
||||||
|
"-v", ("{0}:/shared:rw" -f $dockerRun),
|
||||||
|
"-v", ((Convert-ToDockerPath $media) + ":/opt/media:ro"),
|
||||||
|
"-v", ((Convert-ToDockerPath $opencv) + ":/opt/opencv:ro"),
|
||||||
|
"-v", ((Convert-ToDockerPath $pillow) + ":/opt/pillow:ro"),
|
||||||
|
"-v", ((Convert-ToDockerPath $source.CameraIndex) + ":/source/camera-index.jsonl:ro"),
|
||||||
|
"-v", ((Convert-ToDockerPath $source.SourcePack) + ":/source/source-pack.npz:ro"),
|
||||||
|
"-v", ((Convert-ToDockerPath $source.LocalSurface) + ":/source/local-surface.npz:ro"),
|
||||||
|
"-v", ((Convert-ToDockerPath $source.Video) + ":/source/right.mp4:ro"),
|
||||||
|
"-v", ((Convert-ToDockerPath $source.Mask) + ":/source/mask.png:ro"),
|
||||||
|
"--entrypoint", "python3", $RuntimeImage,
|
||||||
|
"/release/run_m48s_reference_graph_shadow_worker.py",
|
||||||
|
"--graph-config", "/release/m48n-rf-detr-native-reference-graph-shadow-v0.json",
|
||||||
|
"--baseline-profile", "/release/m4-recorded-realtime-baseline-v1.json",
|
||||||
|
"--detector-profile", "/release/rf-detr-large-native-kb4-risk-shadow-v0.json",
|
||||||
|
"--geometry-profile", "/release/m4-geometry-association-v1.json",
|
||||||
|
"--temporal-motion-profile", "/release/m4-temporal-motion-v1.json",
|
||||||
|
"--rolling-map-profile", "/release/m4-rolling-local-map-v1.json",
|
||||||
|
"--threat-profile", "/release/m4-replay-threat-v3.json",
|
||||||
|
"--camera-index", "/source/camera-index.jsonl",
|
||||||
|
"--source-pack", "/source/source-pack.npz",
|
||||||
|
"--local-surface", "/source/local-surface.npz",
|
||||||
|
"--video", "/source/right.mp4",
|
||||||
|
"--valid-fov-mask", "/source/mask.png",
|
||||||
|
"--triton-origin", "http://127.0.0.1:8000",
|
||||||
|
"--loops", "1", "--maximum-frames", "4489", "--source-rate-hz", $rate,
|
||||||
|
"--minimum-delivery-ratio", "1.0",
|
||||||
|
"--minimum-effective-world-state-fps", "11.209069",
|
||||||
|
"--maximum-world-state-completion-p95-ms", "125.0",
|
||||||
|
"--load-purpose", "reserve-gate",
|
||||||
|
"--runtime-artifact-sha256", $wheelSha256,
|
||||||
|
"--runner-sha256", $runnerSha256,
|
||||||
|
"--shared-start-ready-file", "/shared/control/graph.ready",
|
||||||
|
"--shared-start-file", "/shared/control/start.signal",
|
||||||
|
"--output", "/shared/graph/result.json",
|
||||||
|
"--progress", "/shared/graph/progress.jsonl",
|
||||||
|
"--frame-ledger", "/shared/graph/frames.jsonl"
|
||||||
|
)
|
||||||
|
& docker @graphArguments *> $null
|
||||||
|
Assert-LastExitCode "M49 integrated graph creation"
|
||||||
|
|
||||||
|
& docker create --name $tgsName --network none --cpus 16 --memory 24g `
|
||||||
|
--read-only --security-opt "no-new-privileges:true" --cap-drop ALL `
|
||||||
|
--pids-limit 256 --tmpfs "/tmp:rw,noexec,nosuid,size=1g" `
|
||||||
|
-e ("M49_SOURCE_RATE_HZ={0}" -f $rate) `
|
||||||
|
--entrypoint /bin/bash `
|
||||||
|
--volume ($dockerRelease + ":/release:ro") `
|
||||||
|
--volume ($dockerRun + ":/shared:rw") `
|
||||||
|
$TravelImageTag /release/run_tgs_integrated_shadow.sh *> $null
|
||||||
|
Assert-LastExitCode "M49 integrated TGS creation"
|
||||||
|
|
||||||
|
& docker start $graphName *> $null
|
||||||
|
Assert-LastExitCode "M49 integrated graph start"
|
||||||
|
& docker start $tgsName *> $null
|
||||||
|
Assert-LastExitCode "M49 integrated TGS start"
|
||||||
|
$graphReady = Join-Path $runOutput "control\graph.ready"
|
||||||
|
$tgsReady = Join-Path $runOutput "control\tgs.ready"
|
||||||
|
Wait-SharedReady $graphReady $tgsReady $graphName $tgsName
|
||||||
|
[DateTimeOffset]::UtcNow.ToString("o") | Set-Content -LiteralPath (
|
||||||
|
Join-Path $runOutput "control\start.signal"
|
||||||
|
) -Encoding utf8
|
||||||
|
|
||||||
|
$telemetryPath = Join-Path $runOutput "container-telemetry.jsonl"
|
||||||
|
while ($true) {
|
||||||
|
$graphState = Get-Container $graphName
|
||||||
|
$tgsState = Get-Container $tgsName
|
||||||
|
$running = @()
|
||||||
|
if ($graphState.State.Running) { $running += $graphName }
|
||||||
|
if ($tgsState.State.Running) { $running += $tgsName }
|
||||||
|
if ((Get-Container $tritonName).State.Running) { $running += $tritonName }
|
||||||
|
if ($running.Count -gt 0) {
|
||||||
|
$stats = @((& docker stats --no-stream --format "{{json .}}" @running))
|
||||||
|
Assert-LastExitCode "M49 integrated container telemetry"
|
||||||
|
foreach ($line in $stats) {
|
||||||
|
$value = $line | ConvertFrom-Json
|
||||||
|
$role = if ($value.Name -ceq $graphName) {
|
||||||
|
"graph"
|
||||||
|
} elseif ($value.Name -ceq $tgsName) {
|
||||||
|
"tgs"
|
||||||
|
} elseif ($value.Name -ceq $tritonName) {
|
||||||
|
"triton"
|
||||||
|
} else {
|
||||||
|
throw "Unknown M49 telemetry container"
|
||||||
|
}
|
||||||
|
[ordered]@{
|
||||||
|
observed_utc = [DateTimeOffset]::UtcNow.ToString("o")
|
||||||
|
role = $role
|
||||||
|
name = [string]$value.Name
|
||||||
|
cpu_percent = [string]$value.CPUPerc
|
||||||
|
memory_usage = [string]$value.MemUsage
|
||||||
|
memory_percent = [string]$value.MemPerc
|
||||||
|
pids = [string]$value.PIDs
|
||||||
|
} | ConvertTo-Json -Compress | Out-File -LiteralPath $telemetryPath -Encoding utf8 -Append
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $graphState.State.Running -and -not $tgsState.State.Running) { break }
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
}
|
||||||
|
$graphExit = [int](Get-Container $graphName).State.ExitCode
|
||||||
|
$tgsExit = [int](Get-Container $tgsName).State.ExitCode
|
||||||
|
$previousErrorAction = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
$graphLogs = & docker logs $graphName 2>&1
|
||||||
|
$tgsLogs = & docker logs $tgsName 2>&1
|
||||||
|
$ErrorActionPreference = $previousErrorAction
|
||||||
|
$graphLogs | Set-Content -LiteralPath (Join-Path $runOutput "graph.log") -Encoding utf8
|
||||||
|
$tgsLogs | Set-Content -LiteralPath (Join-Path $runOutput "tgs.log") -Encoding utf8
|
||||||
|
if ($graphExit -ne 0) { throw "M49 integrated graph failed with exit code $graphExit" }
|
||||||
|
if ($tgsExit -ne 0) { throw "M49 integrated TGS failed with exit code $tgsExit" }
|
||||||
|
|
||||||
|
& docker run --rm --name $analyzeName --network none --cpus 8 --memory 16g `
|
||||||
|
--entrypoint python3 `
|
||||||
|
--volume ($dockerRelease + ":/release:ro") `
|
||||||
|
--volume ($dockerRun + ":/shared:rw") `
|
||||||
|
$ParityImageTag /release/build_tgs_full_shadow_evidence.py `
|
||||||
|
--run-root /shared/tgs `
|
||||||
|
--config /release/m49-tgs-full-shadow-v1.json `
|
||||||
|
--output-root /shared/tgs/evidence
|
||||||
|
Assert-LastExitCode "M49 integrated TGS evidence analysis"
|
||||||
|
|
||||||
|
& docker run --rm --name $evidenceName --network none --cpus 4 --memory 8g `
|
||||||
|
--entrypoint python3 `
|
||||||
|
--volume ($dockerRelease + ":/release:ro") `
|
||||||
|
--volume ($dockerRun + ":/shared:rw") `
|
||||||
|
$ParityImageTag /release/build_tgs_integrated_graph_evidence.py `
|
||||||
|
--profile /release/m49-tgs-integrated-graph-shadow-v1.json `
|
||||||
|
--graph-result /shared/graph/result.json `
|
||||||
|
--graph-frames /shared/graph/frames.jsonl `
|
||||||
|
--tgs-result /shared/tgs/evidence/result.json `
|
||||||
|
--tgs-timing /shared/tgs/tgs-full-timing.tsv `
|
||||||
|
--telemetry /shared/container-telemetry.jsonl `
|
||||||
|
--output /shared/result.json `
|
||||||
|
--release-sha256 $ExpectedArtifactSha256
|
||||||
|
Assert-LastExitCode "M49 integrated evidence gate"
|
||||||
|
} finally {
|
||||||
|
foreach ($name in $containers) { Remove-ExactContainer $name }
|
||||||
|
$canonicalAfter = Get-Container "ndc-mission-core-triton"
|
||||||
|
if (
|
||||||
|
[string]$canonicalAfter.Id -cne $canonicalId -or
|
||||||
|
-not $canonicalAfter.State.Running -or
|
||||||
|
$canonicalAfter.State.Health.Status -cne "healthy"
|
||||||
|
) { throw "Canonical Mission Core Triton changed during M49 integrated shadow" }
|
||||||
|
}
|
||||||
|
|
||||||
|
$completed = [DateTimeOffset]::UtcNow
|
||||||
|
$resultPath = Join-Path $runOutput "result.json"
|
||||||
|
if (-not (Test-Path -LiteralPath $resultPath -PathType Leaf)) {
|
||||||
|
throw "M49 integrated result is missing"
|
||||||
|
}
|
||||||
|
$result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json
|
||||||
|
$summary = [ordered]@{
|
||||||
|
schema_version = "missioncore.m49-tgs-integrated-graph-worker-summary/v1"
|
||||||
|
worker_id = "worker-006"
|
||||||
|
run_id = $RunId
|
||||||
|
code_revision = [string]$releaseDocument.code_revision
|
||||||
|
source_rate_hz = $SourceRateHz
|
||||||
|
started_utc = $started.ToString("o")
|
||||||
|
completed_utc = $completed.ToString("o")
|
||||||
|
wall_seconds = [math]::Round(($completed - $started).TotalSeconds, 6)
|
||||||
|
free_memory_gib_before = [math]::Round($freeMemoryGiB, 6)
|
||||||
|
result_id = [string]$result.result_id
|
||||||
|
result_status = [string]$result.status
|
||||||
|
canonical_triton_id = $canonicalId
|
||||||
|
canonical_triton_health = "healthy"
|
||||||
|
gauss_or_playcanvas_action = "none"
|
||||||
|
durable_worker_action = "none"
|
||||||
|
navigation_or_actuation_allowed = $false
|
||||||
|
}
|
||||||
|
$summary | ConvertTo-Json -Depth 3 | Set-Content -LiteralPath (
|
||||||
|
Join-Path $runOutput "worker-summary.json"
|
||||||
|
) -Encoding utf8
|
||||||
|
$summary | ConvertTo-Json -Depth 3
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
readonly TARGET=${1:?target binary path is required}
|
||||||
|
|
||||||
|
test -f /release/run_tgs_full_shadow.cpp
|
||||||
|
test ! -e "${TARGET}"
|
||||||
|
mkdir -p "$(dirname "${TARGET}")"
|
||||||
|
g++ -std=c++17 -O3 -DNDEBUG -pthread \
|
||||||
|
-I/opt/travel/src/TRAVEL/cpp/travel/core \
|
||||||
|
-I/usr/include/eigen3 \
|
||||||
|
/release/run_tgs_full_shadow.cpp \
|
||||||
|
-o "${TARGET}"
|
||||||
|
chmod 0755 "${TARGET}"
|
||||||
|
sha256sum "${TARGET}"
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Seal the synchronized TGS plus native RF-DETR reference-graph shadow."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
PROFILE_SCHEMA = "missioncore.m49-tgs-integrated-graph-shadow-profile/v1"
|
||||||
|
GRAPH_SCHEMA = "missioncore.m48s-reference-graph-shadow-load/v5"
|
||||||
|
TGS_SCHEMA = "missioncore.m49-tgs-full-shadow-result/v1"
|
||||||
|
RESULT_SCHEMA = "missioncore.m49-tgs-integrated-graph-shadow-result/v1"
|
||||||
|
FRAME_COUNT = 4_489
|
||||||
|
|
||||||
|
|
||||||
|
class IntegratedShadowError(RuntimeError):
|
||||||
|
"""The integrated shadow evidence is incomplete or incompatible."""
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_json(value: object) -> bytes:
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path: Path, label: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
raise IntegratedShadowError(f"{label} is unreadable") from exc
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise IntegratedShadowError(f"{label} is not an object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def distribution(values: list[float]) -> dict[str, float]:
|
||||||
|
if not values:
|
||||||
|
return {"mean": 0.0, "p50": 0.0, "p95": 0.0, "p99": 0.0, "maximum": 0.0}
|
||||||
|
array = np.asarray(values, dtype=np.float64)
|
||||||
|
return {
|
||||||
|
"mean": round(float(array.mean()), 6),
|
||||||
|
"p50": round(float(np.percentile(array, 50)), 6),
|
||||||
|
"p95": round(float(np.percentile(array, 95)), 6),
|
||||||
|
"p99": round(float(np.percentile(array, 99)), 6),
|
||||||
|
"maximum": round(float(array.max()), 6),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def graph_completion_ages(path: Path) -> list[float]:
|
||||||
|
ages: list[float] = []
|
||||||
|
with path.open("r", encoding="utf-8") as stream:
|
||||||
|
for expected, line in enumerate(stream):
|
||||||
|
row = json.loads(line)
|
||||||
|
sequence = row.get("source_envelope", {}).get("sequence")
|
||||||
|
if sequence != expected:
|
||||||
|
raise IntegratedShadowError("graph frame ledger sequence changed")
|
||||||
|
age_ns = row.get("completion_age_ns")
|
||||||
|
if not isinstance(age_ns, int) or age_ns < 0:
|
||||||
|
raise IntegratedShadowError("graph completion age is invalid")
|
||||||
|
ages.append(age_ns / 1_000_000.0)
|
||||||
|
if len(ages) != FRAME_COUNT:
|
||||||
|
raise IntegratedShadowError("graph frame ledger is incomplete")
|
||||||
|
return ages
|
||||||
|
|
||||||
|
|
||||||
|
def tgs_completion_ages(path: Path) -> list[float]:
|
||||||
|
ages: list[float] = []
|
||||||
|
with path.open("r", encoding="utf-8", newline="") as stream:
|
||||||
|
for expected, row in enumerate(csv.DictReader(stream, delimiter="\t")):
|
||||||
|
if int(row["timeline_frame_index"]) != expected:
|
||||||
|
raise IntegratedShadowError("TGS timing sequence changed")
|
||||||
|
value = float(row["completion_age_ms"])
|
||||||
|
if not math.isfinite(value) or value < 0:
|
||||||
|
raise IntegratedShadowError("TGS completion age is invalid")
|
||||||
|
ages.append(value)
|
||||||
|
if len(ages) != FRAME_COUNT:
|
||||||
|
raise IntegratedShadowError("TGS timing ledger is incomplete")
|
||||||
|
return ages
|
||||||
|
|
||||||
|
|
||||||
|
_SIZE = re.compile(r"^\s*([0-9.]+)\s*([kmgt]?i?b)\s*$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def size_mib(value: str) -> float:
|
||||||
|
match = _SIZE.fullmatch(value)
|
||||||
|
if match is None:
|
||||||
|
raise IntegratedShadowError("container memory telemetry is invalid")
|
||||||
|
number = float(match.group(1))
|
||||||
|
unit = match.group(2).lower()
|
||||||
|
scale = {
|
||||||
|
"b": 1.0 / (1024.0 * 1024.0),
|
||||||
|
"kb": 1.0 / 1024.0,
|
||||||
|
"kib": 1.0 / 1024.0,
|
||||||
|
"mb": 1.0,
|
||||||
|
"mib": 1.0,
|
||||||
|
"gb": 1024.0,
|
||||||
|
"gib": 1024.0,
|
||||||
|
"tb": 1024.0 * 1024.0,
|
||||||
|
"tib": 1024.0 * 1024.0,
|
||||||
|
}[unit]
|
||||||
|
return number * scale
|
||||||
|
|
||||||
|
|
||||||
|
def host_telemetry(path: Path) -> dict[str, object]:
|
||||||
|
samples: dict[str, list[dict[str, float]]] = defaultdict(list)
|
||||||
|
with path.open("r", encoding="utf-8-sig") as stream:
|
||||||
|
for line in stream:
|
||||||
|
row = json.loads(line)
|
||||||
|
role = row.get("role")
|
||||||
|
if role not in {"graph", "tgs", "triton"}:
|
||||||
|
raise IntegratedShadowError("container telemetry role changed")
|
||||||
|
cpu_text = row.get("cpu_percent")
|
||||||
|
memory_text = row.get("memory_usage")
|
||||||
|
memory_percent_text = row.get("memory_percent")
|
||||||
|
if not all(
|
||||||
|
isinstance(value, str) for value in (cpu_text, memory_text, memory_percent_text)
|
||||||
|
):
|
||||||
|
raise IntegratedShadowError("container telemetry row is incomplete")
|
||||||
|
used_text = memory_text.split("/", 1)[0].strip()
|
||||||
|
samples[role].append(
|
||||||
|
{
|
||||||
|
"cpu_percent": float(cpu_text.rstrip("%")),
|
||||||
|
"memory_used_mib": size_mib(used_text),
|
||||||
|
"memory_percent": float(memory_percent_text.rstrip("%")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if any(not samples[role] for role in ("graph", "tgs", "triton")):
|
||||||
|
raise IntegratedShadowError("container telemetry does not cover every runtime role")
|
||||||
|
return {
|
||||||
|
role: {
|
||||||
|
"sample_count": len(rows),
|
||||||
|
"cpu_percent": distribution([row["cpu_percent"] for row in rows]),
|
||||||
|
"memory_used_mib": distribution([row["memory_used_mib"] for row in rows]),
|
||||||
|
"memory_percent": distribution([row["memory_percent"] for row in rows]),
|
||||||
|
}
|
||||||
|
for role, rows in sorted(samples.items())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build(
|
||||||
|
*,
|
||||||
|
profile_path: Path,
|
||||||
|
graph_result_path: Path,
|
||||||
|
graph_frames_path: Path,
|
||||||
|
tgs_result_path: Path,
|
||||||
|
tgs_timing_path: Path,
|
||||||
|
telemetry_path: Path,
|
||||||
|
output_path: Path,
|
||||||
|
release_sha256: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if output_path.exists():
|
||||||
|
raise IntegratedShadowError("integrated result already exists")
|
||||||
|
profile = load_json(profile_path, "integrated profile")
|
||||||
|
graph = load_json(graph_result_path, "reference graph result")
|
||||||
|
tgs = load_json(tgs_result_path, "TGS result")
|
||||||
|
if profile.get("schema_version") != PROFILE_SCHEMA:
|
||||||
|
raise IntegratedShadowError("integrated profile schema changed")
|
||||||
|
if graph.get("schema_version") != GRAPH_SCHEMA:
|
||||||
|
raise IntegratedShadowError("reference graph result schema changed")
|
||||||
|
if tgs.get("schema_version") != TGS_SCHEMA:
|
||||||
|
raise IntegratedShadowError("TGS result schema changed")
|
||||||
|
if len(release_sha256) != 64 or any(
|
||||||
|
value not in "0123456789abcdef" for value in release_sha256
|
||||||
|
):
|
||||||
|
raise IntegratedShadowError("release SHA-256 is invalid")
|
||||||
|
|
||||||
|
graph_ages = graph_completion_ages(graph_frames_path)
|
||||||
|
tgs_ages = tgs_completion_ages(tgs_timing_path)
|
||||||
|
combined_ages = [
|
||||||
|
max(graph_age, tgs_age) for graph_age, tgs_age in zip(graph_ages, tgs_ages, strict=True)
|
||||||
|
]
|
||||||
|
combined = distribution(combined_ages)
|
||||||
|
telemetry = host_telemetry(telemetry_path)
|
||||||
|
acceptance_profile = profile["acceptance"]
|
||||||
|
execution = graph.get("execution", {})
|
||||||
|
graph_metrics = graph.get("metrics", {})
|
||||||
|
tgs_performance = tgs.get("performance", {})
|
||||||
|
effective_fps = float(execution.get("effective_world_state_fps", 0.0))
|
||||||
|
reference_fps = float(acceptance_profile["reference_world_state_fps"])
|
||||||
|
fps_regression = max(0.0, (reference_fps - effective_fps) / reference_fps)
|
||||||
|
graph_p95 = float(graph_metrics.get("world_state_completion_age_ms", {}).get("p95", math.inf))
|
||||||
|
tgs_p95 = float(tgs_performance.get("candidate_tgs_ms", {}).get("p95", math.inf))
|
||||||
|
tgs_p99 = float(tgs_performance.get("candidate_tgs_ms", {}).get("p99", math.inf))
|
||||||
|
tgs_drops = int(tgs_performance.get("capacity_drop_count", -1))
|
||||||
|
terminal = execution.get("terminal_outcomes", {})
|
||||||
|
superseded = int(terminal.get("superseded", 0)) if isinstance(terminal, dict) else -1
|
||||||
|
gpu_samples = int(graph_metrics.get("gpu", {}).get("sample_count", 0))
|
||||||
|
graph_inputs = graph.get("identity", {}).get("inputs", {})
|
||||||
|
checks = {
|
||||||
|
"frozen_graph_identity": (
|
||||||
|
graph_inputs.get("graph_config")
|
||||||
|
== profile["stages"]["reference_graph"]["graph_config_sha256"]
|
||||||
|
and graph_inputs.get("detector_profile")
|
||||||
|
== profile["stages"]["reference_graph"]["detector_profile_sha256"]
|
||||||
|
),
|
||||||
|
"frozen_tgs_identity": (
|
||||||
|
tgs.get("config_sha256") == profile["stages"]["tgs"]["profile_sha256"]
|
||||||
|
),
|
||||||
|
"requested_source_rate_preserved": (
|
||||||
|
execution.get("requested_source_rate_hz")
|
||||||
|
== profile["source"]["requested_source_rate_hz"]
|
||||||
|
),
|
||||||
|
"all_graph_frames_delivered": (
|
||||||
|
execution.get("admitted_frames") == FRAME_COUNT
|
||||||
|
and execution.get("delivered_world_states") == FRAME_COUNT
|
||||||
|
),
|
||||||
|
"all_tgs_frames_accounted": tgs.get("timeline", {}).get("frame_count") == FRAME_COUNT,
|
||||||
|
"exact_sequence_join": len(combined_ages) == FRAME_COUNT,
|
||||||
|
"minimum_delivery_ratio": float(execution.get("delivery_ratio", 0.0))
|
||||||
|
>= float(acceptance_profile["minimum_delivery_ratio"]),
|
||||||
|
"maximum_world_state_fps_regression": fps_regression
|
||||||
|
<= float(acceptance_profile["maximum_world_state_fps_regression_fraction"]),
|
||||||
|
"minimum_effective_world_state_fps": effective_fps
|
||||||
|
>= float(acceptance_profile["minimum_effective_world_state_fps"]),
|
||||||
|
"maximum_world_state_completion_p95_ms": graph_p95
|
||||||
|
<= float(acceptance_profile["maximum_world_state_completion_p95_ms"]),
|
||||||
|
"candidate_stage_p95_ms": tgs_p95
|
||||||
|
<= float(acceptance_profile["candidate_stage_p95_ms_max"]),
|
||||||
|
"candidate_stage_p99_ms": tgs_p99
|
||||||
|
<= float(acceptance_profile["candidate_stage_p99_ms_max"]),
|
||||||
|
"combined_output_age_p99_ms": combined["p99"]
|
||||||
|
<= float(acceptance_profile["combined_output_age_p99_ms_max"]),
|
||||||
|
"zero_capacity_drops": tgs_drops <= int(acceptance_profile["capacity_drop_count_max"])
|
||||||
|
and superseded <= int(acceptance_profile["capacity_drop_count_max"]),
|
||||||
|
"reference_graph_integrity": graph.get("evidence_integrity_gate_passed") is True,
|
||||||
|
"tgs_integrity": tgs.get("status") == "passed",
|
||||||
|
"host_resource_telemetry_complete": all(
|
||||||
|
telemetry[role]["sample_count"] > 0 for role in ("graph", "tgs", "triton")
|
||||||
|
),
|
||||||
|
"gpu_telemetry_complete": gpu_samples > 0,
|
||||||
|
"authority_remains_false": all(value is False for value in profile["authority"].values()),
|
||||||
|
}
|
||||||
|
files = {
|
||||||
|
label: {"bytes": path.stat().st_size, "sha256": sha256_file(path)}
|
||||||
|
for label, path in (
|
||||||
|
("graph-result.json", graph_result_path),
|
||||||
|
("graph-frames.jsonl", graph_frames_path),
|
||||||
|
("tgs-result.json", tgs_result_path),
|
||||||
|
("tgs-timing.tsv", tgs_timing_path),
|
||||||
|
("container-telemetry.jsonl", telemetry_path),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
document: dict[str, object] = {
|
||||||
|
"schema_version": RESULT_SCHEMA,
|
||||||
|
"profile_id": profile["profile_id"],
|
||||||
|
"status": "passed" if all(checks.values()) else "failed",
|
||||||
|
"source": {
|
||||||
|
"source_id": profile["source"]["source_id"],
|
||||||
|
"source_pack_sha256": profile["source"]["source_pack_sha256"],
|
||||||
|
"requested_source_rate_hz": profile["source"]["requested_source_rate_hz"],
|
||||||
|
"joined_frame_count": len(combined_ages),
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"release_sha256": release_sha256,
|
||||||
|
"profile_sha256": sha256_file(profile_path),
|
||||||
|
"graph_config_sha256": profile["stages"]["reference_graph"]["graph_config_sha256"],
|
||||||
|
"tgs_profile_sha256": profile["stages"]["tgs"]["profile_sha256"],
|
||||||
|
"linked_accepted_tgs_result_id": profile["stages"]["tgs"]["linked_accepted_result_id"],
|
||||||
|
},
|
||||||
|
"performance": {
|
||||||
|
"effective_world_state_fps": effective_fps,
|
||||||
|
"reference_world_state_fps": reference_fps,
|
||||||
|
"world_state_fps_regression_fraction": round(fps_regression, 9),
|
||||||
|
"world_state_completion_age_ms": graph_metrics.get("world_state_completion_age_ms"),
|
||||||
|
"tgs_candidate_stage_ms": tgs_performance.get("candidate_tgs_ms"),
|
||||||
|
"tgs_completion_age_ms": tgs_performance.get("completion_age_ms"),
|
||||||
|
"combined_output_age_ms": combined,
|
||||||
|
"gpu": graph_metrics.get("gpu"),
|
||||||
|
"host_containers": telemetry,
|
||||||
|
},
|
||||||
|
"accounting": {
|
||||||
|
"graph_admitted": execution.get("admitted_frames"),
|
||||||
|
"graph_delivered": execution.get("delivered_world_states"),
|
||||||
|
"graph_terminal_outcomes": terminal,
|
||||||
|
"tgs_timeline_frames": tgs.get("timeline", {}).get("frame_count"),
|
||||||
|
"tgs_available_lidar_frames": tgs.get("timeline", {}).get(
|
||||||
|
"available_lidar_frame_count"
|
||||||
|
),
|
||||||
|
"tgs_capacity_drops": tgs_drops,
|
||||||
|
},
|
||||||
|
"checks": checks,
|
||||||
|
"integrated_runtime_gate_passed": all(checks.values()),
|
||||||
|
"visual_quality_accepted": False,
|
||||||
|
"traversability_accepted": False,
|
||||||
|
"production_accepted": False,
|
||||||
|
"authority": profile["authority"],
|
||||||
|
"files": files,
|
||||||
|
}
|
||||||
|
identity = hashlib.sha256(canonical_json(document)).hexdigest()
|
||||||
|
document["result_id"] = f"m49-tgs-integrated-graph-shadow-{identity}"
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output_path.write_text(json.dumps(document, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
return document
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--profile", type=Path, required=True)
|
||||||
|
parser.add_argument("--graph-result", type=Path, required=True)
|
||||||
|
parser.add_argument("--graph-frames", type=Path, required=True)
|
||||||
|
parser.add_argument("--tgs-result", type=Path, required=True)
|
||||||
|
parser.add_argument("--tgs-timing", type=Path, required=True)
|
||||||
|
parser.add_argument("--telemetry", type=Path, required=True)
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
parser.add_argument("--release-sha256", required=True)
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
result = build(
|
||||||
|
profile_path=arguments.profile,
|
||||||
|
graph_result_path=arguments.graph_result,
|
||||||
|
graph_frames_path=arguments.graph_frames,
|
||||||
|
tgs_result_path=arguments.tgs_result,
|
||||||
|
tgs_timing_path=arguments.tgs_timing,
|
||||||
|
telemetry_path=arguments.telemetry,
|
||||||
|
output_path=arguments.output,
|
||||||
|
release_sha256=arguments.release_sha256,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
json.dumps({"result_id": result["result_id"], "status": result["status"]}, sort_keys=True)
|
||||||
|
)
|
||||||
|
return 0 if result["status"] == "passed" else 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <iomanip>
|
#include <iomanip>
|
||||||
@@ -76,11 +77,38 @@ double milliseconds(Clock::duration duration) {
|
|||||||
return std::chrono::duration<double, std::milli>(duration).count();
|
return std::chrono::duration<double, std::milli>(duration).count();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void waitForSharedStart(const std::string& ready_path, const std::string& start_path) {
|
||||||
|
if (ready_path.empty() != start_path.empty()) {
|
||||||
|
throw std::runtime_error("shared-start paths must be configured together");
|
||||||
|
}
|
||||||
|
if (ready_path.empty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (std::filesystem::exists(ready_path)) {
|
||||||
|
throw std::runtime_error("shared-start ready file already exists");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
std::ofstream ready(ready_path);
|
||||||
|
ready << "ready\n";
|
||||||
|
if (!ready) {
|
||||||
|
throw std::runtime_error("cannot publish TGS shared-start readiness");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const auto deadline = Clock::now() + std::chrono::minutes(10);
|
||||||
|
while (!std::filesystem::is_regular_file(start_path)) {
|
||||||
|
if (Clock::now() >= deadline) {
|
||||||
|
throw std::runtime_error("TGS shared-start barrier timed out");
|
||||||
|
}
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
int main(int argc, char** argv) {
|
int main(int argc, char** argv) {
|
||||||
if (argc != 5) {
|
if (argc != 5 && argc != 8) {
|
||||||
std::cerr << "Usage: run_tgs_full_shadow <sequence_dir> <schedule.tsv> <output_dir> <timing.tsv>\n";
|
std::cerr << "Usage: run_tgs_full_shadow <sequence_dir> <schedule.tsv> <output_dir>"
|
||||||
|
" <timing.tsv> [target_rate_hz ready_file start_file]\n";
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -89,6 +117,19 @@ int main(int argc, char** argv) {
|
|||||||
const std::string output_dir = argv[3];
|
const std::string output_dir = argv[3];
|
||||||
const std::string timing_path = argv[4];
|
const std::string timing_path = argv[4];
|
||||||
const auto schedule = readSchedule(schedule_path);
|
const auto schedule = readSchedule(schedule_path);
|
||||||
|
const double target_rate_hz = argc == 8 ? std::stod(argv[5]) : 0.0;
|
||||||
|
if (target_rate_hz < 0.0 || !std::isfinite(target_rate_hz)) {
|
||||||
|
throw std::runtime_error("invalid TGS target rate");
|
||||||
|
}
|
||||||
|
const double source_duration_seconds =
|
||||||
|
schedule.back().session_seconds - schedule.front().session_seconds;
|
||||||
|
if (!(source_duration_seconds > 0.0)) {
|
||||||
|
throw std::runtime_error("invalid TGS source duration");
|
||||||
|
}
|
||||||
|
const double recorded_rate_hz =
|
||||||
|
static_cast<double>(schedule.size() - 1) / source_duration_seconds;
|
||||||
|
const double pacing_scale =
|
||||||
|
target_rate_hz > 0.0 ? recorded_rate_hz / target_rate_hz : 1.0;
|
||||||
KittiLoader loader(sequence_dir);
|
KittiLoader loader(sequence_dir);
|
||||||
if (loader.size() != 3928) {
|
if (loader.size() != 3928) {
|
||||||
throw std::runtime_error("full-shadow available LiDAR frame count changed");
|
throw std::runtime_error("full-shadow available LiDAR frame count changed");
|
||||||
@@ -103,12 +144,14 @@ int main(int argc, char** argv) {
|
|||||||
<< "\ttgs_ms\tstage_wall_ms\tqueue_delay_ms\tcompletion_age_ms\tcapacity_drop\n";
|
<< "\ttgs_ms\tstage_wall_ms\tqueue_delay_ms\tcompletion_age_ms\tcapacity_drop\n";
|
||||||
timing << std::fixed << std::setprecision(6);
|
timing << std::fixed << std::setprecision(6);
|
||||||
|
|
||||||
|
waitForSharedStart(argc == 8 ? argv[6] : "", argc == 8 ? argv[7] : "");
|
||||||
const double first_source_seconds = schedule.front().session_seconds;
|
const double first_source_seconds = schedule.front().session_seconds;
|
||||||
const auto run_started = Clock::now();
|
const auto run_started = Clock::now();
|
||||||
std::size_t expected_slot = 0;
|
std::size_t expected_slot = 0;
|
||||||
for (const auto& row : schedule) {
|
for (const auto& row : schedule) {
|
||||||
const auto target = run_started + std::chrono::duration_cast<Clock::duration>(
|
const auto target = run_started + std::chrono::duration_cast<Clock::duration>(
|
||||||
std::chrono::duration<double>(row.session_seconds - first_source_seconds));
|
std::chrono::duration<double>(
|
||||||
|
(row.session_seconds - first_source_seconds) * pacing_scale));
|
||||||
const auto before_wait = Clock::now();
|
const auto before_wait = Clock::now();
|
||||||
if (before_wait < target) {
|
if (before_wait < target) {
|
||||||
std::this_thread::sleep_until(target);
|
std::this_thread::sleep_until(target);
|
||||||
@@ -173,6 +216,9 @@ int main(int argc, char** argv) {
|
|||||||
throw std::runtime_error("full-shadow available frame accounting changed");
|
throw std::runtime_error("full-shadow available frame accounting changed");
|
||||||
}
|
}
|
||||||
std::cout << "[TGS-FULL] complete timeline=4489 available=3928\n";
|
std::cout << "[TGS-FULL] complete timeline=4489 available=3928\n";
|
||||||
|
std::cout << "[TGS-FULL] recorded_rate_hz=" << recorded_rate_hz
|
||||||
|
<< " target_rate_hz=" << (target_rate_hz > 0.0 ? target_rate_hz : recorded_rate_hz)
|
||||||
|
<< "\n";
|
||||||
return 0;
|
return 0;
|
||||||
} catch (const std::exception& error) {
|
} catch (const std::exception& error) {
|
||||||
std::cerr << "[TGS-FULL] " << error.what() << '\n';
|
std::cerr << "[TGS-FULL] " << error.what() << '\n';
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
readonly BINARY=/shared/bin/run_tgs_full_shadow
|
||||||
|
readonly INPUT_ROOT=/shared/tgs/inputs
|
||||||
|
readonly OUTPUT_ROOT=/shared/tgs/outputs/causal_rolling_1s
|
||||||
|
readonly TIMING_PATH=/shared/tgs/tgs-full-timing.tsv
|
||||||
|
readonly READY_FILE=/shared/control/tgs.ready
|
||||||
|
readonly START_FILE=/shared/control/start.signal
|
||||||
|
readonly SOURCE_RATE_HZ=${M49_SOURCE_RATE_HZ:-12.0}
|
||||||
|
|
||||||
|
test -x "${BINARY}"
|
||||||
|
test -f "${INPUT_ROOT}/input-manifest.json"
|
||||||
|
test -f "${INPUT_ROOT}/schedule.tsv"
|
||||||
|
test ! -e /shared/tgs/outputs
|
||||||
|
test ! -e "${TIMING_PATH}"
|
||||||
|
test ! -e "${READY_FILE}"
|
||||||
|
mkdir -p "${OUTPUT_ROOT}"
|
||||||
|
exec /usr/bin/time -v "${BINARY}" \
|
||||||
|
"${INPUT_ROOT}/profiles/causal_rolling_1s" \
|
||||||
|
"${INPUT_ROOT}/schedule.tsv" \
|
||||||
|
"${OUTPUT_ROOT}" \
|
||||||
|
"${TIMING_PATH}" \
|
||||||
|
"${SOURCE_RATE_HZ}" \
|
||||||
|
"${READY_FILE}" \
|
||||||
|
"${START_FILE}"
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build a clean-revision Worker 006 release for the integrated M4.9 shadow."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import gzip
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
WHEEL_NAME = "nodedc_mission_core-0.1.0-py3-none-any.whl"
|
||||||
|
PATCH_ID = re.compile(r"^[A-Za-z0-9._-]{1,96}$")
|
||||||
|
SOURCES = (
|
||||||
|
Path("experiments/perception/worker/Invoke-M49TgsIntegratedGraphShadow.ps1"),
|
||||||
|
Path("experiments/perception/run_m48s_reference_graph_shadow_worker.py"),
|
||||||
|
Path("experiments/perception/worker/rf_detr_large_native_kb4_config.pbtxt"),
|
||||||
|
Path("experiments/perception/worker/m49_t3_travel/prepare_tgs_fail_closed_inputs.py"),
|
||||||
|
Path("experiments/perception/worker/m49_t3_travel/build_tgs_fail_closed_evidence.py"),
|
||||||
|
Path("experiments/perception/worker/m49_t3_travel/prepare_tgs_full_shadow_inputs.py"),
|
||||||
|
Path("experiments/perception/worker/m49_t3_travel/run_tgs_full_shadow.cpp"),
|
||||||
|
Path("experiments/perception/worker/m49_t3_travel/build_tgs_full_shadow_binary.sh"),
|
||||||
|
Path("experiments/perception/worker/m49_t3_travel/run_tgs_integrated_shadow.sh"),
|
||||||
|
Path("experiments/perception/worker/m49_t3_travel/build_tgs_full_shadow_evidence.py"),
|
||||||
|
Path("experiments/perception/worker/m49_t3_travel/build_tgs_integrated_graph_evidence.py"),
|
||||||
|
Path("config/perception/m49-tgs-integrated-graph-shadow-v1.json"),
|
||||||
|
Path("config/perception/m49-tgs-full-shadow-v1.json"),
|
||||||
|
Path("config/perception/m48n-rf-detr-native-reference-graph-shadow-v0.json"),
|
||||||
|
Path("config/perception/m4-recorded-realtime-baseline-v1.json"),
|
||||||
|
Path("config/perception/rf-detr-large-native-kb4-risk-shadow-v0.json"),
|
||||||
|
Path("config/perception/m4-geometry-association-v1.json"),
|
||||||
|
Path("config/perception/m4-temporal-motion-v1.json"),
|
||||||
|
Path("config/perception/m4-rolling-local-map-v1.json"),
|
||||||
|
Path("config/perception/m4-replay-threat-v3.json"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactBuildError(RuntimeError):
|
||||||
|
"""The integrated Worker release cannot be built from its declared revision."""
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def git_revision() -> str:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "rev-parse", "HEAD"],
|
||||||
|
cwd=REPOSITORY_ROOT,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
revision = result.stdout.strip()
|
||||||
|
if re.fullmatch(r"[a-f0-9]{40}", revision) is None:
|
||||||
|
raise ArtifactBuildError("Git revision is not a full SHA-1")
|
||||||
|
return revision
|
||||||
|
|
||||||
|
|
||||||
|
def materialize_revision(revision: str, destination: Path) -> None:
|
||||||
|
archive_path = destination.parent / "source.tar"
|
||||||
|
subprocess.run(
|
||||||
|
["git", "archive", "--format=tar", "--output", str(archive_path), revision],
|
||||||
|
cwd=REPOSITORY_ROOT,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
destination.mkdir()
|
||||||
|
root = destination.resolve()
|
||||||
|
with tarfile.open(archive_path, "r:") as archive:
|
||||||
|
for member in archive.getmembers():
|
||||||
|
target = (destination / member.name).resolve()
|
||||||
|
if target != root and root not in target.parents:
|
||||||
|
raise ArtifactBuildError("Git archive contains an unsafe path")
|
||||||
|
archive.extractall(destination)
|
||||||
|
|
||||||
|
|
||||||
|
def build_wheel(source_root: Path, output: Path) -> Path:
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["SOURCE_DATE_EPOCH"] = "0"
|
||||||
|
result = subprocess.run(
|
||||||
|
["uv", "build", "--wheel", "--out-dir", str(output)],
|
||||||
|
cwd=source_root,
|
||||||
|
env=environment,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
detail = (result.stderr or result.stdout).strip()
|
||||||
|
raise ArtifactBuildError(f"wheel build failed: {detail}")
|
||||||
|
wheel = output / WHEEL_NAME
|
||||||
|
if not wheel.is_file() or wheel.is_symlink():
|
||||||
|
raise ArtifactBuildError("expected Worker wheel was not built")
|
||||||
|
return wheel
|
||||||
|
|
||||||
|
|
||||||
|
def tar_info(path: Path, arcname: str) -> tarfile.TarInfo:
|
||||||
|
info = tarfile.TarInfo(arcname)
|
||||||
|
info.uid = info.gid = 0
|
||||||
|
info.uname = info.gname = "root"
|
||||||
|
info.mtime = 0
|
||||||
|
if path.is_dir():
|
||||||
|
info.type = tarfile.DIRTYPE
|
||||||
|
info.mode = 0o755
|
||||||
|
else:
|
||||||
|
info.type = tarfile.REGTYPE
|
||||||
|
info.mode = 0o755 if path.suffix in {".sh", ".ps1", ".py"} else 0o644
|
||||||
|
info.size = path.stat().st_size
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def write_archive(stage: Path, target: Path) -> None:
|
||||||
|
members = [stage / "manifest.env", stage / "files.txt", stage / "payload"]
|
||||||
|
members.extend(sorted((stage / "payload").rglob("*")))
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with (
|
||||||
|
target.open("wb") as raw,
|
||||||
|
gzip.GzipFile(filename="", mode="wb", fileobj=raw, compresslevel=9, mtime=0) as compressed,
|
||||||
|
tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as archive,
|
||||||
|
):
|
||||||
|
for path in members:
|
||||||
|
info = tar_info(path, path.relative_to(stage).as_posix())
|
||||||
|
if path.is_file():
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
archive.addfile(info, stream)
|
||||||
|
else:
|
||||||
|
archive.addfile(info, io.BytesIO())
|
||||||
|
|
||||||
|
|
||||||
|
def build_artifact(
|
||||||
|
patch_id: str,
|
||||||
|
output_directory: Path,
|
||||||
|
*,
|
||||||
|
revision: str | None = None,
|
||||||
|
source_root: Path | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if PATCH_ID.fullmatch(patch_id) is None:
|
||||||
|
raise ArtifactBuildError("patch id is invalid")
|
||||||
|
selected_revision = revision or git_revision()
|
||||||
|
if re.fullmatch(r"[a-f0-9]{40}", selected_revision) is None:
|
||||||
|
raise ArtifactBuildError("artifact revision is invalid")
|
||||||
|
with tempfile.TemporaryDirectory(prefix="mission-core-m49-integrated-") as directory:
|
||||||
|
stage = Path(directory)
|
||||||
|
snapshot = source_root
|
||||||
|
if snapshot is None:
|
||||||
|
snapshot = stage / "source"
|
||||||
|
materialize_revision(selected_revision, snapshot)
|
||||||
|
sources = tuple(snapshot / relative for relative in SOURCES)
|
||||||
|
if any(path.is_symlink() or not path.is_file() for path in sources):
|
||||||
|
raise ArtifactBuildError("release input is not a regular file")
|
||||||
|
payload = stage / "payload"
|
||||||
|
payload.mkdir()
|
||||||
|
wheel = build_wheel(snapshot, stage / "wheel")
|
||||||
|
copied: list[Path] = []
|
||||||
|
for source in sources:
|
||||||
|
destination = payload / source.name
|
||||||
|
if destination.exists():
|
||||||
|
raise ArtifactBuildError("release payload file names are not unique")
|
||||||
|
destination.write_bytes(source.read_bytes())
|
||||||
|
copied.append(destination)
|
||||||
|
wheel_destination = payload / WHEEL_NAME
|
||||||
|
wheel_destination.write_bytes(wheel.read_bytes())
|
||||||
|
copied.append(wheel_destination)
|
||||||
|
release = {
|
||||||
|
"schema_version": "missioncore.m49-tgs-integrated-graph-worker-release/v1",
|
||||||
|
"patch_id": patch_id,
|
||||||
|
"transition": "m49-tgs-native-risk-integrated-shadow/v1",
|
||||||
|
"code_revision": selected_revision,
|
||||||
|
"worker_id": "worker-006",
|
||||||
|
"source_pack_sha256": (
|
||||||
|
"0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944"
|
||||||
|
),
|
||||||
|
"expected_frames": 4489,
|
||||||
|
"requested_source_rate_hz": 12.0,
|
||||||
|
"native_engine_sha256": (
|
||||||
|
"b8a40b3580edff001ec9680de68707242294ff590ab296000fae371f1083f695"
|
||||||
|
),
|
||||||
|
"images": {
|
||||||
|
"travel": "sha256:7b412020f4d8392d1d1ed1b33beadc44140f0ea8f781e62dd69796042334300f",
|
||||||
|
"parity": "sha256:ceb13548617e4bd3f619766bfdff00af3fa5160946b367828da6d2233dcdcba0",
|
||||||
|
"runtime": (
|
||||||
|
"sha256:58df7489c3f2276f9591d500a012dee03e23d35543ce3c390b4c001e6bf90794"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"authority": {
|
||||||
|
"visual_quality_accepted": False,
|
||||||
|
"traversability_accepted": False,
|
||||||
|
"physical_free_space_accepted": False,
|
||||||
|
"commands_enabled": False,
|
||||||
|
"actuation_allowed": False,
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
"production_accepted": False,
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"gauss_or_playcanvas_action": "none",
|
||||||
|
"durable_worker_action": "none",
|
||||||
|
"canonical_triton_action": "none",
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
path.name: {"sha256": sha256_file(path), "bytes": path.stat().st_size}
|
||||||
|
for path in sorted(copied)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
release_path = payload / "release.json"
|
||||||
|
release_path.write_text(
|
||||||
|
json.dumps(release, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
payload_files = sorted((*release["files"], release_path.name))
|
||||||
|
(stage / "manifest.env").write_text(
|
||||||
|
f"id={patch_id}\ncomponent=mission-core-worker\ntype=shadow-release\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(stage / "files.txt").write_text("\n".join(payload_files) + "\n", encoding="utf-8")
|
||||||
|
target = output_directory.resolve() / f"nodedc-{patch_id}.tgz"
|
||||||
|
write_archive(stage, target)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"patch_id": patch_id,
|
||||||
|
"artifact": str(target),
|
||||||
|
"sha256": sha256_file(target),
|
||||||
|
"code_revision": selected_revision,
|
||||||
|
"wheel_sha256": release["files"][WHEEL_NAME]["sha256"],
|
||||||
|
"payload_files": payload_files,
|
||||||
|
"transition": release["transition"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("patch_id")
|
||||||
|
parser.add_argument(
|
||||||
|
"--output-directory",
|
||||||
|
type=Path,
|
||||||
|
default=REPOSITORY_ROOT / ".runtime/worker-artifacts",
|
||||||
|
)
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
try:
|
||||||
|
result = build_artifact(arguments.patch_id, arguments.output_directory)
|
||||||
|
except (ArtifactBuildError, OSError, subprocess.SubprocessError) as exc:
|
||||||
|
parser.error(str(exc))
|
||||||
|
print(json.dumps(result, indent=2, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -344,6 +344,27 @@ class RecordedGeometryStore:
|
|||||||
points.setflags(write=False)
|
points.setflags(write=False)
|
||||||
return points
|
return points
|
||||||
|
|
||||||
|
def playback_points_map(self) -> FloatArray:
|
||||||
|
"""Expose the sealed contiguous map-point track for binary LAB playback.
|
||||||
|
|
||||||
|
The returned array is the exact source-pack point index space. It is
|
||||||
|
read-only and deliberately excludes any UI projection or resampling so
|
||||||
|
the browser can retain it once and derive the current increment by the
|
||||||
|
verified offsets below.
|
||||||
|
"""
|
||||||
|
|
||||||
|
points = np.asarray(self._source["cloud_points_map"], dtype=np.dtype("<f4"))
|
||||||
|
if not points.flags.c_contiguous:
|
||||||
|
raise GeometryProviderError("source playback point track is not contiguous")
|
||||||
|
points.setflags(write=False)
|
||||||
|
return points
|
||||||
|
|
||||||
|
def playback_point_offsets(self) -> tuple[int, ...]:
|
||||||
|
"""Return immutable offsets into :meth:`playback_points_map`."""
|
||||||
|
|
||||||
|
offsets = np.asarray(self._source["cloud_offsets"], dtype=np.int64)
|
||||||
|
return tuple(int(value) for value in offsets)
|
||||||
|
|
||||||
def point_step_candidates_for_frame(self, frame_index: int) -> UInt8Array | None:
|
def point_step_candidates_for_frame(self, frame_index: int) -> UInt8Array | None:
|
||||||
"""Expose the sealed low-step diagnostic in the source point index space.
|
"""Expose the sealed low-step diagnostic in the source point index space.
|
||||||
|
|
||||||
|
|||||||
@@ -150,14 +150,23 @@ class RecordedThreatTimeline:
|
|||||||
"access": "read-only-bounded-recorded-replay",
|
"access": "read-only-bounded-recorded-replay",
|
||||||
}
|
}
|
||||||
|
|
||||||
def chunk(self, *, start_sequence: int, frame_count: int) -> dict[str, object]:
|
def chunk(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
start_sequence: int,
|
||||||
|
frame_count: int,
|
||||||
|
include_points: bool = True,
|
||||||
|
) -> dict[str, object]:
|
||||||
if not 0 <= start_sequence < len(self.index.offsets):
|
if not 0 <= start_sequence < len(self.index.offsets):
|
||||||
raise RecordedThreatTimelineError("recorded timeline chunk start is invalid")
|
raise RecordedThreatTimelineError("recorded timeline chunk start is invalid")
|
||||||
if not 1 <= frame_count <= RECORDED_SPATIAL_MAX_CHUNK_FRAMES:
|
if not 1 <= frame_count <= RECORDED_SPATIAL_MAX_CHUNK_FRAMES:
|
||||||
raise RecordedThreatTimelineError("recorded timeline chunk size is invalid")
|
raise RecordedThreatTimelineError("recorded timeline chunk size is invalid")
|
||||||
stop = min(len(self.index.offsets), start_sequence + frame_count)
|
stop = min(len(self.index.offsets), start_sequence + frame_count)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
frames = [self._project_frame(sequence) for sequence in range(start_sequence, stop)]
|
frames = [
|
||||||
|
self._project_frame(sequence, include_points=include_points)
|
||||||
|
for sequence in range(start_sequence, stop)
|
||||||
|
]
|
||||||
return {
|
return {
|
||||||
"schema_version": RECORDED_SPATIAL_CHUNK_SCHEMA,
|
"schema_version": RECORDED_SPATIAL_CHUNK_SCHEMA,
|
||||||
"result_id": self.result.result_id,
|
"result_id": self.result.result_id,
|
||||||
@@ -170,7 +179,12 @@ class RecordedThreatTimeline:
|
|||||||
"access": "read-only-bounded-recorded-replay",
|
"access": "read-only-bounded-recorded-replay",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _project_frame(self, sequence: int) -> dict[str, object]:
|
def _project_frame(
|
||||||
|
self,
|
||||||
|
sequence: int,
|
||||||
|
*,
|
||||||
|
include_points: bool,
|
||||||
|
) -> dict[str, object]:
|
||||||
row = _read_frame_at(self.frames_path, self.index, sequence)
|
row = _read_frame_at(self.frames_path, self.index, sequence)
|
||||||
frame_id = row.get("frame_id")
|
frame_id = row.get("frame_id")
|
||||||
if not isinstance(frame_id, str) or not frame_id:
|
if not isinstance(frame_id, str) or not frame_id:
|
||||||
@@ -193,11 +207,13 @@ class RecordedThreatTimeline:
|
|||||||
raise RecordedThreatTimelineError(
|
raise RecordedThreatTimelineError(
|
||||||
"recorded timeline current increment binding changed"
|
"recorded timeline current increment binding changed"
|
||||||
)
|
)
|
||||||
point_cloud, point_source_count = sample_points_in_body_frame(
|
point_source_count = int(points.shape[0])
|
||||||
points,
|
if include_points:
|
||||||
body_frame,
|
point_cloud, point_source_count = sample_points_in_body_frame(
|
||||||
point_limit=RECORDED_SPATIAL_POINT_LIMIT,
|
points,
|
||||||
)
|
body_frame,
|
||||||
|
point_limit=RECORDED_SPATIAL_POINT_LIMIT,
|
||||||
|
)
|
||||||
metric_visuals = project_metric_obstacles_to_body(
|
metric_visuals = project_metric_obstacles_to_body(
|
||||||
_mapping_array(row.get("metric_obstacles"), "metric obstacles"),
|
_mapping_array(row.get("metric_obstacles"), "metric obstacles"),
|
||||||
body_frame,
|
body_frame,
|
||||||
@@ -219,13 +235,13 @@ class RecordedThreatTimeline:
|
|||||||
if body_frame is None
|
if body_frame is None
|
||||||
else {
|
else {
|
||||||
"origin_map_xyz_m": list(body_frame.origin_map_xyz_m),
|
"origin_map_xyz_m": list(body_frame.origin_map_xyz_m),
|
||||||
"basis_map_from_body": [
|
"basis_map_from_body": [list(row) for row in body_frame.basis_map_from_body],
|
||||||
list(row) for row in body_frame.basis_map_from_body
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
"point_cloud_body_xyz_m": point_cloud,
|
"point_cloud_body_xyz_m": point_cloud,
|
||||||
"point_cloud_source_count": point_source_count,
|
"point_cloud_source_count": point_source_count,
|
||||||
"point_cloud_sample_count": len(point_cloud),
|
"point_cloud_sample_count": point_source_count
|
||||||
|
if not include_points
|
||||||
|
else len(point_cloud),
|
||||||
"point_cloud_layer": "current-increment",
|
"point_cloud_layer": "current-increment",
|
||||||
"rolling_map_component_count": sum(
|
"rolling_map_component_count": sum(
|
||||||
item.get("state") == "retained" for item in metric_visuals
|
item.get("state") == "retained" for item in metric_visuals
|
||||||
|
|||||||
@@ -13,17 +13,44 @@ from typing import Final
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from fastapi import APIRouter, HTTPException, Query, Response
|
from fastapi import APIRouter, HTTPException, Query, Response
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
from k1link.laboratory.m49_tgs_full_shadow import (
|
from k1link.laboratory.m49_tgs_full_shadow import (
|
||||||
|
PREFIX,
|
||||||
M49TgsFullShadowError,
|
M49TgsFullShadowError,
|
||||||
M49TgsFullShadowResult,
|
M49TgsFullShadowResult,
|
||||||
PREFIX,
|
|
||||||
read_m49_tgs_full_shadow,
|
read_m49_tgs_full_shadow,
|
||||||
)
|
)
|
||||||
|
|
||||||
RootProvider = Callable[[], Path | None]
|
RootProvider = Callable[[], Path | None]
|
||||||
RESULT_ID: Final = re.compile(rf"^{re.escape(PREFIX)}[a-f0-9]{{64}}$")
|
RESULT_ID: Final = re.compile(rf"^{re.escape(PREFIX)}[a-f0-9]{{64}}$")
|
||||||
ENDPOINT_ROOT: Final = "/api/v1/laboratory/m49/tgs-full-shadow"
|
ENDPOINT_ROOT: Final = "/api/v1/laboratory/m49/tgs-full-shadow"
|
||||||
|
PLAYBACK_TRACKS: Final = {
|
||||||
|
"centers": (
|
||||||
|
"costmap-cell-centers-xy-m.npy",
|
||||||
|
"application/x-npy",
|
||||||
|
"<f4",
|
||||||
|
[2244, 2],
|
||||||
|
),
|
||||||
|
"states": (
|
||||||
|
"costmap-states.npy",
|
||||||
|
"application/x-npy",
|
||||||
|
"|u1",
|
||||||
|
[4489, 2244],
|
||||||
|
),
|
||||||
|
"z-bounds": (
|
||||||
|
"costmap-z-bounds-m.npy",
|
||||||
|
"application/x-npy",
|
||||||
|
"<f4",
|
||||||
|
[4489, 2244, 2],
|
||||||
|
),
|
||||||
|
"frames": (
|
||||||
|
"frames.ndjson",
|
||||||
|
"application/x-ndjson",
|
||||||
|
"ndjson",
|
||||||
|
[4489],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_m49_tgs_full_shadow_router(*, root_provider: RootProvider = lambda: None) -> APIRouter:
|
def build_m49_tgs_full_shadow_router(*, root_provider: RootProvider = lambda: None) -> APIRouter:
|
||||||
@@ -55,16 +82,46 @@ def build_m49_tgs_full_shadow_router(*, root_provider: RootProvider = lambda: No
|
|||||||
if not candidate.is_dir() or RESULT_ID.fullmatch(candidate.name) is None:
|
if not candidate.is_dir() or RESULT_ID.fullmatch(candidate.name) is None:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
results.append(_project(_read_cached(str(candidate.resolve()), _signature(candidate))))
|
results.append(
|
||||||
|
_project(_read_cached(str(candidate.resolve()), _signature(candidate)))
|
||||||
|
)
|
||||||
except (M49TgsFullShadowError, OSError, ValueError):
|
except (M49TgsFullShadowError, OSError, ValueError):
|
||||||
invalid += 1
|
invalid += 1
|
||||||
results.sort(key=lambda row: (str(row["created_at_utc"]), str(row["result_id"])), reverse=True)
|
results.sort(
|
||||||
|
key=lambda row: (str(row["created_at_utc"]), str(row["result_id"])), reverse=True
|
||||||
|
)
|
||||||
return _catalog(results[:limit], configured=True, invalid_total=invalid)
|
return _catalog(results[:limit], configured=True, invalid_total=invalid)
|
||||||
|
|
||||||
@router.get("/{result_id}")
|
@router.get("/{result_id}")
|
||||||
def get_result(result_id: str) -> dict[str, object]:
|
def get_result(result_id: str) -> dict[str, object]:
|
||||||
return _project(sealed(result_id))
|
return _project(sealed(result_id))
|
||||||
|
|
||||||
|
@router.get("/{result_id}/playback/manifest")
|
||||||
|
def get_playback_manifest(result_id: str) -> dict[str, object]:
|
||||||
|
return _playback_manifest(sealed(result_id))
|
||||||
|
|
||||||
|
@router.get("/{result_id}/playback/tracks/{track_id}")
|
||||||
|
def get_playback_track(result_id: str, track_id: str) -> FileResponse:
|
||||||
|
result = sealed(result_id)
|
||||||
|
descriptor = PLAYBACK_TRACKS.get(track_id)
|
||||||
|
if descriptor is None:
|
||||||
|
raise HTTPException(status_code=404, detail="M49 playback track not found")
|
||||||
|
name, media_type, _dtype, _shape = descriptor
|
||||||
|
artifact = _artifact_descriptor(result, name)
|
||||||
|
return FileResponse(
|
||||||
|
result.root / name,
|
||||||
|
media_type=media_type,
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "private, max-age=31536000, immutable",
|
||||||
|
# These playback artifacts are consumed on the local control
|
||||||
|
# station. Avoid spending multiple seconds recompressing
|
||||||
|
# already compact numeric tracks on every cold open.
|
||||||
|
"Content-Encoding": "identity",
|
||||||
|
"ETag": f'"{artifact["sha256"]}"',
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
@router.get("/{result_id}/frames/{source_sequence}/spatial")
|
@router.get("/{result_id}/frames/{source_sequence}/spatial")
|
||||||
def get_spatial(result_id: str, source_sequence: int) -> Response:
|
def get_spatial(result_id: str, source_sequence: int) -> Response:
|
||||||
if source_sequence < 0 or source_sequence >= 4489:
|
if source_sequence < 0 or source_sequence >= 4489:
|
||||||
@@ -75,11 +132,16 @@ def build_m49_tgs_full_shadow_router(*, root_provider: RootProvider = lambda: No
|
|||||||
str(result.root), result_id, source_sequence, _evidence_signature(result.root)
|
str(result.root), result_id, source_sequence, _evidence_signature(result.root)
|
||||||
)
|
)
|
||||||
except (OSError, ValueError, KeyError, json.JSONDecodeError):
|
except (OSError, ValueError, KeyError, json.JSONDecodeError):
|
||||||
raise HTTPException(status_code=503, detail="M49 TGS full-shadow spatial evidence failed verification") from None
|
raise HTTPException(
|
||||||
|
status_code=503, detail="M49 TGS full-shadow spatial evidence failed verification"
|
||||||
|
) from None
|
||||||
return Response(
|
return Response(
|
||||||
content=content,
|
content=content,
|
||||||
media_type="application/json",
|
media_type="application/json",
|
||||||
headers={"Cache-Control": "private, max-age=31536000, immutable", "X-Content-Type-Options": "nosniff"},
|
headers={
|
||||||
|
"Cache-Control": "private, max-age=31536000, immutable",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/{result_id}/spatial/chunk")
|
@router.get("/{result_id}/spatial/chunk")
|
||||||
@@ -134,7 +196,9 @@ def _frame_json_cached(
|
|||||||
root_path = Path(root)
|
root_path = Path(root)
|
||||||
frame_signature = (signature[-2], signature[-1])
|
frame_signature = (signature[-2], signature[-1])
|
||||||
frame = _frames(root, frame_signature)[source_sequence]
|
frame = _frames(root, frame_signature)[source_sequence]
|
||||||
centers = np.load(root_path / "costmap-cell-centers-xy-m.npy", mmap_mode="r", allow_pickle=False)
|
centers = np.load(
|
||||||
|
root_path / "costmap-cell-centers-xy-m.npy", mmap_mode="r", allow_pickle=False
|
||||||
|
)
|
||||||
states_all = np.load(root_path / "costmap-states.npy", mmap_mode="r", allow_pickle=False)
|
states_all = np.load(root_path / "costmap-states.npy", mmap_mode="r", allow_pickle=False)
|
||||||
z_all = np.load(root_path / "costmap-z-bounds-m.npy", mmap_mode="r", allow_pickle=False)
|
z_all = np.load(root_path / "costmap-z-bounds-m.npy", mmap_mode="r", allow_pickle=False)
|
||||||
states = states_all[source_sequence]
|
states = states_all[source_sequence]
|
||||||
@@ -169,13 +233,20 @@ def _frame_json_cached(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
"metrics": copy.deepcopy(frame),
|
"metrics": copy.deepcopy(frame),
|
||||||
"state_codes": {"UNOBSERVED": 0, "GROUND_SUPPORT": 1, "NONGROUND_OCCUPIED": 2, "UNKNOWN_REJECTED": 3},
|
"state_codes": {
|
||||||
|
"UNOBSERVED": 0,
|
||||||
|
"GROUND_SUPPORT": 1,
|
||||||
|
"NONGROUND_OCCUPIED": 2,
|
||||||
|
"UNKNOWN_REJECTED": 3,
|
||||||
|
},
|
||||||
"aos_used": False,
|
"aos_used": False,
|
||||||
"gpu_used": False,
|
"gpu_used": False,
|
||||||
"authority": {"navigation_or_safety_accepted": False, "visual_quality_accepted": False},
|
"authority": {"navigation_or_safety_accepted": False, "visual_quality_accepted": False},
|
||||||
"access": "read-only",
|
"access": "read-only",
|
||||||
}
|
}
|
||||||
return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
return json.dumps(
|
||||||
|
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=16)
|
@lru_cache(maxsize=16)
|
||||||
@@ -189,7 +260,9 @@ def _chunk_json_cached(
|
|||||||
root_path = Path(root)
|
root_path = Path(root)
|
||||||
frame_signature = (signature[-2], signature[-1])
|
frame_signature = (signature[-2], signature[-1])
|
||||||
frames = _frames(root, frame_signature)
|
frames = _frames(root, frame_signature)
|
||||||
centers = np.load(root_path / "costmap-cell-centers-xy-m.npy", mmap_mode="r", allow_pickle=False)
|
centers = np.load(
|
||||||
|
root_path / "costmap-cell-centers-xy-m.npy", mmap_mode="r", allow_pickle=False
|
||||||
|
)
|
||||||
states_all = np.load(root_path / "costmap-states.npy", mmap_mode="r", allow_pickle=False)
|
states_all = np.load(root_path / "costmap-states.npy", mmap_mode="r", allow_pickle=False)
|
||||||
z_all = np.load(root_path / "costmap-z-bounds-m.npy", mmap_mode="r", allow_pickle=False)
|
z_all = np.load(root_path / "costmap-z-bounds-m.npy", mmap_mode="r", allow_pickle=False)
|
||||||
if (
|
if (
|
||||||
@@ -258,6 +331,46 @@ def _chunk_json_cached(
|
|||||||
).encode("utf-8")
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _artifact_descriptor(
|
||||||
|
result: M49TgsFullShadowResult,
|
||||||
|
name: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
for artifact in result.manifest["artifacts"]:
|
||||||
|
if artifact.get("path") == name:
|
||||||
|
return artifact
|
||||||
|
raise ValueError(f"full-shadow artifact is not declared: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def _playback_manifest(result: M49TgsFullShadowResult) -> dict[str, object]:
|
||||||
|
tracks: list[dict[str, object]] = []
|
||||||
|
total_bytes = 0
|
||||||
|
for track_id, (name, media_type, dtype, shape) in PLAYBACK_TRACKS.items():
|
||||||
|
artifact = _artifact_descriptor(result, name)
|
||||||
|
byte_length = int(artifact["byte_length"])
|
||||||
|
total_bytes += byte_length
|
||||||
|
tracks.append(
|
||||||
|
{
|
||||||
|
"id": track_id,
|
||||||
|
"url": (f"{ENDPOINT_ROOT}/{result.result_id}/playback/tracks/{track_id}"),
|
||||||
|
"media_type": media_type,
|
||||||
|
"dtype": dtype,
|
||||||
|
"shape": shape,
|
||||||
|
"byte_length": byte_length,
|
||||||
|
"sha256": artifact["sha256"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"schema_version": "missioncore.m49-tgs-full-shadow-playback/v1",
|
||||||
|
"result_id": result.result_id,
|
||||||
|
"coordinate_frame": "map-gravity-local",
|
||||||
|
"frame_count": 4489,
|
||||||
|
"cell_count": 2244,
|
||||||
|
"total_byte_length": total_bytes,
|
||||||
|
"tracks": tracks,
|
||||||
|
"access": "read-only",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _project(result: M49TgsFullShadowResult) -> dict[str, object]:
|
def _project(result: M49TgsFullShadowResult) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
**copy.deepcopy(result.report),
|
**copy.deepcopy(result.report),
|
||||||
@@ -269,7 +382,9 @@ def _project(result: M49TgsFullShadowResult) -> dict[str, object]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _catalog(items: list[dict[str, object]], *, configured: bool, invalid_total: int) -> dict[str, object]:
|
def _catalog(
|
||||||
|
items: list[dict[str, object]], *, configured: bool, invalid_total: int
|
||||||
|
) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"schema_version": "missioncore.m49-tgs-full-shadow-catalog/v1",
|
"schema_version": "missioncore.m49-tgs-full-shadow-catalog/v1",
|
||||||
"configured": configured,
|
"configured": configured,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from collections.abc import Callable, Iterator
|
from collections.abc import Callable, Iterator
|
||||||
@@ -11,6 +12,7 @@ from pathlib import Path
|
|||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Response
|
from fastapi import APIRouter, HTTPException, Query, Response
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from k1link.perception.threat_replay import (
|
from k1link.perception.threat_replay import (
|
||||||
THREAT_REPLAY_FRAME_SCHEMA,
|
THREAT_REPLAY_FRAME_SCHEMA,
|
||||||
@@ -166,7 +168,7 @@ def build_m4_threat_replay_router(
|
|||||||
def get_timeline(result_id: str) -> dict[str, object]:
|
def get_timeline(result_id: str) -> dict[str, object]:
|
||||||
return copy.deepcopy(timeline(result_id).metadata())
|
return copy.deepcopy(timeline(result_id).metadata())
|
||||||
|
|
||||||
@router.get("/results/{result_id}/timeline/chunk")
|
@router.get("/results/{result_id}/timeline/chunk", response_model=None)
|
||||||
def get_timeline_chunk(
|
def get_timeline_chunk(
|
||||||
result_id: str,
|
result_id: str,
|
||||||
start: int = Query(default=0, ge=0),
|
start: int = Query(default=0, ge=0),
|
||||||
@@ -175,14 +177,87 @@ def build_m4_threat_replay_router(
|
|||||||
ge=1,
|
ge=1,
|
||||||
le=RECORDED_SPATIAL_MAX_CHUNK_FRAMES,
|
le=RECORDED_SPATIAL_MAX_CHUNK_FRAMES,
|
||||||
),
|
),
|
||||||
) -> dict[str, object]:
|
include_points: bool = Query(default=True),
|
||||||
|
) -> dict[str, object] | Response:
|
||||||
try:
|
try:
|
||||||
return timeline(result_id).chunk(start_sequence=start, frame_count=count)
|
payload = timeline(result_id).chunk(
|
||||||
|
start_sequence=start,
|
||||||
|
frame_count=count,
|
||||||
|
include_points=include_points,
|
||||||
|
)
|
||||||
except RecordedThreatTimelineError:
|
except RecordedThreatTimelineError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
detail="M4.6 timeline chunk не найден",
|
detail="M4.6 timeline chunk не найден",
|
||||||
) from None
|
) from None
|
||||||
|
if include_points:
|
||||||
|
return payload
|
||||||
|
return Response(
|
||||||
|
content=json.dumps(
|
||||||
|
payload,
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
),
|
||||||
|
media_type="application/json",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "private, max-age=31536000, immutable",
|
||||||
|
"Content-Encoding": "identity",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/results/{result_id}/timeline/playback")
|
||||||
|
def get_timeline_playback(result_id: str) -> dict[str, object]:
|
||||||
|
projected = timeline(result_id)
|
||||||
|
points = projected.store.playback_points_map()
|
||||||
|
offsets = projected.store.playback_point_offsets()
|
||||||
|
content_sha256 = hashlib.sha256(memoryview(points).cast("B")).hexdigest()
|
||||||
|
return {
|
||||||
|
"schema_version": "missioncore.recorded-spatial-playback/v1",
|
||||||
|
"result_id": result_id,
|
||||||
|
"frame_count": len(offsets) - 1,
|
||||||
|
"point_count": int(points.shape[0]),
|
||||||
|
"point_offsets": list(offsets),
|
||||||
|
"track": {
|
||||||
|
"id": "points-map-f32",
|
||||||
|
"url": (
|
||||||
|
f"/api/v1/laboratory/m4-threat/results/{result_id}"
|
||||||
|
"/timeline/playback/tracks/points-map-f32"
|
||||||
|
),
|
||||||
|
"media_type": "application/octet-stream",
|
||||||
|
"dtype": "<f4",
|
||||||
|
"shape": [int(points.shape[0]), 3],
|
||||||
|
"bytes": int(points.nbytes),
|
||||||
|
"sha256": content_sha256,
|
||||||
|
},
|
||||||
|
"source_pack_sha256": projected.profile.source_pack_sha256,
|
||||||
|
"coordinate_frame": "map",
|
||||||
|
"ground_truth": False,
|
||||||
|
"authority": "replay-simulated",
|
||||||
|
"access": "read-only-sealed-binary-playback",
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/results/{result_id}/timeline/playback/tracks/points-map-f32",
|
||||||
|
response_class=StreamingResponse,
|
||||||
|
)
|
||||||
|
def get_timeline_playback_points(result_id: str) -> StreamingResponse:
|
||||||
|
projected = timeline(result_id)
|
||||||
|
points = projected.store.playback_points_map()
|
||||||
|
source_digest = projected.profile.source_pack_sha256
|
||||||
|
return StreamingResponse(
|
||||||
|
_binary_chunks(memoryview(points).cast("B")),
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "private, max-age=31536000, immutable",
|
||||||
|
"Content-Encoding": "identity",
|
||||||
|
"Content-Length": str(points.nbytes),
|
||||||
|
"ETag": f'"{source_digest}-points-map-f32"',
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"X-Uncompressed-Content-Length": str(points.nbytes),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
@router.get("/results/{result_id}/timeline/frames/{sequence}/camera")
|
@router.get("/results/{result_id}/timeline/frames/{sequence}/camera")
|
||||||
def get_timeline_camera(result_id: str, sequence: int) -> Response:
|
def get_timeline_camera(result_id: str, sequence: int) -> Response:
|
||||||
@@ -196,6 +271,11 @@ def build_m4_threat_replay_router(
|
|||||||
return router
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def _binary_chunks(view: memoryview, chunk_size: int = 1024 * 1024) -> Iterator[bytes]:
|
||||||
|
for start in range(0, view.nbytes, chunk_size):
|
||||||
|
yield bytes(view[start : start + chunk_size])
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=4)
|
@lru_cache(maxsize=4)
|
||||||
def _read_threat_result_cached(
|
def _read_threat_result_cached(
|
||||||
root_value: str,
|
root_value: str,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from threading import Thread
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from k1link.perception.detector import DetectorFrameTiming
|
from k1link.perception.detector import DetectorFrameTiming
|
||||||
@@ -157,3 +158,33 @@ def test_cyclic_gc_policy_collects_outside_hot_loop_and_restores_state() -> None
|
|||||||
"pre_collected": 3,
|
"pre_collected": 3,
|
||||||
"post_collected": 0,
|
"post_collected": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_start_barrier_publishes_readiness_and_waits_for_release(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
ready = tmp_path / "graph.ready"
|
||||||
|
start = tmp_path / "start.signal"
|
||||||
|
completed: list[bool] = []
|
||||||
|
|
||||||
|
thread = Thread(
|
||||||
|
target=lambda: (
|
||||||
|
RUNNER.wait_for_shared_start(
|
||||||
|
ready_file=ready,
|
||||||
|
start_file=start,
|
||||||
|
timeout_seconds=1.0,
|
||||||
|
),
|
||||||
|
completed.append(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
deadline = time.monotonic() + 1.0
|
||||||
|
while not ready.exists() and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.005)
|
||||||
|
assert ready.read_text(encoding="utf-8") == "ready\n"
|
||||||
|
assert completed == []
|
||||||
|
|
||||||
|
start.write_text("start\n", encoding="utf-8")
|
||||||
|
thread.join(timeout=1.0)
|
||||||
|
|
||||||
|
assert completed == [True]
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ from pathlib import Path
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from k1link.laboratory.m49_tgs_full_shadow import seal_m49_tgs_full_shadow
|
from k1link.laboratory.m49_tgs_full_shadow import (
|
||||||
|
M49TgsFullShadowResult,
|
||||||
|
seal_m49_tgs_full_shadow,
|
||||||
|
)
|
||||||
from k1link.web import m49_tgs_full_shadow_api as full_shadow_api
|
from k1link.web import m49_tgs_full_shadow_api as full_shadow_api
|
||||||
|
|
||||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||||
@@ -96,24 +99,34 @@ def test_full_shadow_seal_binds_visual_and_semantic_timelines(tmp_path: Path) ->
|
|||||||
"files": files,
|
"files": files,
|
||||||
}
|
}
|
||||||
(source / "result.json").write_text(json.dumps(worker), encoding="utf-8")
|
(source / "result.json").write_text(json.dumps(worker), encoding="utf-8")
|
||||||
(source / "worker-summary.json").write_text(json.dumps({
|
(source / "worker-summary.json").write_text(
|
||||||
"schema_version": "missioncore.m49-tgs-full-shadow-worker-summary/v1",
|
json.dumps(
|
||||||
"gpu_requested": False,
|
{
|
||||||
"aos_used": False,
|
"schema_version": "missioncore.m49-tgs-full-shadow-worker-summary/v1",
|
||||||
"all_timeline_frames_accounted": True,
|
"gpu_requested": False,
|
||||||
"all_eligible_points_accounted": True,
|
"aos_used": False,
|
||||||
"canonical_triton_health": "healthy",
|
"all_timeline_frames_accounted": True,
|
||||||
"canonical_triton_id": "triton",
|
"all_eligible_points_accounted": True,
|
||||||
"wall_seconds": 1.0,
|
"canonical_triton_health": "healthy",
|
||||||
}), encoding="utf-8")
|
"canonical_triton_id": "triton",
|
||||||
|
"wall_seconds": 1.0,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
profile = tmp_path / "profile.json"
|
profile = tmp_path / "profile.json"
|
||||||
profile.write_text(json.dumps({
|
profile.write_text(
|
||||||
"schema_version": "missioncore.m49-tgs-full-shadow-profile/v1",
|
json.dumps(
|
||||||
"profile_id": "test",
|
{
|
||||||
"profile": {"history_seconds": 1.0},
|
"schema_version": "missioncore.m49-tgs-full-shadow-profile/v1",
|
||||||
"costmap": {"state_priority": ["NONGROUND_OCCUPIED"]},
|
"profile_id": "test",
|
||||||
"state_codes": {"UNOBSERVED": 0},
|
"profile": {"history_seconds": 1.0},
|
||||||
}), encoding="utf-8")
|
"costmap": {"state_priority": ["NONGROUND_OCCUPIED"]},
|
||||||
|
"state_codes": {"UNOBSERVED": 0},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
visual = "m4-threat-replay-" + "d" * 64
|
visual = "m4-threat-replay-" + "d" * 64
|
||||||
semantic = "e47-semantic-slam-" + "e" * 64
|
semantic = "e47-semantic-slam-" + "e" * 64
|
||||||
|
|
||||||
@@ -176,3 +189,35 @@ def test_full_shadow_chunk_contract_keeps_missing_lidar_unobserved(
|
|||||||
assert len(payload["costmap"]["centers_xy_m"]) == 2244
|
assert len(payload["costmap"]["centers_xy_m"]) == 2244
|
||||||
assert payload["frames"][1]["sample_available"] is False
|
assert payload["frames"][1]["sample_available"] is False
|
||||||
assert set(payload["frames"][1]["states"]) == {0}
|
assert set(payload["frames"][1]["states"]) == {0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_shadow_playback_manifest_exposes_sealed_binary_tracks(tmp_path: Path) -> None:
|
||||||
|
names = {descriptor[0] for descriptor in full_shadow_api.PLAYBACK_TRACKS.values()}
|
||||||
|
artifacts = [
|
||||||
|
{
|
||||||
|
"path": name,
|
||||||
|
"byte_length": index + 10,
|
||||||
|
"sha256": f"{index + 1:064x}",
|
||||||
|
}
|
||||||
|
for index, name in enumerate(sorted(names))
|
||||||
|
]
|
||||||
|
result = M49TgsFullShadowResult(
|
||||||
|
"m49-tgs-full-shadow-" + "a" * 64,
|
||||||
|
tmp_path,
|
||||||
|
{"artifacts": artifacts},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = full_shadow_api._playback_manifest(result)
|
||||||
|
|
||||||
|
assert payload["schema_version"] == "missioncore.m49-tgs-full-shadow-playback/v1"
|
||||||
|
assert payload["frame_count"] == 4489
|
||||||
|
assert payload["cell_count"] == 2244
|
||||||
|
assert payload["total_byte_length"] == sum(item["byte_length"] for item in artifacts)
|
||||||
|
assert {item["id"] for item in payload["tracks"]} == {
|
||||||
|
"frames",
|
||||||
|
"centers",
|
||||||
|
"states",
|
||||||
|
"z-bounds",
|
||||||
|
}
|
||||||
|
assert all("/playback/tracks/" in item["url"] for item in payload["tracks"])
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import tarfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
EVIDENCE_PATH = (
|
||||||
|
REPOSITORY_ROOT
|
||||||
|
/ "experiments/perception/worker/m49_t3_travel/build_tgs_integrated_graph_evidence.py"
|
||||||
|
)
|
||||||
|
ARTIFACT_PATH = REPOSITORY_ROOT / "scripts/build_m49_tgs_integrated_graph_worker_artifact.py"
|
||||||
|
|
||||||
|
|
||||||
|
def load_module(name: str, path: Path):
|
||||||
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
EVIDENCE = load_module("m49_tgs_integrated_evidence", EVIDENCE_PATH)
|
||||||
|
ARTIFACT = load_module("m49_tgs_integrated_artifact", ARTIFACT_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def test_integrated_gate_joins_all_frames_and_preserves_false_authority(tmp_path: Path) -> None:
|
||||||
|
profile_path = tmp_path / "profile.json"
|
||||||
|
profile_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema_version": EVIDENCE.PROFILE_SCHEMA,
|
||||||
|
"profile_id": "test",
|
||||||
|
"source": {
|
||||||
|
"source_id": "RAVNOVES00",
|
||||||
|
"source_pack_sha256": "a" * 64,
|
||||||
|
"requested_source_rate_hz": 12.0,
|
||||||
|
},
|
||||||
|
"stages": {
|
||||||
|
"reference_graph": {
|
||||||
|
"graph_config_sha256": "b" * 64,
|
||||||
|
"detector_profile_sha256": "z" * 64,
|
||||||
|
},
|
||||||
|
"tgs": {
|
||||||
|
"profile_sha256": "c" * 64,
|
||||||
|
"linked_accepted_result_id": "m49-tgs-full-shadow-" + "d" * 64,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"acceptance": {
|
||||||
|
"minimum_delivery_ratio": 1.0,
|
||||||
|
"reference_world_state_fps": 11.79902,
|
||||||
|
"maximum_world_state_fps_regression_fraction": 0.05,
|
||||||
|
"minimum_effective_world_state_fps": 11.209069,
|
||||||
|
"maximum_world_state_completion_p95_ms": 125.0,
|
||||||
|
"candidate_stage_p95_ms_max": 25.0,
|
||||||
|
"candidate_stage_p99_ms_max": 50.0,
|
||||||
|
"combined_output_age_p99_ms_max": 125.0,
|
||||||
|
"capacity_drop_count_max": 0,
|
||||||
|
},
|
||||||
|
"authority": {
|
||||||
|
"visual_quality_accepted": False,
|
||||||
|
"traversability_accepted": False,
|
||||||
|
"physical_free_space_accepted": False,
|
||||||
|
"commands_enabled": False,
|
||||||
|
"actuation_allowed": False,
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
"production_accepted": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
graph_result = tmp_path / "graph-result.json"
|
||||||
|
graph_result.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema_version": EVIDENCE.GRAPH_SCHEMA,
|
||||||
|
"execution": {
|
||||||
|
"admitted_frames": EVIDENCE.FRAME_COUNT,
|
||||||
|
"delivered_world_states": EVIDENCE.FRAME_COUNT,
|
||||||
|
"effective_world_state_fps": 11.75,
|
||||||
|
"delivery_ratio": 1.0,
|
||||||
|
"terminal_outcomes": {"delivered": EVIDENCE.FRAME_COUNT},
|
||||||
|
"requested_source_rate_hz": 12.0,
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"inputs": {
|
||||||
|
"graph_config": "b" * 64,
|
||||||
|
"detector_profile": "z" * 64,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"world_state_completion_age_ms": {"p95": 40.0},
|
||||||
|
"gpu": {"sample_count": 2},
|
||||||
|
},
|
||||||
|
"evidence_integrity_gate_passed": True,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
graph_frames = tmp_path / "graph-frames.jsonl"
|
||||||
|
graph_frames.write_text(
|
||||||
|
"".join(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"source_envelope": {"sequence": index},
|
||||||
|
"completion_age_ns": 40_000_000,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
for index in range(EVIDENCE.FRAME_COUNT)
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
tgs_result = tmp_path / "tgs-result.json"
|
||||||
|
tgs_result.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema_version": EVIDENCE.TGS_SCHEMA,
|
||||||
|
"status": "passed",
|
||||||
|
"config_sha256": "c" * 64,
|
||||||
|
"timeline": {
|
||||||
|
"frame_count": EVIDENCE.FRAME_COUNT,
|
||||||
|
"available_lidar_frame_count": 3928,
|
||||||
|
},
|
||||||
|
"performance": {
|
||||||
|
"candidate_tgs_ms": {"p95": 2.0, "p99": 3.0},
|
||||||
|
"completion_age_ms": {"p99": 5.0},
|
||||||
|
"capacity_drop_count": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
tgs_timing = tmp_path / "tgs-timing.tsv"
|
||||||
|
tgs_timing.write_text(
|
||||||
|
"timeline_frame_index\tcompletion_age_ms\n"
|
||||||
|
+ "".join(f"{index}\t5.0\n" for index in range(EVIDENCE.FRAME_COUNT)),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
telemetry = tmp_path / "telemetry.jsonl"
|
||||||
|
telemetry.write_text(
|
||||||
|
"".join(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"role": role,
|
||||||
|
"cpu_percent": "10.0%",
|
||||||
|
"memory_usage": "1GiB / 64GiB",
|
||||||
|
"memory_percent": "1.56%",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
for role in ("graph", "tgs", "triton")
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
output = tmp_path / "result.json"
|
||||||
|
|
||||||
|
result = EVIDENCE.build(
|
||||||
|
profile_path=profile_path,
|
||||||
|
graph_result_path=graph_result,
|
||||||
|
graph_frames_path=graph_frames,
|
||||||
|
tgs_result_path=tgs_result,
|
||||||
|
tgs_timing_path=tgs_timing,
|
||||||
|
telemetry_path=telemetry,
|
||||||
|
output_path=output,
|
||||||
|
release_sha256="e" * 64,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "passed"
|
||||||
|
assert result["source"]["joined_frame_count"] == EVIDENCE.FRAME_COUNT
|
||||||
|
assert result["performance"]["combined_output_age_ms"]["p99"] == 40.0
|
||||||
|
assert result["checks"]["authority_remains_false"] is True
|
||||||
|
assert result["production_accepted"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_artifact_is_deterministic_and_excludes_gauss(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
def fake_wheel(_source_root: Path, output: Path) -> Path:
|
||||||
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
wheel = output / ARTIFACT.WHEEL_NAME
|
||||||
|
wheel.write_bytes(b"clean committed wheel\n")
|
||||||
|
return wheel
|
||||||
|
|
||||||
|
monkeypatch.setattr(ARTIFACT, "build_wheel", fake_wheel)
|
||||||
|
revision = "f" * 40
|
||||||
|
first = ARTIFACT.build_artifact(
|
||||||
|
"mission-core-m49-integrated-unit-001",
|
||||||
|
tmp_path / "first",
|
||||||
|
revision=revision,
|
||||||
|
source_root=REPOSITORY_ROOT,
|
||||||
|
)
|
||||||
|
second = ARTIFACT.build_artifact(
|
||||||
|
"mission-core-m49-integrated-unit-001",
|
||||||
|
tmp_path / "second",
|
||||||
|
revision=revision,
|
||||||
|
source_root=REPOSITORY_ROOT,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert Path(first["artifact"]).read_bytes() == Path(second["artifact"]).read_bytes()
|
||||||
|
with tarfile.open(first["artifact"], "r:gz") as archive:
|
||||||
|
names = set(archive.getnames())
|
||||||
|
release_stream = archive.extractfile("payload/release.json")
|
||||||
|
assert release_stream is not None
|
||||||
|
release = json.loads(release_stream.read())
|
||||||
|
assert not any("gauss" in name.lower() or "playcanvas" in name.lower() for name in names)
|
||||||
|
assert "payload/build_tgs_fail_closed_evidence.py" in names
|
||||||
|
assert release["scope"]["gauss_or_playcanvas_action"] == "none"
|
||||||
|
assert all(value is False for value in release["authority"].values())
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi.routing import APIRoute
|
from fastapi.routing import APIRoute
|
||||||
@@ -126,6 +127,7 @@ def test_m4_6_lab_api_projects_report_and_exact_visual_frame() -> None:
|
|||||||
def test_m4_6_timeline_is_indexed_and_spatial_evidence_is_chunked() -> None:
|
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")
|
||||||
|
|
||||||
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"
|
||||||
@@ -160,6 +162,23 @@ def test_m4_6_timeline_is_indexed_and_spatial_evidence_is_chunked() -> None:
|
|||||||
assert 0 < first["point_cloud_sample_count"] <= 4096
|
assert 0 < first["point_cloud_sample_count"] <= 4096
|
||||||
assert first["camera_url"].endswith(f"/{RESULT_ID}/timeline/frames/1880/camera")
|
assert first["camera_url"].endswith(f"/{RESULT_ID}/timeline/frames/1880/camera")
|
||||||
|
|
||||||
|
lightweight_response = get_chunk(RESULT_ID, start=1880, count=1, include_points=False)
|
||||||
|
lightweight = json.loads(lightweight_response.body)
|
||||||
|
assert lightweight["frames"][0]["point_cloud_body_xyz_m"] == []
|
||||||
|
assert lightweight["frames"][0]["point_cloud_source_count"] == first["point_cloud_source_count"]
|
||||||
|
assert lightweight["frames"][0]["point_cloud_sample_count"] == first["point_cloud_sample_count"]
|
||||||
|
|
||||||
|
playback = get_playback(RESULT_ID)
|
||||||
|
assert playback["schema_version"] == "missioncore.recorded-spatial-playback/v1"
|
||||||
|
assert playback["frame_count"] == 4489
|
||||||
|
assert playback["point_count"] == 9_207_270
|
||||||
|
assert len(playback["point_offsets"]) == 4490
|
||||||
|
assert playback["point_offsets"][-1] == playback["point_count"]
|
||||||
|
assert playback["track"]["dtype"] == "<f4"
|
||||||
|
assert playback["track"]["shape"] == [9_207_270, 3]
|
||||||
|
assert playback["track"]["bytes"] == 110_487_240
|
||||||
|
assert len(playback["track"]["sha256"]) == 64
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
calls: list[tuple[str, int]] = []
|
calls: list[tuple[str, int]] = []
|
||||||
|
|||||||
Reference in New Issue
Block a user