Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
424374263e | ||
|
|
906a6ce37b | ||
|
|
1496184167 | ||
|
|
9561a068ab | ||
|
|
9e686b3311 | ||
|
|
936ee479ed | ||
|
|
b82a97fe8b | ||
|
|
90bdc27785 |
@@ -26,6 +26,7 @@ export interface LaboratoryMetricObstacleVisual {
|
||||
state: "current" | "retained" | "held" | "expired";
|
||||
centroidBodyXyzM: LaboratoryMetricPoint3;
|
||||
cellCentersBodyXyzM: readonly LaboratoryMetricPoint3[];
|
||||
occupancySource?: "baseline" | "mixed" | "additive-low-step";
|
||||
}
|
||||
|
||||
export interface LaboratoryMetricRigVisual {
|
||||
@@ -41,7 +42,7 @@ export interface LaboratoryMetricCorridorVisual {
|
||||
}
|
||||
|
||||
export interface LaboratoryMetricLegendEntry {
|
||||
id: LaboratoryMetricDecision | "context" | "local-surface" | "rolling";
|
||||
id: LaboratoryMetricDecision | "context" | "local-surface" | "rolling" | "low-step";
|
||||
label: string;
|
||||
}
|
||||
|
||||
@@ -52,6 +53,7 @@ export function laboratoryMetricLegendEntries({
|
||||
showCurrentIncrement,
|
||||
showLocalSurface,
|
||||
showRollingMap,
|
||||
showLowStep = true,
|
||||
}: {
|
||||
pointCloudCount: number;
|
||||
localSurfaceCount: number;
|
||||
@@ -59,19 +61,29 @@ export function laboratoryMetricLegendEntries({
|
||||
showCurrentIncrement: boolean;
|
||||
showLocalSurface: boolean;
|
||||
showRollingMap: boolean;
|
||||
showLowStep?: boolean;
|
||||
}): readonly LaboratoryMetricLegendEntry[] {
|
||||
const visibleObstacles = obstacles.filter((obstacle) => (
|
||||
obstacle.state === "current"
|
||||
(showLowStep || obstacle.occupancySource === undefined || obstacle.occupancySource === "baseline")
|
||||
&& (obstacle.state === "current"
|
||||
? showCurrentIncrement
|
||||
: obstacle.state === "retained"
|
||||
? showRollingMap
|
||||
: false
|
||||
: false)
|
||||
));
|
||||
const decisions = new Set(visibleObstacles.map(({ decision }) => decision));
|
||||
const entries: LaboratoryMetricLegendEntry[] = [];
|
||||
if (decisions.has("threat")) entries.push({ id: "threat", label: "Угроза" });
|
||||
if (decisions.has("not-threat")) entries.push({ id: "not-threat", label: "Вне коридора" });
|
||||
if (decisions.has("unknown")) entries.push({ id: "unknown", label: "Неизвестно" });
|
||||
if (
|
||||
showLowStep
|
||||
&& visibleObstacles.some((obstacle) => (
|
||||
obstacle.occupancySource !== undefined && obstacle.occupancySource !== "baseline"
|
||||
))
|
||||
) {
|
||||
entries.push({ id: "low-step", label: "LOW-STEP · добавлено системой" });
|
||||
}
|
||||
if (showCurrentIncrement && pointCloudCount > 0) {
|
||||
entries.push({ id: "context", label: "Текущий кадр" });
|
||||
}
|
||||
@@ -153,6 +165,16 @@ function decisionColor(
|
||||
return tokenColor(host, "--nodedc-warning-rgb", [255, 197, 92]);
|
||||
}
|
||||
|
||||
function obstacleColor(
|
||||
host: HTMLElement,
|
||||
obstacle: LaboratoryMetricObstacleVisual,
|
||||
): THREE.Color {
|
||||
if (obstacle.occupancySource !== "baseline") {
|
||||
return tokenColor(host, "--nodedc-accent-rgb", [232, 56, 126]);
|
||||
}
|
||||
return decisionColor(host, obstacle.decision);
|
||||
}
|
||||
|
||||
export interface LaboratoryMetricEvidenceSceneHandle {
|
||||
resetView: () => void;
|
||||
}
|
||||
@@ -171,6 +193,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
showCurrentIncrement: boolean;
|
||||
showLocalSurface: boolean;
|
||||
showRollingMap: boolean;
|
||||
showLowStep?: boolean;
|
||||
pointSemanticClassIds?: readonly (number | null)[];
|
||||
semanticClasses?: readonly RecordedEvidenceSemanticClass[];
|
||||
semanticPalette?: readonly RecordedEvidenceSemanticPaletteEntry[];
|
||||
@@ -187,6 +210,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
showCurrentIncrement,
|
||||
showLocalSurface,
|
||||
showRollingMap,
|
||||
showLowStep = true,
|
||||
pointSemanticClassIds,
|
||||
semanticClasses,
|
||||
semanticPalette,
|
||||
@@ -352,6 +376,12 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
|
||||
for (const obstacle of obstacles) {
|
||||
if (
|
||||
(
|
||||
!showLowStep
|
||||
&& obstacle.occupancySource !== undefined
|
||||
&& obstacle.occupancySource !== "baseline"
|
||||
)
|
||||
||
|
||||
(obstacle.state === "current" && !showCurrentIncrement)
|
||||
|| (obstacle.state === "retained" && !showRollingMap)
|
||||
|| obstacle.state === "held"
|
||||
@@ -359,7 +389,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const color = decisionColor(host, obstacle.decision);
|
||||
const color = obstacleColor(host, obstacle);
|
||||
if (obstacle.state === "retained") {
|
||||
const geometry = new THREE.BoxGeometry(
|
||||
occupiedVoxelSizeM * 0.82,
|
||||
@@ -424,6 +454,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
showCurrentIncrement,
|
||||
showLocalSurface,
|
||||
showRollingMap,
|
||||
showLowStep,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -538,6 +569,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
showCurrentIncrement,
|
||||
showLocalSurface,
|
||||
showRollingMap,
|
||||
showLowStep,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import {
|
||||
Application,
|
||||
Asset,
|
||||
BLEND_NORMAL,
|
||||
Color,
|
||||
CULLFACE_NONE,
|
||||
Entity,
|
||||
FILLMODE_NONE,
|
||||
Mat4,
|
||||
RESOLUTION_AUTO,
|
||||
StandardMaterial,
|
||||
Vec2,
|
||||
Vec3,
|
||||
type ContainerResource,
|
||||
type RenderComponent,
|
||||
type ScriptType,
|
||||
} from "playcanvas";
|
||||
import { CameraControls } from "playcanvas/scripts/esm/camera-controls.mjs";
|
||||
@@ -12,18 +20,58 @@ import { CameraControls } from "playcanvas/scripts/esm/camera-controls.mjs";
|
||||
import type { SimulationWorldManifest } from "../../core/simulation/projects";
|
||||
|
||||
export type SimulationViewMode = "visual" | "collision" | "combined";
|
||||
export type SimulationQuality = "auto" | "low" | "medium" | "high";
|
||||
export type SimulationQuality = "low" | "medium" | "high" | "ultra" | "maximum";
|
||||
export type SimulationLayer = "visual" | "collision";
|
||||
|
||||
export const PLAYCANVAS_IDENTITY_TRANSFORM = [
|
||||
1, 0, 0, 0,
|
||||
0, 1, 0, 0,
|
||||
0, 0, 1, 0,
|
||||
0, 0, 0, 1,
|
||||
];
|
||||
|
||||
export const PLAYCANVAS_X_180_TRANSFORM = [
|
||||
1, 0, 0, 0,
|
||||
0, -1, 0, 0,
|
||||
0, 0, -1, 0,
|
||||
0, 0, 0, 1,
|
||||
];
|
||||
|
||||
export interface SimulationRuntime {
|
||||
mount(canvas: HTMLCanvasElement): Promise<void>;
|
||||
loadWorld(manifest: SimulationWorldManifest): Promise<void>;
|
||||
setViewMode(mode: SimulationViewMode): void;
|
||||
setViewMode(mode: SimulationViewMode): Promise<void>;
|
||||
setQuality(quality: SimulationQuality): void;
|
||||
setLayerWorldTransform(layer: SimulationLayer, transform: number[]): void;
|
||||
home(): void;
|
||||
focusBounds(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
type CameraController = ScriptType & Pick<CameraControls, "reset" | "focus">;
|
||||
type DesktopCameraInput = {
|
||||
read(): { mouse: number[] } & Record<string, number[]>;
|
||||
};
|
||||
|
||||
type CameraController = ScriptType & Pick<CameraControls, "reset" | "focus"> & {
|
||||
_desktopInput?: DesktopCameraInput;
|
||||
};
|
||||
|
||||
const HOME_POSITION = new Vec3(0, 1, 0);
|
||||
const HOME_FOCUS = new Vec3(1, 1, 0);
|
||||
|
||||
const QUALITY_PROFILES: Record<SimulationQuality, {
|
||||
lodBaseDistance: number;
|
||||
lodMultiplier: number;
|
||||
lodRangeMin: number;
|
||||
lodRangeMax: number;
|
||||
pixelRatio: number;
|
||||
}> = {
|
||||
low: { lodBaseDistance: 5, lodMultiplier: 2, lodRangeMin: 3, lodRangeMax: 5, pixelRatio: 0.75 },
|
||||
medium: { lodBaseDistance: 5, lodMultiplier: 2, lodRangeMin: 2, lodRangeMax: 5, pixelRatio: 1 },
|
||||
high: { lodBaseDistance: 5, lodMultiplier: 3, lodRangeMin: 1, lodRangeMax: 5, pixelRatio: 1.5 },
|
||||
ultra: { lodBaseDistance: 7, lodMultiplier: 3, lodRangeMin: 0, lodRangeMax: 5, pixelRatio: 2 },
|
||||
maximum: { lodBaseDistance: 7, lodMultiplier: 3, lodRangeMin: 0, lodRangeMax: 0, pixelRatio: 2 },
|
||||
};
|
||||
|
||||
export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
private app: Application | null = null;
|
||||
@@ -32,15 +80,21 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
private cameraController: CameraController | null = null;
|
||||
private visualEntity: Entity | null = null;
|
||||
private visualAsset: Asset | null = null;
|
||||
private collisionEntity: Entity | null = null;
|
||||
private collisionAsset: Asset | null = null;
|
||||
private collisionMaterial: StandardMaterial | null = null;
|
||||
private collisionSource: { meshUrl: string; projectId: string; worldTransform: number[] } | null = null;
|
||||
private collisionLoadPromise: Promise<void> | null = null;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private viewMode: SimulationViewMode = "visual";
|
||||
private quality: SimulationQuality = "auto";
|
||||
private quality: SimulationQuality = "maximum";
|
||||
|
||||
async mount(canvas: HTMLCanvasElement): Promise<void> {
|
||||
if (this.app) throw new Error("PlayCanvas runtime уже смонтирован.");
|
||||
this.canvas = canvas;
|
||||
canvas.tabIndex = 0;
|
||||
canvas.addEventListener("contextmenu", preventContextMenu);
|
||||
canvas.addEventListener("pointerdown", focusCanvas, true);
|
||||
const app = new Application(canvas, {
|
||||
graphicsDeviceOptions: {
|
||||
antialias: true,
|
||||
@@ -50,6 +104,8 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
},
|
||||
});
|
||||
this.app = app;
|
||||
app.setCanvasFillMode(FILLMODE_NONE, 1, 1);
|
||||
app.setCanvasResolution(RESOLUTION_AUTO);
|
||||
app.scene.ambientLight = new Color(0.35, 0.37, 0.42);
|
||||
|
||||
const camera = new Entity("SimulationCamera");
|
||||
@@ -59,8 +115,8 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
farClip: 20_000,
|
||||
fov: 58,
|
||||
});
|
||||
camera.setPosition(5, 3, 5);
|
||||
camera.lookAt(0, 0, 0);
|
||||
camera.setPosition(HOME_POSITION);
|
||||
camera.lookAt(HOME_FOCUS);
|
||||
camera.addComponent("script");
|
||||
const controller = camera.script?.create(CameraControls, {
|
||||
properties: {
|
||||
@@ -70,6 +126,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
zoomRange: new Vec2(0.05, 20_000),
|
||||
},
|
||||
}) as CameraController | null;
|
||||
if (controller) invertHorizontalCameraDrag(controller);
|
||||
app.root.addChild(camera);
|
||||
this.camera = camera;
|
||||
this.cameraController = controller;
|
||||
@@ -103,6 +160,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
const ready = (loaded: Asset) => {
|
||||
const visual = new Entity("GaussianWorld");
|
||||
visual.enabled = false;
|
||||
applyWorldTransform(visual, manifest.transforms.worldFromVisual);
|
||||
app.root.addChild(visual);
|
||||
visual.addComponent("gsplat", {
|
||||
asset: loaded,
|
||||
@@ -112,7 +170,6 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
this.visualEntity = visual;
|
||||
this.applyQuality();
|
||||
this.applyViewMode();
|
||||
this.focusBounds();
|
||||
resolve();
|
||||
};
|
||||
const failed = (error: unknown) => {
|
||||
@@ -122,11 +179,24 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
asset.once("error", failed);
|
||||
app.assets.load(asset);
|
||||
});
|
||||
if (manifest.collision.available && manifest.collision.meshUrl) {
|
||||
this.collisionSource = {
|
||||
meshUrl: manifest.collision.meshUrl,
|
||||
projectId: manifest.projectId,
|
||||
worldTransform: manifest.transforms.worldFromCollision,
|
||||
};
|
||||
}
|
||||
this.applyViewMode();
|
||||
this.home();
|
||||
}
|
||||
|
||||
setViewMode(mode: SimulationViewMode): void {
|
||||
async setViewMode(mode: SimulationViewMode): Promise<void> {
|
||||
this.viewMode = mode;
|
||||
this.applyViewMode();
|
||||
if (mode !== "visual" && !this.collisionEntity) {
|
||||
await this.ensureCollision();
|
||||
this.applyViewMode();
|
||||
}
|
||||
}
|
||||
|
||||
setQuality(quality: SimulationQuality): void {
|
||||
@@ -134,6 +204,28 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
this.applyQuality();
|
||||
}
|
||||
|
||||
setLayerWorldTransform(layer: SimulationLayer, transform: number[]): void {
|
||||
if (transform.length !== 16) {
|
||||
throw new Error("World transform должен содержать матрицу 4×4.");
|
||||
}
|
||||
if (layer === "visual") {
|
||||
if (this.visualEntity) applyWorldTransform(this.visualEntity, transform);
|
||||
return;
|
||||
}
|
||||
if (this.collisionSource) this.collisionSource.worldTransform = [...transform];
|
||||
if (this.collisionEntity) applyWorldTransform(this.collisionEntity, transform);
|
||||
}
|
||||
|
||||
home(): void {
|
||||
if (this.cameraController) {
|
||||
this.cameraController.reset(HOME_FOCUS, HOME_POSITION);
|
||||
} else if (this.camera) {
|
||||
this.camera.setPosition(HOME_POSITION);
|
||||
this.camera.lookAt(HOME_FOCUS);
|
||||
}
|
||||
this.canvas?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
focusBounds(): void {
|
||||
const bounds = this.visualEntity?.gsplat?.customAabb;
|
||||
const focus = bounds?.center.clone() ?? new Vec3(0, 0, 0);
|
||||
@@ -153,7 +245,10 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
this.resizeObserver = null;
|
||||
this.unloadWorld();
|
||||
if (this.app) this.app.destroy();
|
||||
if (this.canvas) this.canvas.removeEventListener("contextmenu", preventContextMenu);
|
||||
if (this.canvas) {
|
||||
this.canvas.removeEventListener("contextmenu", preventContextMenu);
|
||||
this.canvas.removeEventListener("pointerdown", focusCanvas, true);
|
||||
}
|
||||
this.app = null;
|
||||
this.canvas = null;
|
||||
this.camera = null;
|
||||
@@ -175,22 +270,100 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
|
||||
private applyViewMode(): void {
|
||||
if (this.visualEntity) {
|
||||
this.visualEntity.enabled = this.viewMode !== "collision";
|
||||
this.visualEntity.enabled = this.viewMode !== "collision" || !this.collisionEntity;
|
||||
}
|
||||
if (this.collisionEntity) {
|
||||
this.collisionEntity.enabled = this.viewMode !== "visual";
|
||||
}
|
||||
if (this.collisionMaterial) {
|
||||
const combined = this.viewMode === "combined";
|
||||
this.collisionMaterial.opacity = combined ? 0.34 : 0.82;
|
||||
this.collisionMaterial.depthWrite = !combined;
|
||||
this.collisionMaterial.update();
|
||||
}
|
||||
}
|
||||
|
||||
private applyQuality(): void {
|
||||
const gsplat = this.visualEntity?.gsplat;
|
||||
if (!gsplat) return;
|
||||
const profiles: Record<SimulationQuality, [number, number]> = {
|
||||
auto: [5, 2],
|
||||
low: [2.5, 1.7],
|
||||
medium: [5, 2],
|
||||
high: [9, 2.3],
|
||||
};
|
||||
const [baseDistance, multiplier] = profiles[this.quality];
|
||||
gsplat.lodBaseDistance = baseDistance;
|
||||
gsplat.lodMultiplier = multiplier;
|
||||
const profile = QUALITY_PROFILES[this.quality];
|
||||
if (gsplat) {
|
||||
gsplat.lodBaseDistance = profile.lodBaseDistance;
|
||||
gsplat.lodMultiplier = profile.lodMultiplier;
|
||||
gsplat.lodRangeMin = profile.lodRangeMin;
|
||||
gsplat.lodRangeMax = profile.lodRangeMax;
|
||||
}
|
||||
if (this.app) {
|
||||
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||
this.app.graphicsDevice.maxPixelRatio = Math.min(devicePixelRatio, profile.pixelRatio);
|
||||
this.resize();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadCollision(meshUrl: string, projectId: string, worldTransform: number[]): Promise<void> {
|
||||
const app = this.requiredApp();
|
||||
const asset = new Asset(
|
||||
`SimulationCollision:${projectId}`,
|
||||
"container",
|
||||
{ url: meshUrl, filename: "scene.collision.glb" },
|
||||
);
|
||||
app.assets.add(asset);
|
||||
this.collisionAsset = asset;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
asset.ready((loaded) => {
|
||||
const resource = loaded.resource as ContainerResource | null;
|
||||
if (!resource) {
|
||||
reject(new Error("PlayCanvas не открыл collision GLB."));
|
||||
return;
|
||||
}
|
||||
const entity = resource.instantiateRenderEntity({
|
||||
castShadows: false,
|
||||
receiveShadows: false,
|
||||
});
|
||||
entity.name = "CollisionWorld";
|
||||
entity.enabled = false;
|
||||
applyWorldTransform(entity, worldTransform);
|
||||
app.root.addChild(entity);
|
||||
|
||||
const material = new StandardMaterial();
|
||||
material.name = "SimulationCollisionMaterial";
|
||||
material.diffuse = new Color(0.2, 0.95, 0.42);
|
||||
material.emissive = new Color(0.08, 0.36, 0.14);
|
||||
material.opacity = 0.34;
|
||||
material.blendType = BLEND_NORMAL;
|
||||
material.depthWrite = false;
|
||||
material.cull = CULLFACE_NONE;
|
||||
material.update();
|
||||
for (const render of entity.findComponents("render") as RenderComponent[]) {
|
||||
for (const meshInstance of render.meshInstances) {
|
||||
meshInstance.material = material;
|
||||
}
|
||||
}
|
||||
this.collisionEntity = entity;
|
||||
this.collisionMaterial = material;
|
||||
resolve();
|
||||
});
|
||||
asset.once("error", (error: unknown) => {
|
||||
reject(new Error(error instanceof Error ? error.message : "PlayCanvas не загрузил collision GLB."));
|
||||
});
|
||||
app.assets.load(asset);
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureCollision(): Promise<void> {
|
||||
if (this.collisionEntity) return;
|
||||
if (!this.collisionSource) {
|
||||
throw new Error("Collision GLB отсутствует в world manifest.");
|
||||
}
|
||||
if (!this.collisionLoadPromise) {
|
||||
this.collisionLoadPromise = this.loadCollision(
|
||||
this.collisionSource.meshUrl,
|
||||
this.collisionSource.projectId,
|
||||
this.collisionSource.worldTransform,
|
||||
).finally(() => {
|
||||
this.collisionLoadPromise = null;
|
||||
});
|
||||
}
|
||||
await this.collisionLoadPromise;
|
||||
}
|
||||
|
||||
private unloadWorld(): void {
|
||||
@@ -203,9 +376,46 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
this.visualAsset.unload();
|
||||
this.visualAsset = null;
|
||||
}
|
||||
if (this.collisionEntity) {
|
||||
this.collisionEntity.destroy();
|
||||
this.collisionEntity = null;
|
||||
}
|
||||
if (this.collisionAsset && this.app) {
|
||||
this.app.assets.remove(this.collisionAsset);
|
||||
this.collisionAsset.unload();
|
||||
this.collisionAsset = null;
|
||||
}
|
||||
this.collisionMaterial?.destroy();
|
||||
this.collisionMaterial = null;
|
||||
this.collisionSource = null;
|
||||
this.collisionLoadPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
function preventContextMenu(event: Event): void {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function focusCanvas(event: Event): void {
|
||||
(event.currentTarget as HTMLCanvasElement | null)?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function invertHorizontalCameraDrag(controller: CameraController): void {
|
||||
// CameraControls 2.21.4 deliberately exposes one pinned input source here.
|
||||
// Keep its pointer-capture and fly/orbit behavior, changing only horizontal drag semantics.
|
||||
const input = controller._desktopInput;
|
||||
if (!input) return;
|
||||
const read = input.read.bind(input);
|
||||
input.read = () => {
|
||||
const frame = read();
|
||||
if (frame.mouse.length > 0) frame.mouse[0] *= -1;
|
||||
return frame;
|
||||
};
|
||||
}
|
||||
|
||||
function applyWorldTransform(entity: Entity, values: number[]): void {
|
||||
const transform = new Mat4().set(values);
|
||||
entity.setLocalPosition(transform.getTranslation());
|
||||
entity.setLocalEulerAngles(transform.getEulerAngles());
|
||||
entity.setLocalScale(transform.getScale());
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Button, SegmentedControl, StatusBadge } from "@nodedc/ui-react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
Icon,
|
||||
IconButton,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
StatusBadge,
|
||||
Switch,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import type { SimulationProject } from "../../core/simulation/projects";
|
||||
import {
|
||||
PLAYCANVAS_IDENTITY_TRANSFORM,
|
||||
PLAYCANVAS_X_180_TRANSFORM,
|
||||
PlayCanvasRuntime,
|
||||
type SimulationQuality,
|
||||
type SimulationViewMode,
|
||||
@@ -11,10 +22,25 @@ import {
|
||||
export function SimulationViewport({ project }: { project: SimulationProject }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const runtimeRef = useRef<PlayCanvasRuntime | null>(null);
|
||||
const settingsId = useId();
|
||||
const [state, setState] = useState<"mounting" | "loading" | "ready" | "failed">("mounting");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<SimulationViewMode>("visual");
|
||||
const [quality, setQuality] = useState<SimulationQuality>("auto");
|
||||
const [quality, setQuality] = useState<SimulationQuality>("maximum");
|
||||
const [collisionState, setCollisionState] = useState<"idle" | "loading" | "ready" | "failed">("idle");
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [visualInverted, setVisualInverted] = useState(
|
||||
() => isX180Transform(project.worldManifest?.transforms.worldFromVisual),
|
||||
);
|
||||
const [collisionInverted, setCollisionInverted] = useState(
|
||||
() => isX180Transform(project.worldManifest?.transforms.worldFromCollision),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setVisualInverted(isX180Transform(project.worldManifest?.transforms.worldFromVisual));
|
||||
setCollisionInverted(isX180Transform(project.worldManifest?.transforms.worldFromCollision));
|
||||
setSettingsOpen(false);
|
||||
}, [project.projectId, project.worldManifest]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@@ -48,41 +74,124 @@ export function SimulationViewport({ project }: { project: SimulationProject })
|
||||
<section className="simulation-viewport" aria-label={`Сцена ${project.name}`}>
|
||||
<header className="simulation-viewport__toolbar">
|
||||
<div>
|
||||
<StatusBadge tone={state === "ready" ? "success" : state === "failed" ? "warning" : "accent"}>
|
||||
{state === "ready" ? "Runtime готов" : state === "failed" ? "Ошибка runtime" : "Загрузка сцены"}
|
||||
<StatusBadge tone={state === "failed" || collisionState === "failed" ? "warning" : state === "ready" && collisionState !== "loading" ? "success" : "accent"}>
|
||||
{collisionState === "loading"
|
||||
? "Загрузка collision"
|
||||
: collisionState === "failed"
|
||||
? "Ошибка collision"
|
||||
: state === "ready"
|
||||
? "Runtime готов"
|
||||
: state === "failed"
|
||||
? "Ошибка runtime"
|
||||
: "Загрузка сцены"}
|
||||
</StatusBadge>
|
||||
<span>PlayCanvas Engine 2.21.4</span>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
label="Слой сцены"
|
||||
value={viewMode}
|
||||
onChange={(next) => {
|
||||
setViewMode(next);
|
||||
runtimeRef.current?.setViewMode(next);
|
||||
}}
|
||||
items={[
|
||||
{ value: "visual", label: "Визуал" },
|
||||
{ value: "collision", label: "Коллизии", disabled: !collisionAvailable },
|
||||
{ value: "combined", label: "Вместе", disabled: !collisionAvailable },
|
||||
]}
|
||||
/>
|
||||
<SegmentedControl
|
||||
label="Качество Streamed SOG"
|
||||
value={quality}
|
||||
onChange={(next) => {
|
||||
setQuality(next);
|
||||
runtimeRef.current?.setQuality(next);
|
||||
}}
|
||||
items={[
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Med" },
|
||||
{ value: "high", label: "High" },
|
||||
]}
|
||||
/>
|
||||
<Button size="compact" variant="secondary" onClick={() => runtimeRef.current?.focusBounds()}>
|
||||
Вписать сцену
|
||||
</Button>
|
||||
<div className="simulation-viewport__controls">
|
||||
<Select
|
||||
label="Качество Gaussian-сцены"
|
||||
value={quality}
|
||||
onChange={(next) => {
|
||||
setQuality(next);
|
||||
runtimeRef.current?.setQuality(next);
|
||||
}}
|
||||
options={[
|
||||
{ value: "maximum", label: "Максимум", description: "Только полный LOD 0, Retina до 2×" },
|
||||
{ value: "ultra", label: "Ультра", description: "LOD 0 и Retina до 2×" },
|
||||
{ value: "high", label: "Высокое", description: "LOD 1 и Retina до 1,5×" },
|
||||
{ value: "medium", label: "Среднее", description: "LOD 2 и обычное разрешение" },
|
||||
{ value: "low", label: "Низкое", description: "LOD 3 для слабых устройств" },
|
||||
]}
|
||||
minMenuWidth={230}
|
||||
menuWidth={230}
|
||||
/>
|
||||
<SegmentedControl
|
||||
label="Слой сцены"
|
||||
value={viewMode}
|
||||
onChange={(next) => {
|
||||
setViewMode(next);
|
||||
const runtime = runtimeRef.current;
|
||||
if (!runtime) return;
|
||||
if (next !== "visual" && collisionAvailable && collisionState !== "ready") {
|
||||
setCollisionState("loading");
|
||||
}
|
||||
void runtime.setViewMode(next).then(() => {
|
||||
if (next !== "visual") setCollisionState("ready");
|
||||
}).catch((caught: unknown) => {
|
||||
setCollisionState("failed");
|
||||
setError(caught instanceof Error ? caught.message : "Не удалось открыть collision GLB.");
|
||||
});
|
||||
}}
|
||||
items={[
|
||||
{ value: "visual", label: "Визуал" },
|
||||
{ value: "collision", label: "Коллизии", disabled: !collisionAvailable },
|
||||
{ value: "combined", label: "Вместе", disabled: !collisionAvailable },
|
||||
]}
|
||||
/>
|
||||
<Button size="compact" variant="secondary" onClick={() => runtimeRef.current?.home()}>
|
||||
Домой
|
||||
</Button>
|
||||
<div className="simulation-viewport__settings-anchor">
|
||||
<IconButton
|
||||
label="Настройки системы координат"
|
||||
aria-controls={settingsId}
|
||||
aria-expanded={settingsOpen}
|
||||
aria-pressed={settingsOpen}
|
||||
onClick={() => setSettingsOpen((open) => !open)}
|
||||
>
|
||||
<Icon name="settings" size={18} />
|
||||
</IconButton>
|
||||
{settingsOpen ? (
|
||||
<div
|
||||
id={settingsId}
|
||||
className="simulation-viewport__settings"
|
||||
role="dialog"
|
||||
aria-label="Настройки системы координат"
|
||||
>
|
||||
<div className="simulation-viewport__settings-head">
|
||||
<div>
|
||||
<strong>Система координат</strong>
|
||||
<span>Мир PlayCanvas · Y вверх</span>
|
||||
</div>
|
||||
<IconButton label="Закрыть настройки" onClick={() => setSettingsOpen(false)}>
|
||||
<Icon name="close" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<p>
|
||||
Коррекция применяется к слоям независимо и не меняет координаты камеры,
|
||||
навигации и будущей физики.
|
||||
</p>
|
||||
<div className="simulation-viewport__settings-switches">
|
||||
<Switch
|
||||
checked={visualInverted}
|
||||
disabled={state !== "ready"}
|
||||
label="Инверсия визуального слоя"
|
||||
onChange={(checked) => {
|
||||
setVisualInverted(checked);
|
||||
runtimeRef.current?.setLayerWorldTransform(
|
||||
"visual",
|
||||
checked ? PLAYCANVAS_X_180_TRANSFORM : PLAYCANVAS_IDENTITY_TRANSFORM,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
checked={collisionInverted}
|
||||
disabled={state !== "ready"}
|
||||
label="Инверсия collision-слоя"
|
||||
onChange={(checked) => {
|
||||
setCollisionInverted(checked);
|
||||
runtimeRef.current?.setLayerWorldTransform(
|
||||
"collision",
|
||||
checked ? PLAYCANVAS_X_180_TRANSFORM : PLAYCANVAS_IDENTITY_TRANSFORM,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<span className="simulation-viewport__toolbar-balance" aria-hidden="true" />
|
||||
</header>
|
||||
<div className="simulation-viewport__stage">
|
||||
<canvas ref={canvasRef} aria-label={`PlayCanvas сцена ${project.name}`} />
|
||||
@@ -99,7 +208,17 @@ export function SimulationViewport({ project }: { project: SimulationProject })
|
||||
<StatusBadge tone="warning">Collision недоступен</StatusBadge>
|
||||
<span>Для этой сборки collision GLB не был запрошен; визуальный слой настоящий и не подменяется.</span>
|
||||
</footer>
|
||||
) : collisionState === "failed" ? (
|
||||
<footer className="simulation-viewport__notice" role="alert">
|
||||
<StatusBadge tone="warning">Collision не открылся</StatusBadge>
|
||||
<span>{error ?? "PlayCanvas не смог загрузить collision GLB."}</span>
|
||||
</footer>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function isX180Transform(transform: number[] | undefined): boolean {
|
||||
if (!transform || transform.length !== 16) return false;
|
||||
return transform.every((value, index) => value === PLAYCANVAS_X_180_TRANSFORM[index]);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "./m48ObjectCentricQuality";
|
||||
import { fetchM48SmallStaticRegression } from "./m48SmallStaticRegression";
|
||||
import { fetchM48StaticOccupancyQualification } from "./m48StaticOccupancyQualification";
|
||||
import { fetchM48R3StaticOccupancyShadow } from "./m48r3StaticOccupancyShadow";
|
||||
import { fetchM48SFixedClassDetectorResult } from "./m48sFixedClassDetector";
|
||||
import { fetchM48TRiskQualityResult } from "./m48tRiskQuality";
|
||||
|
||||
@@ -50,6 +51,7 @@ export type AdvancedLaboratoryWorkId =
|
||||
| "m48-object-centric-quality"
|
||||
| "m48-small-static-passage-regression"
|
||||
| "m48-static-occupancy-qualification"
|
||||
| "m48r3-static-occupancy-shadow"
|
||||
| "m48s-fixed-class-detector"
|
||||
| "m48t-risk-quality-temporal"
|
||||
| "m47-reference-graph-shadow"
|
||||
@@ -97,6 +99,7 @@ const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [
|
||||
"m48-object-centric-quality",
|
||||
"m48-small-static-passage-regression",
|
||||
"m48-static-occupancy-qualification",
|
||||
"m48r3-static-occupancy-shadow",
|
||||
"m48s-fixed-class-detector",
|
||||
"m48t-risk-quality-temporal",
|
||||
"m47-reference-graph-shadow",
|
||||
@@ -139,6 +142,7 @@ const RESULT_PREFIX: Readonly<Record<AdvancedLaboratoryWorkId, string>> = {
|
||||
"m48-object-centric-quality": "m48-object-quality-(?:pack|result)",
|
||||
"m48-small-static-passage-regression": "m48-small-static-passage-regression",
|
||||
"m48-static-occupancy-qualification": "m48-static-occupancy-qualification",
|
||||
"m48r3-static-occupancy-shadow": "m48r3-static-occupancy-shadow",
|
||||
"m48s-fixed-class-detector": "m48s-fixed-class-detector-lab",
|
||||
"m48t-risk-quality-temporal": "(?:m48t-risk-quality-temporal-lab|m48q-native-risk-quality-lab)",
|
||||
"m47-reference-graph-shadow": "m47-reference-graph-lab",
|
||||
@@ -189,6 +193,7 @@ export function emptyAdvancedLaboratoryResults(): AdvancedLaboratoryResults {
|
||||
m48: null,
|
||||
m48SmallStatic: null,
|
||||
m48StaticOccupancy: null,
|
||||
m48r3StaticOccupancy: null,
|
||||
m48s: null,
|
||||
m48t: null,
|
||||
m4Threat: null,
|
||||
@@ -318,6 +323,7 @@ export function advancedLaboratoryResultAvailable(
|
||||
return workId === "m48-object-centric-quality" ? results.m48 !== null
|
||||
: workId === "m48-small-static-passage-regression" ? results.m48SmallStatic !== null
|
||||
: workId === "m48-static-occupancy-qualification" ? results.m48StaticOccupancy !== null
|
||||
: workId === "m48r3-static-occupancy-shadow" ? results.m48r3StaticOccupancy !== null
|
||||
: workId === "m48s-fixed-class-detector" ? results.m48s !== null
|
||||
: workId === "m48t-risk-quality-temporal" ? results.m48t !== null
|
||||
: workId === "m47-reference-graph-shadow" ? results.m47Graph !== null
|
||||
@@ -378,6 +384,9 @@ export async function fetchAdvancedLaboratoryResult(
|
||||
} else if (workId === "m48-static-occupancy-qualification") {
|
||||
if (!resultId) throw new AdvancedLaboratoryContractError("M4.8R2 qualification identity не выбрана.");
|
||||
results.m48StaticOccupancy = await fetchM48StaticOccupancyQualification(resultId, { fetcher, signal });
|
||||
} else if (workId === "m48r3-static-occupancy-shadow") {
|
||||
if (!resultId) throw new AdvancedLaboratoryContractError("M4.8R3 Worker shadow identity не выбрана.");
|
||||
results.m48r3StaticOccupancy = await fetchM48R3StaticOccupancyShadow(resultId, { fetcher, signal });
|
||||
} else if (workId === "m48s-fixed-class-detector") {
|
||||
if (!resultId) throw new AdvancedLaboratoryContractError("M4.8S LAB identity не выбрана.");
|
||||
results.m48s = await fetchM48SFixedClassDetectorResult(resultId, { fetcher, signal });
|
||||
|
||||
@@ -37,6 +37,7 @@ import type { M47ReferenceGraphLabResult } from "./m47ReferenceGraph";
|
||||
import type { M48AdvancedResult } from "./m48ObjectCentricQuality";
|
||||
import type { M48SmallStaticRegressionResult } from "./m48SmallStaticRegression";
|
||||
import type { M48StaticOccupancyQualificationResult } from "./m48StaticOccupancyQualification";
|
||||
import type { M48R3StaticOccupancyShadowResult } from "./m48r3StaticOccupancyShadow";
|
||||
import type { M48SFixedClassDetectorResult } from "./m48sFixedClassDetector";
|
||||
import type { M48TRiskQualityResult } from "./m48tRiskQuality";
|
||||
|
||||
@@ -45,6 +46,7 @@ export interface AdvancedLaboratoryResults {
|
||||
m48: M48AdvancedResult | null;
|
||||
m48SmallStatic: M48SmallStaticRegressionResult | null;
|
||||
m48StaticOccupancy: M48StaticOccupancyQualificationResult | null;
|
||||
m48r3StaticOccupancy: M48R3StaticOccupancyShadowResult | null;
|
||||
m48s: M48SFixedClassDetectorResult | null;
|
||||
m48t: M48TRiskQualityResult | null;
|
||||
m4Threat: M4ThreatReplayResult | null;
|
||||
|
||||
@@ -968,6 +968,7 @@ export async function fetchAdvancedLaboratoryResults({
|
||||
const e40 = settledCatalogValue(settled[8]);
|
||||
return {
|
||||
m47Graph: null, m48: null, m48SmallStatic: null, m48StaticOccupancy: null,
|
||||
m48r3StaticOccupancy: null,
|
||||
m48s: null, m48t: null, m4Threat: null,
|
||||
l3: null, l31: null, l32: null, l33: null,
|
||||
e31,
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import type { LaboratoryFetch } from "./advancedResults";
|
||||
import type { M48Authority } from "./m48ObjectCentricQuality";
|
||||
|
||||
const RESULT_ID = /^m48r3-static-occupancy-shadow-[a-f0-9]{64}$/;
|
||||
|
||||
interface M48R3WorkerPerformance {
|
||||
admittedFrames: number;
|
||||
deliveredFrames: number;
|
||||
fps: number;
|
||||
worldP95Ms: number;
|
||||
worldP99Ms: number;
|
||||
geometryP95Ms: number;
|
||||
geometryP99Ms: number;
|
||||
}
|
||||
|
||||
export interface M48R3StaticOccupancyShadowResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
accepted: boolean;
|
||||
profile: {
|
||||
id: string;
|
||||
sha256: string;
|
||||
minimumPoints: number;
|
||||
};
|
||||
metrics: {
|
||||
frames: { expected: number; baselineDelivered: number; candidateDelivered: number };
|
||||
performance: {
|
||||
baseline: M48R3WorkerPerformance;
|
||||
candidate: M48R3WorkerPerformance;
|
||||
fpsRegressionFraction: number;
|
||||
worldStateP95DeltaMs: number;
|
||||
};
|
||||
occupancy: {
|
||||
baselineCellTotal: number;
|
||||
candidateCellTotal: number;
|
||||
addedCellTotal: number;
|
||||
lostCellTotal: number;
|
||||
baselineComponentTotal: number;
|
||||
candidateComponentTotal: number;
|
||||
meanCellGrowthFraction: number;
|
||||
meanComponentGrowthFraction: number;
|
||||
maximumAddedCellsPerFrame: number;
|
||||
maximumCandidateComponentsPerFrame: number;
|
||||
};
|
||||
provider: {
|
||||
additiveObservationCount: number;
|
||||
additiveVoxelCount: number;
|
||||
framesWithAdditions: number;
|
||||
additiveMeanMsPerFrame: number;
|
||||
peakTemporalComponents: number;
|
||||
peakRollingCells: number;
|
||||
};
|
||||
anchors: {
|
||||
count: number;
|
||||
criticalNearCount: number;
|
||||
criticalNearRecall: number;
|
||||
matchedCount: number;
|
||||
canonicalEngineeringRecall: number;
|
||||
separation: readonly {
|
||||
displayFrame: number;
|
||||
expectedMinimumComponents: number;
|
||||
observedComponents: number;
|
||||
passed: boolean;
|
||||
interpretation: string;
|
||||
}[];
|
||||
};
|
||||
capacityDropCount: number;
|
||||
falseFreeCount: number;
|
||||
};
|
||||
gates: Readonly<Record<string, boolean>>;
|
||||
decision: {
|
||||
state: "accepted-bounded-worker-shadow" | "rejected-bounded-worker-shadow";
|
||||
candidateAccepted: boolean;
|
||||
productionAccepted: false;
|
||||
nextAction: string;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
authority: M48Authority;
|
||||
}
|
||||
|
||||
export interface M48R3StaticOccupancyCase {
|
||||
anchorId: string;
|
||||
displayFrame: number;
|
||||
sourceSequence: number;
|
||||
extentXyxy: readonly [number, number, number, number];
|
||||
distanceBand: "critical-near" | "approach" | "outside-qualified-bands";
|
||||
componentCount: number;
|
||||
matched: boolean;
|
||||
}
|
||||
|
||||
export class M48R3StaticOccupancyContractError extends Error {}
|
||||
|
||||
function objectValue(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new M48R3StaticOccupancyContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new M48R3StaticOccupancyContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new M48R3StaticOccupancyContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = numberValue(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new M48R3StaticOccupancyContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function bool(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new M48R3StaticOccupancyContractError(`${label}: ожидался флаг.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exact(value: unknown, expected: string | boolean, label: string): void {
|
||||
if (value !== expected) {
|
||||
throw new M48R3StaticOccupancyContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(value: unknown, label: string): string {
|
||||
const parsed = text(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new M48R3StaticOccupancyContractError(`${label}: неверный SHA-256.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function extent(value: unknown): readonly [number, number, number, number] {
|
||||
if (!Array.isArray(value) || value.length !== 4) {
|
||||
throw new M48R3StaticOccupancyContractError("M4.8R3 extent: нарушен контракт.");
|
||||
}
|
||||
const parsed = value.map((item) => numberValue(item, "M4.8R3 extent"));
|
||||
return [parsed[0]!, parsed[1]!, parsed[2]!, parsed[3]!];
|
||||
}
|
||||
|
||||
function authority(value: unknown): M48Authority {
|
||||
const row = objectValue(value, "M4.8R3 authority");
|
||||
exact(row.mode, "replay-simulated", "M4.8R3 authority.mode");
|
||||
exact(row.physical_live, false, "M4.8R3 authority.physical_live");
|
||||
exact(row.commands_enabled, false, "M4.8R3 authority.commands_enabled");
|
||||
exact(row.actuation_allowed, false, "M4.8R3 authority.actuation_allowed");
|
||||
exact(
|
||||
row.navigation_or_safety_accepted,
|
||||
false,
|
||||
"M4.8R3 authority.navigation_or_safety_accepted",
|
||||
);
|
||||
return {
|
||||
mode: "replay-simulated",
|
||||
physicalLive: false,
|
||||
commandsEnabled: false,
|
||||
actuationAllowed: false,
|
||||
navigationOrSafetyAccepted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function workerPerformance(value: unknown, label: string): M48R3WorkerPerformance {
|
||||
const row = objectValue(value, label);
|
||||
return {
|
||||
admittedFrames: integer(row.admitted_frames, `${label}.admitted_frames`),
|
||||
deliveredFrames: integer(row.delivered_frames, `${label}.delivered_frames`),
|
||||
fps: numberValue(row.fps, `${label}.fps`),
|
||||
worldP95Ms: numberValue(row.world_p95_ms, `${label}.world_p95_ms`),
|
||||
worldP99Ms: numberValue(row.world_p99_ms, `${label}.world_p99_ms`),
|
||||
geometryP95Ms: numberValue(row.geometry_p95_ms, `${label}.geometry_p95_ms`),
|
||||
geometryP99Ms: numberValue(row.geometry_p99_ms, `${label}.geometry_p99_ms`),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchM48R3StaticOccupancyShadow(
|
||||
resultId: string,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<M48R3StaticOccupancyShadowResult> {
|
||||
if (!RESULT_ID.test(resultId)) {
|
||||
throw new M48R3StaticOccupancyContractError("M4.8R3 identity недопустима.");
|
||||
}
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/m48r3/static-occupancy/${encodeURIComponent(resultId)}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new M48R3StaticOccupancyContractError(`M4.8R3 недоступен: HTTP ${response.status}.`);
|
||||
}
|
||||
const payload = objectValue(await response.json(), "M4.8R3");
|
||||
exact(
|
||||
payload.schema_version,
|
||||
"missioncore.m48r3-static-occupancy-shadow-view/v1",
|
||||
"M4.8R3 schema",
|
||||
);
|
||||
exact(payload.result_id, resultId, "M4.8R3 result");
|
||||
exact(payload.ground_truth, false, "M4.8R3 ground truth");
|
||||
const profile = objectValue(payload.profile, "M4.8R3 profile");
|
||||
const componentization = objectValue(profile.componentization, "M4.8R3 componentization");
|
||||
const metrics = objectValue(payload.metrics, "M4.8R3 metrics");
|
||||
const frames = objectValue(metrics.frames, "M4.8R3 frames");
|
||||
const performance = objectValue(metrics.performance, "M4.8R3 performance");
|
||||
const occupancy = objectValue(metrics.occupancy, "M4.8R3 occupancy");
|
||||
const provider = objectValue(metrics.provider, "M4.8R3 provider");
|
||||
const anchors = objectValue(metrics.assisted_anchors, "M4.8R3 anchors");
|
||||
if (!Array.isArray(anchors.separation)) {
|
||||
throw new M48R3StaticOccupancyContractError("M4.8R3 separation: ожидался массив.");
|
||||
}
|
||||
const gates = objectValue(payload.gates, "M4.8R3 gates");
|
||||
const parsedGates = Object.fromEntries(
|
||||
Object.entries(gates).map(([key, value]) => [key, bool(value, `M4.8R3 gate ${key}`)]),
|
||||
);
|
||||
const decision = objectValue(payload.decision, "M4.8R3 decision");
|
||||
const state = text(decision.state, "M4.8R3 decision.state");
|
||||
if (state !== "accepted-bounded-worker-shadow" && state !== "rejected-bounded-worker-shadow") {
|
||||
throw new M48R3StaticOccupancyContractError("M4.8R3 decision.state: неизвестное состояние.");
|
||||
}
|
||||
exact(decision.production_accepted, false, "M4.8R3 production acceptance");
|
||||
if (!Array.isArray(payload.limitations)) {
|
||||
throw new M48R3StaticOccupancyContractError("M4.8R3 limitations: ожидался массив.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: text(payload.created_at_utc, "M4.8R3 created"),
|
||||
accepted: bool(payload.accepted, "M4.8R3 accepted"),
|
||||
profile: {
|
||||
id: text(profile.id, "M4.8R3 profile id"),
|
||||
sha256: sha256(profile.sha256, "M4.8R3 profile sha"),
|
||||
minimumPoints: integer(componentization.minimum_points, "M4.8R3 minimum points"),
|
||||
},
|
||||
metrics: {
|
||||
frames: {
|
||||
expected: integer(frames.expected, "M4.8R3 expected frames"),
|
||||
baselineDelivered: integer(frames.baseline_delivered, "M4.8R3 baseline frames"),
|
||||
candidateDelivered: integer(frames.candidate_delivered, "M4.8R3 candidate frames"),
|
||||
},
|
||||
performance: {
|
||||
baseline: workerPerformance(performance.baseline, "M4.8R3 baseline"),
|
||||
candidate: workerPerformance(performance.candidate, "M4.8R3 candidate"),
|
||||
fpsRegressionFraction: numberValue(performance.fps_regression_fraction, "M4.8R3 FPS regression"),
|
||||
worldStateP95DeltaMs: numberValue(performance.world_state_p95_delta_ms, "M4.8R3 p95 delta"),
|
||||
},
|
||||
occupancy: {
|
||||
baselineCellTotal: integer(occupancy.baseline_cell_total, "M4.8R3 baseline cells"),
|
||||
candidateCellTotal: integer(occupancy.candidate_cell_total, "M4.8R3 candidate cells"),
|
||||
addedCellTotal: integer(occupancy.added_cell_total, "M4.8R3 added cells"),
|
||||
lostCellTotal: integer(occupancy.lost_cell_total, "M4.8R3 lost cells"),
|
||||
baselineComponentTotal: integer(occupancy.baseline_component_total, "M4.8R3 baseline components"),
|
||||
candidateComponentTotal: integer(occupancy.candidate_component_total, "M4.8R3 candidate components"),
|
||||
meanCellGrowthFraction: numberValue(occupancy.mean_cell_growth_fraction, "M4.8R3 cell growth"),
|
||||
meanComponentGrowthFraction: numberValue(occupancy.mean_component_growth_fraction, "M4.8R3 component growth"),
|
||||
maximumAddedCellsPerFrame: integer(occupancy.maximum_added_cells_per_frame, "M4.8R3 maximum added cells"),
|
||||
maximumCandidateComponentsPerFrame: integer(occupancy.maximum_candidate_components_per_frame, "M4.8R3 maximum components"),
|
||||
},
|
||||
provider: {
|
||||
additiveObservationCount: integer(provider.additive_observation_count, "M4.8R3 observations"),
|
||||
additiveVoxelCount: integer(provider.additive_voxel_count, "M4.8R3 voxels"),
|
||||
framesWithAdditions: integer(provider.frames_with_additions, "M4.8R3 added frames"),
|
||||
additiveMeanMsPerFrame: numberValue(provider.additive_mean_ms_per_frame, "M4.8R3 additive mean"),
|
||||
peakTemporalComponents: integer(provider.peak_temporal_components, "M4.8R3 temporal peak"),
|
||||
peakRollingCells: integer(provider.peak_rolling_cells, "M4.8R3 rolling peak"),
|
||||
},
|
||||
anchors: {
|
||||
count: integer(anchors.count, "M4.8R3 anchor count"),
|
||||
criticalNearCount: integer(anchors.critical_near_count, "M4.8R3 near anchors"),
|
||||
criticalNearRecall: numberValue(anchors.critical_near_recall, "M4.8R3 near recall"),
|
||||
matchedCount: integer(anchors.matched_count, "M4.8R3 matched anchors"),
|
||||
canonicalEngineeringRecall: numberValue(anchors.canonical_engineering_recall, "M4.8R3 canonical recall"),
|
||||
separation: anchors.separation.map((value, index) => {
|
||||
const row = objectValue(value, `M4.8R3 separation ${index}`);
|
||||
return {
|
||||
displayFrame: integer(row.display_frame, "M4.8R3 separation frame"),
|
||||
expectedMinimumComponents: integer(row.expected_minimum_components, "M4.8R3 expected components"),
|
||||
observedComponents: integer(row.observed_components, "M4.8R3 observed components"),
|
||||
passed: bool(row.passed, "M4.8R3 separation passed"),
|
||||
interpretation: text(row.interpretation, "M4.8R3 separation interpretation"),
|
||||
};
|
||||
}),
|
||||
},
|
||||
capacityDropCount: integer(metrics.capacity_drop_count, "M4.8R3 capacity drops"),
|
||||
falseFreeCount: integer(metrics.false_free_count, "M4.8R3 false free"),
|
||||
},
|
||||
gates: parsedGates,
|
||||
decision: {
|
||||
state,
|
||||
candidateAccepted: bool(decision.candidate_accepted, "M4.8R3 candidate accepted"),
|
||||
productionAccepted: false,
|
||||
nextAction: text(decision.next_action, "M4.8R3 next action"),
|
||||
},
|
||||
limitations: payload.limitations.map((value, index) => text(value, `M4.8R3 limitation ${index}`)),
|
||||
authority: authority(payload.authority),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchM48R3StaticOccupancyCases(
|
||||
resultId: string,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<readonly M48R3StaticOccupancyCase[]> {
|
||||
if (!RESULT_ID.test(resultId)) {
|
||||
throw new M48R3StaticOccupancyContractError("M4.8R3 identity недопустима.");
|
||||
}
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/m48r3/static-occupancy/${encodeURIComponent(resultId)}/cases`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new M48R3StaticOccupancyContractError(`M4.8R3 cases недоступны: HTTP ${response.status}.`);
|
||||
}
|
||||
const payload = objectValue(await response.json(), "M4.8R3 cases");
|
||||
exact(
|
||||
payload.schema_version,
|
||||
"missioncore.m48r3-static-occupancy-shadow-cases/v1",
|
||||
"M4.8R3 cases schema",
|
||||
);
|
||||
exact(payload.result_id, resultId, "M4.8R3 cases result");
|
||||
if (!Array.isArray(payload.cases) || payload.cases.length !== integer(payload.case_count, "M4.8R3 case count")) {
|
||||
throw new M48R3StaticOccupancyContractError("M4.8R3 cases: нарушен размер.");
|
||||
}
|
||||
return payload.cases.map((value, index) => {
|
||||
const row = objectValue(value, `M4.8R3 case ${index}`);
|
||||
const distanceBand = text(row.distance_band, "M4.8R3 distance band");
|
||||
if (distanceBand !== "critical-near" && distanceBand !== "approach" && distanceBand !== "outside-qualified-bands") {
|
||||
throw new M48R3StaticOccupancyContractError("M4.8R3 distance band: неизвестное значение.");
|
||||
}
|
||||
return {
|
||||
anchorId: text(row.anchor_id, "M4.8R3 anchor id"),
|
||||
displayFrame: integer(row.display_frame, "M4.8R3 display frame"),
|
||||
sourceSequence: integer(row.source_sequence, "M4.8R3 source sequence"),
|
||||
extentXyxy: extent(row.extent_xyxy),
|
||||
distanceBand,
|
||||
componentCount: integer(row.component_count, "M4.8R3 component count"),
|
||||
matched: bool(row.matched, "M4.8R3 matched"),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export type M4ThreatDecision = "threat" | "not-threat" | "unknown";
|
||||
export type M4ThreatMotion = "moving" | "stationary" | "unknown";
|
||||
export type M4OccupancySource = "baseline" | "mixed" | "additive-low-step";
|
||||
export type M4Point3 = readonly [number, number, number];
|
||||
export type M4Matrix3 = readonly [M4Point3, M4Point3, M4Point3];
|
||||
|
||||
@@ -76,6 +77,7 @@ export interface M4ThreatMetricVisual {
|
||||
centroidBodyXyzM: M4Point3;
|
||||
cellCentersBodyXyzM: readonly M4Point3[];
|
||||
assessment: M4ThreatAssessment;
|
||||
occupancySource: M4OccupancySource;
|
||||
}
|
||||
|
||||
export interface M4ThreatCameraProposal {
|
||||
@@ -174,6 +176,7 @@ export interface M4ThreatTimeline {
|
||||
cameraPointWindowSeconds: number;
|
||||
cameraPointSampleLimit: number;
|
||||
worldStateDelivery: "source-paced-latest-wins" | null;
|
||||
occupancyProvenanceDelivery: "baseline-versus-additive-component-diff" | null;
|
||||
worldStateFrameCount: number;
|
||||
supersededFrameCount: number;
|
||||
sourceRepresentationId: "registered-map-increment-v1";
|
||||
@@ -331,6 +334,9 @@ function parseMetricVisual(value: unknown): M4ThreatMetricVisual {
|
||||
) {
|
||||
throw new M4ThreatContractError("M4.6 temporal state: неизвестное состояние.");
|
||||
}
|
||||
const occupancySource = item.occupancy_source === undefined
|
||||
? "baseline"
|
||||
: memberOccupancySource(item.occupancy_source);
|
||||
return {
|
||||
componentId: text(item.component_id, "M4.6 visual component"),
|
||||
state,
|
||||
@@ -340,9 +346,17 @@ function parseMetricVisual(value: unknown): M4ThreatMetricVisual {
|
||||
(point) => vector(point, 3, "M4.6 cell") as [number, number, number],
|
||||
),
|
||||
assessment: parseAssessment(item.assessment),
|
||||
occupancySource,
|
||||
};
|
||||
}
|
||||
|
||||
function memberOccupancySource(value: unknown): M4OccupancySource {
|
||||
if (value !== "baseline" && value !== "mixed" && value !== "additive-low-step") {
|
||||
throw new M4ThreatContractError("M4.8R3 occupancy source: неизвестное состояние.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function fetchM4ThreatReplayResult({
|
||||
resultId: requestedResultId,
|
||||
fetcher = fetch,
|
||||
@@ -683,6 +697,13 @@ export async function fetchM4ThreatTimeline(
|
||||
"source-paced-latest-wins",
|
||||
"M4.6 world-state delivery",
|
||||
),
|
||||
occupancyProvenanceDelivery: payload.occupancy_provenance_delivery == null
|
||||
? null
|
||||
: exact(
|
||||
payload.occupancy_provenance_delivery,
|
||||
"baseline-versus-additive-component-diff",
|
||||
"M4.8R3 occupancy provenance",
|
||||
),
|
||||
worldStateFrameCount: payload.world_state_frame_count === undefined
|
||||
? frameCount
|
||||
: integer(payload.world_state_frame_count, "M4.6 world-state frames"),
|
||||
|
||||
@@ -334,6 +334,12 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__legend span[data-decision="low-step"]::before {
|
||||
box-sizing: border-box;
|
||||
border: 1px solid rgb(var(--nodedc-foreground-rgb));
|
||||
background: rgb(var(--nodedc-accent-rgb));
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__legend span[data-decision="local-surface"]::before {
|
||||
background: rgb(var(--nodedc-accent-rgb));
|
||||
opacity: 0.72;
|
||||
|
||||
@@ -332,6 +332,7 @@
|
||||
}
|
||||
|
||||
.simulation-viewport {
|
||||
container-type: inline-size;
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
@@ -341,7 +342,9 @@
|
||||
}
|
||||
|
||||
.simulation-viewport__toolbar {
|
||||
flex-wrap: wrap;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(12rem, 1fr) auto minmax(12rem, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
border-bottom: 1px solid var(--station-hairline);
|
||||
background: var(--nodedc-glass-panel-bg-soft);
|
||||
@@ -356,6 +359,74 @@
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.simulation-viewport__controls {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.simulation-viewport__settings-anchor {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.simulation-viewport__settings {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
top: calc(100% + 0.65rem);
|
||||
right: 0;
|
||||
display: grid;
|
||||
width: min(19rem, calc(100cqw - 1.3rem));
|
||||
gap: 0.75rem;
|
||||
border-radius: 0.9rem;
|
||||
background: rgb(25 27 31 / 0.98);
|
||||
box-shadow: 0 1rem 3rem rgb(0 0 0 / 0.42);
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.simulation-viewport__settings-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.simulation-viewport__settings-head > div,
|
||||
.simulation-viewport__settings-switches {
|
||||
display: grid;
|
||||
gap: 0.32rem;
|
||||
}
|
||||
|
||||
.simulation-viewport__settings strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.simulation-viewport__settings span,
|
||||
.simulation-viewport__settings p {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.56rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.simulation-viewport__settings p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.simulation-viewport__settings-switches {
|
||||
gap: 0.55rem;
|
||||
border-radius: 0.72rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.65rem;
|
||||
}
|
||||
|
||||
.simulation-viewport__toolbar-balance {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.simulation-viewport__toolbar > div:first-child > span:last-child,
|
||||
.simulation-viewport__notice > span:last-child {
|
||||
color: var(--nodedc-text-muted);
|
||||
@@ -364,14 +435,24 @@
|
||||
|
||||
.simulation-viewport__stage {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 30rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.simulation-viewport__stage canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.simulation-viewport__stage canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.simulation-viewport__stage canvas:focus-visible {
|
||||
@@ -407,6 +488,21 @@
|
||||
padding: 0.48rem 0.65rem;
|
||||
}
|
||||
|
||||
@container (max-width: 68rem) {
|
||||
.simulation-viewport__toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.simulation-viewport__controls {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.simulation-viewport__toolbar-balance {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.simulation-workspace__processing {
|
||||
display: flex;
|
||||
min-height: 18rem;
|
||||
|
||||
@@ -45,6 +45,7 @@ import { M47ReferenceGraphResultView } from "./M47ReferenceGraphResult";
|
||||
import { M48ObjectCentricQualityResultView } from "./M48ObjectCentricQualityResult";
|
||||
import { M48SmallStaticPassageRegressionResultView } from "./M48SmallStaticPassageRegressionResult";
|
||||
import { M48StaticOccupancyQualificationResultView } from "./M48StaticOccupancyQualificationResult";
|
||||
import { M48R3StaticOccupancyShadowResultView } from "./M48R3StaticOccupancyShadowResult";
|
||||
import { M48SFixedClassDetectorResultView } from "./M48SFixedClassDetectorResult";
|
||||
import { M48TRiskQualityResultView } from "./M48TRiskQualityResult";
|
||||
|
||||
@@ -98,6 +99,9 @@ export function AdvancedLaboratoryResult({
|
||||
if (workId === "m48-static-occupancy-qualification" && results.m48StaticOccupancy) {
|
||||
return <M48StaticOccupancyQualificationResultView rigLabel={rigLabel} result={results.m48StaticOccupancy} />;
|
||||
}
|
||||
if (workId === "m48r3-static-occupancy-shadow" && results.m48r3StaticOccupancy) {
|
||||
return <M48R3StaticOccupancyShadowResultView rigLabel={rigLabel} result={results.m48r3StaticOccupancy} />;
|
||||
}
|
||||
if (workId === "m48s-fixed-class-detector" && results.m48s) {
|
||||
return <M48SFixedClassDetectorResultView rigLabel={rigLabel} result={results.m48s} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Icon } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
fetchM48R3StaticOccupancyCases,
|
||||
type M48R3StaticOccupancyCase,
|
||||
type M48R3StaticOccupancyShadowResult,
|
||||
} from "../../core/laboratory/m48r3StaticOccupancyShadow";
|
||||
import {
|
||||
M4ReplayThreatVisual,
|
||||
type M4ReplayThreatReviewAnchor,
|
||||
} from "./M4ReplayThreatVisual";
|
||||
|
||||
function message(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "M4.8R3 timeline недоступен.";
|
||||
}
|
||||
|
||||
export function M48R3StaticOccupancyShadowEvidence({
|
||||
result,
|
||||
}: {
|
||||
result: M48R3StaticOccupancyShadowResult;
|
||||
}) {
|
||||
const [cases, setCases] = useState<readonly M48R3StaticOccupancyCase[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void fetchM48R3StaticOccupancyCases(result.resultId, { signal: controller.signal })
|
||||
.then((nextCases) => {
|
||||
if (!controller.signal.aborted) setCases(nextCases);
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) setError(message(caught));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [result.resultId]);
|
||||
|
||||
const reviewAnchors = useMemo<readonly M4ReplayThreatReviewAnchor[]>(() => (
|
||||
cases.map((item) => ({
|
||||
id: item.anchorId,
|
||||
sourceSequence: item.sourceSequence,
|
||||
extentXyxyNormalized: item.extentXyxy,
|
||||
matchedAtThreshold: item.matched,
|
||||
}))
|
||||
), [cases]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Открываем полный M4.8R3 Worker timeline</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="l3-visual-audit__state" role="alert">
|
||||
<Icon name="alert" size={18} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<M4ReplayThreatVisual
|
||||
resultId={result.resultId}
|
||||
reviewAnchors={reviewAnchors}
|
||||
showReviewAnchorBoxes={false}
|
||||
reviewLabel="Контрольные кадры M4.8R3 · без ручных рамок"
|
||||
timelineEndpointRoot="/api/v1/laboratory/m48r3/static-occupancy"
|
||||
evidenceLabel="M4.8R3"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { M48R3StaticOccupancyShadowResult } from "../../core/laboratory/m48r3StaticOccupancyShadow";
|
||||
import { M48R3StaticOccupancyShadowEvidence } from "./M48R3StaticOccupancyShadowEvidence";
|
||||
|
||||
function number(value: number, digits = 2): string {
|
||||
return value.toLocaleString("ru-RU", { maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
function percent(value: number): string {
|
||||
return `${number(value * 100, 1)}%`;
|
||||
}
|
||||
|
||||
export function M48R3StaticOccupancyShadowResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: M48R3StaticOccupancyShadowResult;
|
||||
}) {
|
||||
const candidate = result.metrics.performance.candidate;
|
||||
const occupancy = result.metrics.occupancy;
|
||||
const separation = result.metrics.anchors.separation;
|
||||
const separated = separation.every((item) => item.passed);
|
||||
const status = result.accepted
|
||||
? "Полный Worker shadow принят: realtime и раздельные препятствия сохранены"
|
||||
: "Worker shadow не прошёл один или несколько предобъявленных gate";
|
||||
const separatedLabel = separation.length
|
||||
? separation.map((item) => `${item.observedComponents}/${item.expectedMinimumComponents}`).join(" · ")
|
||||
: "—";
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="M4.8R3 · full Worker static occupancy shadow"
|
||||
description="Полный 4 489-кадровый прогон проверяет additive low-step occupied-only слой внутри штатного graph pipeline. Камера и RF-DETR не получают дополнительного inference; ручные прямоугольники используются только для перехода к контрольным кадрам и не рисуются как системный результат."
|
||||
status={status}
|
||||
statusTone={result.accepted ? "success" : "warning"}
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} RIGHT · VIDEO/CAMERA/3D/PLAN · LOW-STEP provenance` },
|
||||
{ label: "Профиль", value: `${result.profile.id} · minimum points ${result.profile.minimumPoints}` },
|
||||
{ label: "Прогон", value: `${result.metrics.frames.candidateDelivered}/${result.metrics.frames.expected} · immutable ${result.resultId}` },
|
||||
{ label: "Нагрузка", value: `12 Hz source-paced · ${number(candidate.fps, 3)} effective FPS · +0 inference` },
|
||||
{ label: "Authority", value: "REPLAY-SIMULATED · production/navigation/actuation OFF" },
|
||||
]}
|
||||
brief={{
|
||||
question: "Можно ли добавить геометрическое обнаружение низких статических препятствий, не разрушив realtime и не склеив отдельные столбики/шары в один объект?",
|
||||
approach: `Кандидат сравнен покадрово с native baseline на всех ${result.metrics.frames.expected} кадрах. Проверены latency, FPS, рост occupancy, capacity drops, отсутствие потерянных baseline-ячеек и отдельные компоненты на кадре 1856.`,
|
||||
principalResult: `${number(candidate.fps, 3)} FPS; world-state p95 ${number(candidate.worldP95Ms, 2)} мс; geometry p95/p99 ${number(candidate.geometryP95Ms, 2)}/${number(candidate.geometryP99Ms, 2)} мс; разделение ${separatedLabel}.`,
|
||||
limitation: "Это воспроизводимый Worker shadow, а не доказательство физической проходимости. Просвет между компонентами сохраняется как геометрия, но допустимость проезда зависит от будущего габарита шасси и отдельного free-space контракта.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "deterministic",
|
||||
pipelineId: "m48r3-native-plus-low-step-reference-graph/v1",
|
||||
components: [
|
||||
{ kind: "source", name: "native reference graph baseline", version: "M4.7/M4.8R2", role: "immutable occupied/unknown baseline", identitySha256: null },
|
||||
{ kind: "algorithm", name: "additive low-step occupied-only", version: "v1", role: "CPU geometry; never clearing; no semantic class", identitySha256: result.profile.sha256 },
|
||||
{ kind: "runtime", name: "full source-paced Worker shadow", version: "4 489 frames", role: "predeclared realtime, growth, separation and safety gates", identitySha256: result.resultId.split("-").at(-1) ?? null },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="M4.8R3 VISUAL EVIDENCE · SYSTEM COMPONENTS"
|
||||
title="Полный timeline; LOW-STEP показывает добавленные системой компоненты, ручные рамки скрыты"
|
||||
kind="recorded-replay"
|
||||
resizable
|
||||
>
|
||||
<M48R3StaticOccupancyShadowEvidence result={result} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="Что доказал прогон"
|
||||
status={status}
|
||||
statusTone={result.accepted ? "success" : "warning"}
|
||||
metrics={[
|
||||
{ label: "Realtime", value: `${number(candidate.fps, 3)} FPS`, hint: `p95 ${number(candidate.worldP95Ms, 2)} мс · gate ≥11,5 FPS / ≤60 мс` },
|
||||
{ label: "Geometry", value: `${number(candidate.geometryP95Ms, 2)} / ${number(candidate.geometryP99Ms, 2)} мс`, hint: "p95 / p99 · gates 9 / 16 мс" },
|
||||
{ label: "Occupancy delta", value: `+${occupancy.addedCellTotal.toLocaleString("ru-RU")}`, hint: `${percent(occupancy.meanCellGrowthFraction)} cells · ${percent(occupancy.meanComponentGrowthFraction)} components` },
|
||||
{ label: "Кадр 1856", value: separated ? `раздельно · ${separatedLabel}` : `не принят · ${separatedLabel}`, hint: "два столбика и две полусферы проверяются отдельными component gates" },
|
||||
{ label: "Потери / false free", value: `${occupancy.lostCellTotal} / ${result.metrics.falseFreeCount}`, hint: `capacity drops ${result.metrics.capacityDropCount}` },
|
||||
]}
|
||||
conclusion={{
|
||||
proved: `Система сама добавила ${result.metrics.provider.additiveObservationCount.toLocaleString("ru-RU")} low-step observations на ${result.metrics.provider.framesWithAdditions.toLocaleString("ru-RU")} кадрах, сохранила baseline без потерь и выдержала полный realtime shadow.`,
|
||||
notProved: "Не доказаны физический clearance, planner-authoritative free space, независимые precision/recall и безопасность движения на реальном шасси.",
|
||||
decision: result.accepted
|
||||
? "Worker-кандидат принят в ограниченном replay-shadow контуре. Следующий шаг — отдельное решение о cutover и регрессия на новых сценах; ручная разметка не становится runtime-зависимостью."
|
||||
: "Cutover запрещён. Исправить провалившийся gate и повторить полный immutable shadow без ослабления порогов.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -110,12 +110,16 @@ export function M4ReplayThreatVisual({
|
||||
resultId,
|
||||
semantic,
|
||||
reviewAnchors = EMPTY_REVIEW_ANCHORS,
|
||||
showReviewAnchorBoxes = true,
|
||||
reviewLabel = "Контрольные примеры M4.8R1",
|
||||
timelineEndpointRoot,
|
||||
evidenceLabel = "M4.6",
|
||||
}: {
|
||||
resultId: string;
|
||||
semantic?: M4ReplayThreatSemanticLayer;
|
||||
reviewAnchors?: readonly M4ReplayThreatReviewAnchor[];
|
||||
showReviewAnchorBoxes?: boolean;
|
||||
reviewLabel?: string;
|
||||
timelineEndpointRoot?: string;
|
||||
evidenceLabel?: string;
|
||||
}) {
|
||||
@@ -124,6 +128,7 @@ export function M4ReplayThreatVisual({
|
||||
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
|
||||
const [showLocalSurface, setShowLocalSurface] = useState(true);
|
||||
const [showRollingMap, setShowRollingMap] = useState(true);
|
||||
const [showLowStep, setShowLowStep] = useState(true);
|
||||
const [showMediaSemantic, setShowMediaSemantic] = useState(true);
|
||||
const [showSpatialSemantic, setShowSpatialSemantic] = useState(true);
|
||||
const [showMediaPoints, setShowMediaPoints] = useState(false);
|
||||
@@ -262,7 +267,7 @@ export function M4ReplayThreatVisual({
|
||||
}, [metadata.timeline, resultId, reviewAnchorIdentity, reviewAnchors, seekPlayback, setPlaybackPlaying]);
|
||||
const reviewAnchorBoxes = useMemo<readonly RecordedEvidenceBox[]>(() => {
|
||||
const timeline = metadata.timeline;
|
||||
if (!frame || !timeline) return [];
|
||||
if (!frame || !timeline || !showReviewAnchorBoxes) return [];
|
||||
return reviewAnchors
|
||||
.filter((anchor) => anchor.sourceSequence === frame.sequence)
|
||||
.map((anchor) => {
|
||||
@@ -281,7 +286,7 @@ export function M4ReplayThreatVisual({
|
||||
dashed: true,
|
||||
};
|
||||
});
|
||||
}, [frame, metadata.timeline, reviewAnchors]);
|
||||
}, [frame, metadata.timeline, reviewAnchors, showReviewAnchorBoxes]);
|
||||
const activeBoxes = useMemo(
|
||||
() => [...boxes(frame?.cameraProposals ?? []), ...reviewAnchorBoxes],
|
||||
[frame, reviewAnchorBoxes],
|
||||
@@ -345,6 +350,7 @@ export function M4ReplayThreatVisual({
|
||||
state: obstacle.state,
|
||||
centroidBodyXyzM: obstacle.centroidBodyXyzM,
|
||||
cellCentersBodyXyzM: obstacle.cellCentersBodyXyzM,
|
||||
occupancySource: obstacle.occupancySource,
|
||||
})) ?? [], [spatialFrame]);
|
||||
const currentIncrementObstacles = spatialFrame?.metricObstacles.filter(
|
||||
(item) => item.state === "current",
|
||||
@@ -352,6 +358,9 @@ export function M4ReplayThreatVisual({
|
||||
const rollingMapObstacles = spatialFrame?.metricObstacles.filter(
|
||||
(item) => item.state === "retained",
|
||||
) ?? [];
|
||||
const lowStepObstacles = spatialFrame?.metricObstacles.filter(
|
||||
(item) => item.occupancySource !== "baseline",
|
||||
) ?? [];
|
||||
const nearest = spatialFrame?.metricObstacles
|
||||
.map((item) => item.assessment.closestApproachM)
|
||||
.filter((value): value is number => value !== null)
|
||||
@@ -513,6 +522,18 @@ export function M4ReplayThreatVisual({
|
||||
>
|
||||
ROLLING
|
||||
</Button>
|
||||
{metadata.timeline?.occupancyProvenanceDelivery ? (
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant={showLowStep ? "primary" : "secondary"}
|
||||
aria-pressed={showLowStep}
|
||||
title="Добавочные occupied-only компоненты low-step; без ручных рамок"
|
||||
onClick={() => setShowLowStep((visible) => !visible)}
|
||||
>
|
||||
LOW-STEP
|
||||
</Button>
|
||||
) : null}
|
||||
{semantic ? (
|
||||
<Button
|
||||
size="compact"
|
||||
@@ -567,7 +588,7 @@ export function M4ReplayThreatVisual({
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
<Select
|
||||
label="Контрольные примеры M4.8R1"
|
||||
label={reviewLabel}
|
||||
value={String(selectedReviewAnchorIndex)}
|
||||
options={reviewAnchors.map((anchor, index) => ({
|
||||
value: String(index),
|
||||
@@ -618,6 +639,9 @@ export function M4ReplayThreatVisual({
|
||||
<span>Spatial evidence</span>
|
||||
<strong>
|
||||
{currentIncrementObstacles.length} current · {rollingMapObstacles.length} rolling
|
||||
{metadata.timeline.occupancyProvenanceDelivery
|
||||
? ` · ${lowStepObstacles.length} low-step`
|
||||
: ""}
|
||||
</strong>
|
||||
<small>
|
||||
{spatialFrame
|
||||
@@ -758,6 +782,7 @@ export function M4ReplayThreatVisual({
|
||||
showCurrentIncrement={showCurrentIncrement}
|
||||
showLocalSurface={showLocalSurface}
|
||||
showRollingMap={showRollingMap}
|
||||
showLowStep={showLowStep}
|
||||
pointSemanticClassIds={alignedSemanticPointIds}
|
||||
semanticClasses={semanticClasses}
|
||||
semanticPalette={semanticPalette}
|
||||
|
||||
@@ -84,6 +84,13 @@ const KNOWN_WORKS: Readonly<Record<Exclude<LaboratoryWorkId, `session:${string}`
|
||||
experimentName: "M4.8 · conservative static occupancy qualification",
|
||||
variantName: "M4.8R2 · current/rolling + low-step occupied-only candidate",
|
||||
},
|
||||
"m48r3-static-occupancy-shadow": {
|
||||
profileId: "rig-dual-evidence-virtual-corridor-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Canonical Reference Graph`,
|
||||
experimentId: "m48r3-static-occupancy-shadow",
|
||||
experimentName: "M4.8R3 · full Worker static occupancy shadow",
|
||||
variantName: "M4.8R3 · 4 489 frames · additive low-step occupied-only",
|
||||
},
|
||||
"m48s-fixed-class-detector": {
|
||||
profileId: "rig-ravnoves-perception-gate-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · RAVNOVES00 perception gate`,
|
||||
|
||||
@@ -22,6 +22,7 @@ function mergeResults(
|
||||
m48: next.m48 ?? current.m48,
|
||||
m48SmallStatic: next.m48SmallStatic ?? current.m48SmallStatic,
|
||||
m48StaticOccupancy: next.m48StaticOccupancy ?? current.m48StaticOccupancy,
|
||||
m48r3StaticOccupancy: next.m48r3StaticOccupancy ?? current.m48r3StaticOccupancy,
|
||||
m48s: next.m48s ?? current.m48s,
|
||||
m48t: next.m48t ?? current.m48t,
|
||||
m4Threat: next.m4Threat ?? current.m4Threat,
|
||||
@@ -122,6 +123,7 @@ export function useAdvancedLaboratoryCatalog({
|
||||
"m48-object-centric-quality",
|
||||
"m48-small-static-passage-regression",
|
||||
"m48-static-occupancy-qualification",
|
||||
"m48r3-static-occupancy-shadow",
|
||||
].includes(selectedWorkId)
|
||||
&& !indexedResultId
|
||||
) return;
|
||||
|
||||
@@ -63,11 +63,29 @@ test("PlayCanvas owns the realtime scene graph without an iframe or React entity
|
||||
assert.match(packageDocument, /"playcanvas": "2\.21\.4"/);
|
||||
assert.doesNotMatch(packageDocument, /@playcanvas\/react/);
|
||||
assert.match(runtime, /new Application\(canvas/);
|
||||
assert.match(runtime, /setCanvasFillMode\(FILLMODE_NONE/);
|
||||
assert.match(runtime, /setCanvasResolution\(RESOLUTION_AUTO\)/);
|
||||
assert.match(runtime, /new Asset\([^,]+, "gsplat"/);
|
||||
assert.match(runtime, /camera\.script\?\.create\(CameraControls/);
|
||||
assert.match(runtime, /const HOME_POSITION = new Vec3\(0, 1, 0\)/);
|
||||
assert.match(runtime, /const HOME_FOCUS = new Vec3\(1, 1, 0\)/);
|
||||
assert.match(runtime, /maximum: \{[^}]*lodRangeMin: 0, lodRangeMax: 0, pixelRatio: 2/);
|
||||
assert.match(runtime, /frame\.mouse\[0\] \*= -1/);
|
||||
assert.match(runtime, /new Asset\([\s\S]*"container"/);
|
||||
assert.match(runtime, /instantiateRenderEntity/);
|
||||
assert.match(runtime, /applyWorldTransform/);
|
||||
assert.match(runtime, /setLayerWorldTransform/);
|
||||
assert.match(runtime, /PLAYCANVAS_X_180_TRANSFORM/);
|
||||
assert.match(runtime, /ensureCollision/);
|
||||
assert.match(runtime, /app\.root\.addChild/);
|
||||
assert.match(runtime, /dispose\(\)/);
|
||||
assert.doesNotMatch(`${runtime}\n${viewport}`, /iframe|<GSplat/);
|
||||
assert.match(viewport, /Collision недоступен/);
|
||||
assert.match(viewport, /Для этой сборки collision GLB не был запрошен/);
|
||||
assert.match(viewport, /<Select[\s\S]*Качество Gaussian-сцены/);
|
||||
assert.match(viewport, /Максимум/);
|
||||
assert.match(viewport, /runtimeRef\.current\?\.home\(\)/);
|
||||
assert.match(viewport, /Настройки системы координат/);
|
||||
assert.match(viewport, /Инверсия визуального слоя/);
|
||||
assert.match(viewport, /Инверсия collision-слоя/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schema_version": "missioncore.laboratory-evidence-definition/v1",
|
||||
"work_id": "m48r3-static-occupancy-shadow",
|
||||
"evidence": {
|
||||
"runtime_relative_root": "m48/static-occupancy-shadow-results",
|
||||
"result_id_prefix": "m48r3-static-occupancy-shadow",
|
||||
"document_name": "manifest.json",
|
||||
"schema_version": "missioncore.m48r3-static-occupancy-shadow-result/v1"
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,7 @@
|
||||
}
|
||||
],
|
||||
"legacy_work_ids": [
|
||||
"m48r3-static-occupancy-shadow",
|
||||
"m47-reference-graph-shadow",
|
||||
"e31-source-binding",
|
||||
"e32-track-geometry",
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
"voxel_size_m": 0.45,
|
||||
"neighbor_radius_cells": 1,
|
||||
"minimum_points": 5,
|
||||
"sparse_persistence_minimum_points": 2,
|
||||
"sparse_persistence_window_frames": 6,
|
||||
"sparse_persistence_minimum_hits": 6,
|
||||
"sparse_persistence_maximum_range_m": 8.0,
|
||||
"minimum_voxels": 1,
|
||||
"local_radius_m": 10.0,
|
||||
"maximum_candidate_points_per_frame": 768,
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"provider_id": "ravnoves00-additive-low-step-geometry/v1",
|
||||
"version": "0.1.0",
|
||||
"revision": "m48r3-ravnoves00-additive-low-step/v1",
|
||||
"sha256": "0d46dcb28542902849ee31de4c4594ef90407834d9e5551474fc4191cadbf1a9"
|
||||
"sha256": "00fc197ee7200e2f0447b6bec7cc2f21b3c2970d7dd9414cc41e6787d712fee5"
|
||||
},
|
||||
{
|
||||
"role": "temporal",
|
||||
|
||||
@@ -1504,6 +1504,42 @@ anchors onto its immutable timeline; no third perception instrument exists.
|
||||
Navigation, commands, actuation, physical clearance and collision-safety
|
||||
authority remain false.
|
||||
|
||||
### 2026-08-26 — M4.8R3 bounded static-occupancy Worker shadow accepted
|
||||
|
||||
M4.8R3 closes the bounded Worker integration requested by M4.8R2. The sealed
|
||||
result is
|
||||
`m48r3-static-occupancy-shadow-d1577870b098bcd0b67cc51798234f224b348ad3954ead72f3ce6df414875a29`.
|
||||
It composes the accepted camera/geometry graph with an occupied-only low-step
|
||||
provider; RF-DETR, the native `800×600` fisheye raster and the semantic risk
|
||||
policy remain unchanged. Weak two-to-four-point geometry is published only
|
||||
when the exact `0.45 m` voxel is present in all six causal frames and its
|
||||
current range is at most `8 m`. Strong five-point components remain immediate.
|
||||
The provider never uses a semantic class, clears a cell, infers free space or
|
||||
runs another neural-network pass.
|
||||
|
||||
The isolated Worker 006 replay delivered all `4,489/4,489` frames at
|
||||
`11.79902 FPS`. World-state completion p95 was `56.74324 ms`; geometry p95/p99
|
||||
were `8.420913/13.732692 ms`; GPU utilization p95/maximum were `54%/58%` and
|
||||
maximum used GPU memory was `9,743 MiB`. The additive provider contributed a
|
||||
mean `0.587893 ms` per frame. Relative to the frozen native baseline, FPS
|
||||
regression was `0.3745552%` and world-state p95 increased by `8.802461 ms`,
|
||||
both inside the predeclared envelope.
|
||||
|
||||
All `9/9` critical-near assisted anchors are occupied in the delivered graph.
|
||||
At display frame `1856`, the two thin posts remain independent (`5` observed
|
||||
components in their review extent) and the two concrete hemispheres remain
|
||||
independent (`6` observed components); the system does not inherit the
|
||||
operator's old paired boxes. The ledger adds `613,146` occupied cells, loses
|
||||
zero baseline cells, makes zero false-free claims and records zero capacity
|
||||
drops. The two `8–12 m` approach anchors intentionally remain unknown because
|
||||
the sparse-persistence policy is bounded to the critical `0–8 m` band.
|
||||
|
||||
This accepts the reproducible bounded Worker shadow and its LAB evidence, not a
|
||||
production navigation cutover. The result remains replay-simulated;
|
||||
physical-live authority, commands, actuation, planner-authoritative free space
|
||||
and collision-safety acceptance are false. The next milestone boundary is
|
||||
M4.9 recorded-realtime release-candidate validation using this frozen graph.
|
||||
|
||||
## Implementation order
|
||||
|
||||
The implementation sequence is intentionally strict:
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Seal a complete M4.8R3 Worker shadow against the accepted native baseline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from k1link.laboratory.m48r3_static_occupancy_shadow import (
|
||||
M48R3StaticOccupancyShadowError,
|
||||
build_m48r3_static_occupancy_shadow,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--repository-root", type=Path, required=True)
|
||||
parser.add_argument("--profile", type=Path, required=True)
|
||||
parser.add_argument("--baseline-result", type=Path, required=True)
|
||||
parser.add_argument("--baseline-frames", type=Path, required=True)
|
||||
parser.add_argument("--candidate-result", type=Path, required=True)
|
||||
parser.add_argument("--candidate-frames", type=Path, required=True)
|
||||
parser.add_argument("--m48r2-result-root", type=Path, required=True)
|
||||
parser.add_argument("--output-root", type=Path, required=True)
|
||||
arguments = parser.parse_args()
|
||||
try:
|
||||
result = build_m48r3_static_occupancy_shadow(
|
||||
repository_root=arguments.repository_root,
|
||||
profile_path=arguments.profile,
|
||||
baseline_result_path=arguments.baseline_result,
|
||||
baseline_frames_path=arguments.baseline_frames,
|
||||
candidate_result_path=arguments.candidate_result,
|
||||
candidate_frames_path=arguments.candidate_frames,
|
||||
m48r2_result_root=arguments.m48r2_result_root,
|
||||
output_root=arguments.output_root,
|
||||
)
|
||||
except (M48R3StaticOccupancyShadowError, OSError, ValueError) as exc:
|
||||
parser.error(str(exc))
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"result_id": result.result_id,
|
||||
"result_root": str(result.result_root),
|
||||
"accepted": result.manifest["accepted"],
|
||||
"gates": result.report["gates"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -113,10 +113,10 @@ $expectedConfigs = [ordered]@{
|
||||
if ($AdditiveLowStep) {
|
||||
$expectedConfigs.Remove("m48n-rf-detr-native-reference-graph-shadow-v0.json")
|
||||
$expectedConfigs["m48r3-native-low-step-reference-graph-shadow-v1.json"] = (
|
||||
"5f5832a0a0c1879374b166071efed02de14aeda815046da5711d6f4053eb9af4"
|
||||
"e2e307347265076908c4ec1b7da75028998490dfa74ba64f12939e233406d9d0"
|
||||
)
|
||||
$expectedConfigs["m48r3-additive-low-step-occupancy-v1.json"] = (
|
||||
"0d46dcb28542902849ee31de4c4594ef90407834d9e5551474fc4191cadbf1a9"
|
||||
"00fc197ee7200e2f0447b6bec7cc2f21b3c2970d7dd9414cc41e6787d712fee5"
|
||||
)
|
||||
}
|
||||
foreach ($entry in $expectedConfigs.GetEnumerator()) {
|
||||
|
||||
@@ -0,0 +1,845 @@
|
||||
"""Seal the full M4.8R3 occupied-only Worker shadow and its bounded diff."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
|
||||
import numpy as np
|
||||
|
||||
from k1link.laboratory.m48_static_occupancy_qualification import (
|
||||
M48StaticOccupancyQualificationError,
|
||||
read_m48_static_occupancy_qualification,
|
||||
)
|
||||
from k1link.perception.geometry import RecordedGeometryStore
|
||||
from k1link.perception.geometry_math import project_map_points_kb4
|
||||
from k1link.perception.m48_low_step_occupancy import M48_LOW_STEP_PROFILE_SCHEMA
|
||||
|
||||
M48R3_SHADOW_RESULT_SCHEMA: Final = (
|
||||
"missioncore.m48r3-static-occupancy-shadow-result/v1"
|
||||
)
|
||||
M48R3_SHADOW_REPORT_SCHEMA: Final = (
|
||||
"missioncore.m48r3-static-occupancy-shadow-report/v1"
|
||||
)
|
||||
M48R3_SHADOW_CASE_SCHEMA: Final = (
|
||||
"missioncore.m48r3-static-occupancy-shadow-case/v1"
|
||||
)
|
||||
M48R3_SHADOW_DIFF_SCHEMA: Final = (
|
||||
"missioncore.m48r3-static-occupancy-frame-diff/v1"
|
||||
)
|
||||
M48R3_SHADOW_PREFIX: Final = "m48r3-static-occupancy-shadow-"
|
||||
FRAME_EVIDENCE_SCHEMA: Final = "missioncore.m48s-reference-graph-frame-evidence/v1"
|
||||
WORKER_RESULT_SCHEMA: Final = "missioncore.m48s-reference-graph-shadow-load/v5"
|
||||
EXPECTED_FRAMES: Final = 4_489
|
||||
VOXEL_SIZE_M: Final = 0.45
|
||||
_AUTHORITY: Final = {
|
||||
"mode": "replay-simulated",
|
||||
"physical_live": False,
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
|
||||
Cell = tuple[int, int, int]
|
||||
|
||||
|
||||
class M48R3StaticOccupancyShadowError(RuntimeError):
|
||||
"""The M4.8R3 Worker evidence or immutable result is invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class M48R3StaticOccupancyShadowResult:
|
||||
result_id: str
|
||||
result_root: Path
|
||||
manifest: dict[str, Any]
|
||||
report: dict[str, Any]
|
||||
cases: tuple[dict[str, Any], ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LedgerComparison:
|
||||
frame_count: int
|
||||
baseline_cell_total: int
|
||||
candidate_cell_total: int
|
||||
added_cell_total: int
|
||||
lost_cell_total: int
|
||||
baseline_component_total: int
|
||||
candidate_component_total: int
|
||||
false_free_count: int
|
||||
maximum_added_cells_per_frame: int
|
||||
maximum_candidate_components_per_frame: int
|
||||
mean_cell_growth_fraction: float
|
||||
mean_component_growth_fraction: float
|
||||
diff_rows: tuple[dict[str, Any], ...]
|
||||
selected_candidate_rows: dict[int, dict[str, Any]]
|
||||
|
||||
|
||||
def build_m48r3_static_occupancy_shadow(
|
||||
*,
|
||||
repository_root: Path,
|
||||
profile_path: Path,
|
||||
baseline_result_path: Path,
|
||||
baseline_frames_path: Path,
|
||||
candidate_result_path: Path,
|
||||
candidate_frames_path: Path,
|
||||
m48r2_result_root: Path,
|
||||
output_root: Path,
|
||||
) -> M48R3StaticOccupancyShadowResult:
|
||||
"""Compare full ledgers and publish one append-only M4.8R3 result."""
|
||||
|
||||
repository = repository_root.resolve(strict=True)
|
||||
profile_bytes = profile_path.resolve(strict=True).read_bytes()
|
||||
profile = _object(json.loads(profile_bytes), "M4.8R3 profile")
|
||||
if profile.get("schema_version") != M48_LOW_STEP_PROFILE_SCHEMA:
|
||||
raise M48R3StaticOccupancyShadowError("M4.8R3 profile changed")
|
||||
profile_sha256 = hashlib.sha256(profile_bytes).hexdigest()
|
||||
acceptance = _object(profile.get("acceptance"), "M4.8R3 acceptance")
|
||||
source = _object(profile.get("source"), "M4.8R3 source")
|
||||
if source.get("frame_count") != EXPECTED_FRAMES:
|
||||
raise M48R3StaticOccupancyShadowError("M4.8R3 frame contract changed")
|
||||
|
||||
baseline = _read_worker_result(baseline_result_path)
|
||||
candidate = _read_worker_result(candidate_result_path)
|
||||
_validate_worker_binding(
|
||||
baseline,
|
||||
baseline_frames_path,
|
||||
expected_frames=EXPECTED_FRAMES,
|
||||
additive_profile_sha256=None,
|
||||
)
|
||||
_validate_worker_binding(
|
||||
candidate,
|
||||
candidate_frames_path,
|
||||
expected_frames=EXPECTED_FRAMES,
|
||||
additive_profile_sha256=profile_sha256,
|
||||
)
|
||||
try:
|
||||
m48r2 = read_m48_static_occupancy_qualification(m48r2_result_root)
|
||||
except M48StaticOccupancyQualificationError as exc:
|
||||
raise M48R3StaticOccupancyShadowError("M4.8R2 binding changed") from exc
|
||||
if (
|
||||
m48r2.result_id != source.get("m48r2_result_id")
|
||||
or _file_sha256(m48r2.result_root / "cases.jsonl")
|
||||
!= source.get("m48r2_cases_sha256")
|
||||
):
|
||||
raise M48R3StaticOccupancyShadowError("M4.8R2 source changed")
|
||||
|
||||
selected_sequences = {int(row["sequence"]) - 1 for row in m48r2.cases}
|
||||
comparison = compare_m48r3_frame_ledgers(
|
||||
baseline_frames_path,
|
||||
candidate_frames_path,
|
||||
selected_sequences=selected_sequences,
|
||||
expected_frames=EXPECTED_FRAMES,
|
||||
)
|
||||
store = RecordedGeometryStore.from_repository(repository)
|
||||
cases = _evaluate_cases(
|
||||
m48r2.cases,
|
||||
comparison.selected_candidate_rows,
|
||||
store,
|
||||
)
|
||||
separation = _evaluate_separation(profile, cases)
|
||||
baseline_metrics = _worker_metrics(baseline)
|
||||
candidate_metrics = _worker_metrics(candidate)
|
||||
provider = _provider_metrics(candidate)
|
||||
critical = [row for row in cases if row["distance_band"] == "critical-near"]
|
||||
near_recall = _rate(critical, "matched")
|
||||
canonical_recall = (
|
||||
1.0
|
||||
if comparison.lost_cell_total == 0
|
||||
and m48r2.report["metrics"]["canonical_engineering_recall"] == 1.0
|
||||
else 0.0
|
||||
)
|
||||
capacity_drop_count = (
|
||||
int(provider["geometry_failed_frames"])
|
||||
+ int(provider["temporal_failed_frames"])
|
||||
+ int(provider["rolling_capacity_evicted_cells"])
|
||||
)
|
||||
fps_regression = max(
|
||||
0.0,
|
||||
(baseline_metrics["fps"] - candidate_metrics["fps"])
|
||||
/ baseline_metrics["fps"],
|
||||
)
|
||||
world_p95_delta = (
|
||||
candidate_metrics["world_p95_ms"] - baseline_metrics["world_p95_ms"]
|
||||
)
|
||||
gates = {
|
||||
"complete_frame_accounting": candidate_metrics["delivered_frames"]
|
||||
== EXPECTED_FRAMES,
|
||||
"minimum_effective_world_state_fps": candidate_metrics["fps"]
|
||||
>= float(acceptance["minimum_effective_world_state_fps"]),
|
||||
"maximum_world_state_completion_p95_ms": candidate_metrics[
|
||||
"world_p95_ms"
|
||||
]
|
||||
<= float(acceptance["maximum_world_state_completion_p95_ms"]),
|
||||
"maximum_geometry_stage_p95_ms": candidate_metrics["geometry_p95_ms"]
|
||||
<= float(acceptance["maximum_geometry_stage_p95_ms"]),
|
||||
"maximum_geometry_stage_p99_ms": candidate_metrics["geometry_p99_ms"]
|
||||
<= float(acceptance["maximum_geometry_stage_p99_ms"]),
|
||||
"maximum_fps_regression_fraction": fps_regression
|
||||
<= float(acceptance["maximum_fps_regression_fraction_vs_native_baseline"]),
|
||||
"maximum_world_state_p95_delta_ms": world_p95_delta
|
||||
<= float(acceptance["maximum_world_state_p95_delta_ms_vs_native_baseline"]),
|
||||
"maximum_component_mean_growth_fraction": comparison.mean_component_growth_fraction
|
||||
<= float(acceptance["maximum_additive_component_mean_growth_fraction"]),
|
||||
"maximum_cell_mean_growth_fraction": comparison.mean_cell_growth_fraction
|
||||
<= float(acceptance["maximum_additive_cell_mean_growth_fraction"]),
|
||||
"zero_capacity_drops": capacity_drop_count
|
||||
<= int(acceptance["maximum_capacity_drop_count"]),
|
||||
"zero_baseline_cell_loss": comparison.lost_cell_total == 0,
|
||||
"critical_near_recall": near_recall
|
||||
>= float(acceptance["minimum_critical_near_recall"]),
|
||||
"canonical_engineering_recall": canonical_recall
|
||||
>= float(acceptance["minimum_canonical_engineering_recall"]),
|
||||
"zero_false_free": comparison.false_free_count
|
||||
<= int(acceptance["maximum_false_free_count"]),
|
||||
"separation_expectations": all(row["passed"] for row in separation),
|
||||
}
|
||||
accepted = all(gates.values())
|
||||
producer_sha256 = _file_sha256(Path(__file__).resolve())
|
||||
candidate_frame_sha256 = _file_sha256(candidate_frames_path)
|
||||
baseline_frame_sha256 = _file_sha256(baseline_frames_path)
|
||||
created_at = _worker_completed_at(candidate)
|
||||
identity = {
|
||||
"schema_version": M48R3_SHADOW_RESULT_SCHEMA,
|
||||
"created_at_utc": created_at,
|
||||
"profile_id": profile["profile_id"],
|
||||
"profile_sha256": profile_sha256,
|
||||
"producer_sha256": producer_sha256,
|
||||
"baseline_result_sha256": _file_sha256(baseline_result_path),
|
||||
"baseline_frames_sha256": baseline_frame_sha256,
|
||||
"candidate_result_sha256": _file_sha256(candidate_result_path),
|
||||
"candidate_frames_sha256": candidate_frame_sha256,
|
||||
"m48r2_result_id": m48r2.result_id,
|
||||
"authority": dict(_AUTHORITY),
|
||||
}
|
||||
result_id = M48R3_SHADOW_PREFIX + _canonical_sha256(identity)
|
||||
metrics = {
|
||||
"frames": {
|
||||
"expected": EXPECTED_FRAMES,
|
||||
"baseline_delivered": baseline_metrics["delivered_frames"],
|
||||
"candidate_delivered": candidate_metrics["delivered_frames"],
|
||||
},
|
||||
"performance": {
|
||||
"baseline": baseline_metrics,
|
||||
"candidate": candidate_metrics,
|
||||
"fps_regression_fraction": round(fps_regression, 9),
|
||||
"world_state_p95_delta_ms": round(world_p95_delta, 6),
|
||||
},
|
||||
"occupancy": {
|
||||
"baseline_cell_total": comparison.baseline_cell_total,
|
||||
"candidate_cell_total": comparison.candidate_cell_total,
|
||||
"added_cell_total": comparison.added_cell_total,
|
||||
"lost_cell_total": comparison.lost_cell_total,
|
||||
"baseline_component_total": comparison.baseline_component_total,
|
||||
"candidate_component_total": comparison.candidate_component_total,
|
||||
"mean_cell_growth_fraction": round(
|
||||
comparison.mean_cell_growth_fraction, 9
|
||||
),
|
||||
"mean_component_growth_fraction": round(
|
||||
comparison.mean_component_growth_fraction, 9
|
||||
),
|
||||
"maximum_added_cells_per_frame": comparison.maximum_added_cells_per_frame,
|
||||
"maximum_candidate_components_per_frame": (
|
||||
comparison.maximum_candidate_components_per_frame
|
||||
),
|
||||
},
|
||||
"provider": provider,
|
||||
"assisted_anchors": {
|
||||
"count": len(cases),
|
||||
"critical_near_count": len(critical),
|
||||
"critical_near_recall": near_recall,
|
||||
"matched_count": sum(bool(row["matched"]) for row in cases),
|
||||
"canonical_engineering_recall": canonical_recall,
|
||||
"separation": separation,
|
||||
},
|
||||
"capacity_drop_count": capacity_drop_count,
|
||||
"false_free_count": comparison.false_free_count,
|
||||
}
|
||||
report = {
|
||||
"schema_version": M48R3_SHADOW_REPORT_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"accepted": accepted,
|
||||
"profile": {
|
||||
"id": profile["profile_id"],
|
||||
"sha256": profile_sha256,
|
||||
"componentization": profile["componentization"],
|
||||
"acceptance": acceptance,
|
||||
},
|
||||
"metrics": metrics,
|
||||
"gates": gates,
|
||||
"decision": {
|
||||
"state": "accepted-bounded-worker-shadow"
|
||||
if accepted
|
||||
else "rejected-bounded-worker-shadow",
|
||||
"candidate_accepted": accepted,
|
||||
"production_accepted": False,
|
||||
"next_action": "product-cutover-decision" if accepted else "reduce-load-or-coverage",
|
||||
},
|
||||
"limitations": [
|
||||
"Operator-assisted rectangles are development anchors, not independent truth.",
|
||||
"Separated occupied components do not assert passability for an unknown chassis.",
|
||||
(
|
||||
"No free-space, navigation, command, actuation or collision-safety "
|
||||
"authority is granted."
|
||||
),
|
||||
],
|
||||
"authority": dict(_AUTHORITY),
|
||||
}
|
||||
destination = output_root.resolve(strict=False) / result_id
|
||||
_publish(
|
||||
destination,
|
||||
identity=identity,
|
||||
report=report,
|
||||
cases=cases,
|
||||
diff_rows=comparison.diff_rows,
|
||||
candidate_result_path=candidate_result_path,
|
||||
candidate_frames_path=candidate_frames_path,
|
||||
)
|
||||
return read_m48r3_static_occupancy_shadow(destination)
|
||||
|
||||
|
||||
def compare_m48r3_frame_ledgers(
|
||||
baseline_frames_path: Path,
|
||||
candidate_frames_path: Path,
|
||||
*,
|
||||
selected_sequences: set[int],
|
||||
expected_frames: int,
|
||||
) -> LedgerComparison:
|
||||
"""Stream two aligned ledgers and retain only a bounded cell-diff."""
|
||||
|
||||
diffs: list[dict[str, Any]] = []
|
||||
selected: dict[int, dict[str, Any]] = {}
|
||||
baseline_cells_total = 0
|
||||
candidate_cells_total = 0
|
||||
baseline_components_total = 0
|
||||
candidate_components_total = 0
|
||||
added_total = 0
|
||||
lost_total = 0
|
||||
false_free = 0
|
||||
maximum_added = 0
|
||||
maximum_components = 0
|
||||
count = 0
|
||||
with baseline_frames_path.open("r", encoding="utf-8") as baseline_stream, (
|
||||
candidate_frames_path.open("r", encoding="utf-8")
|
||||
) as candidate_stream:
|
||||
for baseline_line, candidate_line in zip(
|
||||
baseline_stream,
|
||||
candidate_stream,
|
||||
strict=True,
|
||||
):
|
||||
baseline = _frame_row(json.loads(baseline_line))
|
||||
candidate = _frame_row(json.loads(candidate_line))
|
||||
baseline_sequence = _sequence(baseline)
|
||||
candidate_sequence = _sequence(candidate)
|
||||
if baseline_sequence != candidate_sequence or baseline_sequence != count:
|
||||
raise M48R3StaticOccupancyShadowError("frame ledgers are not aligned")
|
||||
baseline_map = _obstacle_map(baseline)
|
||||
candidate_map = _obstacle_map(candidate)
|
||||
baseline_components = _active_components(baseline_map)
|
||||
candidate_components = _active_components(candidate_map)
|
||||
baseline_cells = _cell_union(baseline_components)
|
||||
candidate_cells = _cell_union(candidate_components)
|
||||
added = candidate_cells - baseline_cells
|
||||
lost = baseline_cells - candidate_cells
|
||||
provenance: dict[str, str] = {}
|
||||
for component_id, cells in candidate_components:
|
||||
if not cells.intersection(added):
|
||||
continue
|
||||
provenance[component_id] = (
|
||||
"mixed" if cells.intersection(baseline_cells) else "additive-low-step"
|
||||
)
|
||||
diffs.append(
|
||||
{
|
||||
"schema_version": M48R3_SHADOW_DIFF_SCHEMA,
|
||||
"sequence": baseline_sequence,
|
||||
"baseline_cell_count": len(baseline_cells),
|
||||
"candidate_cell_count": len(candidate_cells),
|
||||
"added_cells": [list(cell) for cell in sorted(added)],
|
||||
"lost_cell_count": len(lost),
|
||||
"component_provenance": provenance,
|
||||
}
|
||||
)
|
||||
if baseline_sequence in selected_sequences:
|
||||
selected[baseline_sequence] = candidate
|
||||
baseline_cells_total += len(baseline_cells)
|
||||
candidate_cells_total += len(candidate_cells)
|
||||
baseline_components_total += len(baseline_components)
|
||||
candidate_components_total += len(candidate_components)
|
||||
added_total += len(added)
|
||||
lost_total += len(lost)
|
||||
false_free += candidate_map.get("free_space_claimed") is True
|
||||
maximum_added = max(maximum_added, len(added))
|
||||
maximum_components = max(maximum_components, len(candidate_components))
|
||||
count += 1
|
||||
if count != expected_frames or set(selected) != selected_sequences:
|
||||
raise M48R3StaticOccupancyShadowError("frame ledgers are incomplete")
|
||||
return LedgerComparison(
|
||||
frame_count=count,
|
||||
baseline_cell_total=baseline_cells_total,
|
||||
candidate_cell_total=candidate_cells_total,
|
||||
added_cell_total=added_total,
|
||||
lost_cell_total=lost_total,
|
||||
baseline_component_total=baseline_components_total,
|
||||
candidate_component_total=candidate_components_total,
|
||||
false_free_count=false_free,
|
||||
maximum_added_cells_per_frame=maximum_added,
|
||||
maximum_candidate_components_per_frame=maximum_components,
|
||||
mean_cell_growth_fraction=_growth(candidate_cells_total, baseline_cells_total),
|
||||
mean_component_growth_fraction=_growth(
|
||||
candidate_components_total,
|
||||
baseline_components_total,
|
||||
),
|
||||
diff_rows=tuple(diffs),
|
||||
selected_candidate_rows=selected,
|
||||
)
|
||||
|
||||
|
||||
def read_m48r3_static_occupancy_shadow(
|
||||
result_root: Path,
|
||||
) -> M48R3StaticOccupancyShadowResult:
|
||||
root = result_root.resolve(strict=True)
|
||||
if result_root.is_symlink() or not root.name.startswith(M48R3_SHADOW_PREFIX):
|
||||
raise M48R3StaticOccupancyShadowError("M4.8R3 result root is invalid")
|
||||
manifest = _read_json(root / "manifest.json", maximum=4 * 1024 * 1024)
|
||||
report = _read_json(root / "report.json", maximum=8 * 1024 * 1024)
|
||||
cases = tuple(_read_jsonl(root / "cases.jsonl", maximum=8 * 1024 * 1024))
|
||||
if (
|
||||
manifest.get("schema_version") != M48R3_SHADOW_RESULT_SCHEMA
|
||||
or manifest.get("result_id") != root.name
|
||||
or report.get("schema_version") != M48R3_SHADOW_REPORT_SCHEMA
|
||||
or report.get("result_id") != root.name
|
||||
or manifest.get("authority") != _AUTHORITY
|
||||
):
|
||||
raise M48R3StaticOccupancyShadowError("M4.8R3 result identity changed")
|
||||
identity = _object(manifest.get("identity"), "M4.8R3 identity")
|
||||
if (
|
||||
root.name != M48R3_SHADOW_PREFIX + _canonical_sha256(identity)
|
||||
or manifest.get("identity_sha256") != _canonical_sha256(identity)
|
||||
):
|
||||
raise M48R3StaticOccupancyShadowError("M4.8R3 digest changed")
|
||||
artifacts = manifest.get("artifacts")
|
||||
if not isinstance(artifacts, list):
|
||||
raise M48R3StaticOccupancyShadowError("M4.8R3 artifacts changed")
|
||||
for artifact in artifacts:
|
||||
item = _object(artifact, "M4.8R3 artifact")
|
||||
path = (root / str(item.get("path"))).resolve(strict=True)
|
||||
if path.parent != root or path.is_symlink() or _file_sha256(path) != item.get("sha256"):
|
||||
raise M48R3StaticOccupancyShadowError("M4.8R3 artifact changed")
|
||||
return M48R3StaticOccupancyShadowResult(root.name, root, manifest, report, cases)
|
||||
|
||||
|
||||
def _evaluate_cases(
|
||||
source_cases: Iterable[dict[str, Any]],
|
||||
candidate_rows: dict[int, dict[str, Any]],
|
||||
store: RecordedGeometryStore,
|
||||
) -> tuple[dict[str, Any], ...]:
|
||||
result: list[dict[str, Any]] = []
|
||||
for source in source_cases:
|
||||
display_frame = int(source["sequence"])
|
||||
sequence = display_frame - 1
|
||||
frame = store.frame_for_index(sequence)
|
||||
if frame is None:
|
||||
raise M48R3StaticOccupancyShadowError("anchor frame is unavailable")
|
||||
extent = source.get("extent_xyxy")
|
||||
if not isinstance(extent, list) or len(extent) != 4:
|
||||
raise M48R3StaticOccupancyShadowError("anchor extent changed")
|
||||
bbox = (
|
||||
float(extent[0]) * frame.projection.width,
|
||||
float(extent[1]) * frame.projection.height,
|
||||
float(extent[2]) * frame.projection.width,
|
||||
float(extent[3]) * frame.projection.height,
|
||||
)
|
||||
matches = _component_matches(candidate_rows[sequence], frame, bbox)
|
||||
result.append(
|
||||
{
|
||||
"schema_version": M48R3_SHADOW_CASE_SCHEMA,
|
||||
"anchor_id": source["anchor_id"],
|
||||
"display_frame": display_frame,
|
||||
"source_sequence": sequence,
|
||||
"extent_xyxy": extent,
|
||||
"distance_band": source["distance_band"],
|
||||
"component_count": len(matches),
|
||||
"components": matches,
|
||||
"matched": bool(matches),
|
||||
"authority": "operator-assisted-development-anchor-not-truth",
|
||||
}
|
||||
)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _evaluate_separation(
|
||||
profile: dict[str, Any],
|
||||
cases: tuple[dict[str, Any], ...],
|
||||
) -> list[dict[str, Any]]:
|
||||
by_anchor = {str(row["anchor_id"]): row for row in cases}
|
||||
result: list[dict[str, Any]] = []
|
||||
for value in profile.get("separation_expectations", []):
|
||||
item = _object(value, "separation expectation")
|
||||
anchor_id = str(item["anchor_id"])
|
||||
case = by_anchor.get(anchor_id)
|
||||
if case is None or case["display_frame"] != item["sequence"]:
|
||||
raise M48R3StaticOccupancyShadowError("separation anchor changed")
|
||||
expected = int(item["expected_minimum_components"])
|
||||
observed = int(case["component_count"])
|
||||
result.append(
|
||||
{
|
||||
"anchor_id": anchor_id,
|
||||
"display_frame": case["display_frame"],
|
||||
"source_sequence": case["source_sequence"],
|
||||
"expected_minimum_components": expected,
|
||||
"observed_components": observed,
|
||||
"passed": observed >= expected,
|
||||
"interpretation": item["interpretation"],
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _component_matches(
|
||||
graph_row: dict[str, Any],
|
||||
frame: Any,
|
||||
bbox: tuple[float, float, float, float],
|
||||
) -> list[dict[str, Any]]:
|
||||
projected_rows: list[dict[str, Any]] = []
|
||||
for component_id, cells in _active_components(_obstacle_map(graph_row)):
|
||||
if not cells:
|
||||
continue
|
||||
points = np.asarray(
|
||||
[
|
||||
(
|
||||
(cell[0] + 0.5) * VOXEL_SIZE_M,
|
||||
(cell[1] + 0.5) * VOXEL_SIZE_M,
|
||||
(cell[2] + 0.5) * VOXEL_SIZE_M,
|
||||
)
|
||||
for cell in cells
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
projected = project_map_points_kb4(
|
||||
points,
|
||||
position_map_xyz=frame.sensor_position_map,
|
||||
orientation_map_from_lidar_xyzw=frame.sensor_orientation_xyzw,
|
||||
profile=frame.projection,
|
||||
)
|
||||
inside = (
|
||||
(projected.pixels_xy[:, 0] >= bbox[0])
|
||||
& (projected.pixels_xy[:, 0] <= bbox[2])
|
||||
& (projected.pixels_xy[:, 1] >= bbox[1])
|
||||
& (projected.pixels_xy[:, 1] <= bbox[3])
|
||||
)
|
||||
if not np.any(inside):
|
||||
continue
|
||||
depths = projected.depths_m[inside]
|
||||
projected_rows.append(
|
||||
{
|
||||
"component_id": component_id,
|
||||
"projected_cell_count": int(depths.size),
|
||||
"nearest_depth_m": round(float(np.min(depths)), 6),
|
||||
}
|
||||
)
|
||||
return sorted(projected_rows, key=lambda row: (row["nearest_depth_m"], row["component_id"]))
|
||||
|
||||
|
||||
def _worker_metrics(result: dict[str, Any]) -> dict[str, Any]:
|
||||
execution = _object(result.get("execution"), "worker execution")
|
||||
timing = _object(
|
||||
_object(result.get("metrics"), "worker metrics").get("pipeline_timing"),
|
||||
"pipeline timing",
|
||||
)
|
||||
geometry = _object(
|
||||
_object(timing.get("provider_ms"), "provider timing").get("geometry"),
|
||||
"geometry timing",
|
||||
)
|
||||
world = _object(
|
||||
_object(result.get("metrics"), "worker metrics").get(
|
||||
"world_state_completion_age_ms"
|
||||
),
|
||||
"world timing",
|
||||
)
|
||||
return {
|
||||
"admitted_frames": int(execution["admitted_frames"]),
|
||||
"delivered_frames": int(execution["delivered_world_states"]),
|
||||
"fps": float(execution["effective_world_state_fps"]),
|
||||
"world_p95_ms": float(world["p95"]),
|
||||
"world_p99_ms": float(world["p99"]),
|
||||
"geometry_p95_ms": float(geometry["p95"]),
|
||||
"geometry_p99_ms": float(geometry["p99"]),
|
||||
}
|
||||
|
||||
|
||||
def _provider_metrics(result: dict[str, Any]) -> dict[str, Any]:
|
||||
loops = _object(result["execution"]["loops"][0], "worker loop")
|
||||
providers = _object(loops.get("providers"), "providers")
|
||||
geometry = _object(providers.get("geometry"), "geometry provider")
|
||||
temporal = _object(providers.get("temporal"), "temporal provider")
|
||||
rolling = _object(providers.get("rolling"), "rolling provider")
|
||||
additive_duration_ns = int(geometry["additive_core_duration_ns"])
|
||||
completed = int(geometry["completed_frames"])
|
||||
return {
|
||||
"additive_observation_count": int(geometry["additive_observation_count"]),
|
||||
"additive_voxel_count": int(geometry["additive_voxel_count"]),
|
||||
"frames_with_additions": int(geometry["frames_with_additions"]),
|
||||
"peak_additive_observations_per_frame": int(
|
||||
geometry["peak_additive_observations_per_frame"]
|
||||
),
|
||||
"peak_candidate_points_per_frame": int(geometry["peak_candidate_points_per_frame"]),
|
||||
"peak_voxels_per_component": int(geometry["peak_voxels_per_component"]),
|
||||
"additive_mean_ms_per_frame": round(
|
||||
additive_duration_ns / max(1, completed) / 1_000_000,
|
||||
6,
|
||||
),
|
||||
"geometry_failed_frames": int(geometry["failed_frames"]),
|
||||
"temporal_failed_frames": int(temporal["failed_frames"]),
|
||||
"rolling_capacity_evicted_cells": int(rolling["capacity_evicted_cells"]),
|
||||
"peak_temporal_components": int(temporal["peak_active_components"]),
|
||||
"peak_rolling_cells": int(rolling["peak_active_cells"]),
|
||||
}
|
||||
|
||||
|
||||
def _validate_worker_binding(
|
||||
result: dict[str, Any],
|
||||
frames_path: Path,
|
||||
*,
|
||||
expected_frames: int,
|
||||
additive_profile_sha256: str | None,
|
||||
) -> None:
|
||||
execution = _object(result.get("execution"), "worker execution")
|
||||
evidence = _object(execution.get("frame_evidence"), "frame evidence")
|
||||
identity = _object(result.get("identity"), "worker identity")
|
||||
inputs = _object(identity.get("inputs"), "worker inputs")
|
||||
if (
|
||||
result.get("schema_version") != WORKER_RESULT_SCHEMA
|
||||
or result.get("completed") is not True
|
||||
or execution.get("admitted_frames") != expected_frames
|
||||
or evidence.get("schema_version") != FRAME_EVIDENCE_SCHEMA
|
||||
or evidence.get("sha256") != _file_sha256(frames_path)
|
||||
or evidence.get("row_count") != execution.get("delivered_world_states")
|
||||
or identity.get("worker_id") != "worker-006"
|
||||
or result.get("authority")
|
||||
!= {
|
||||
"actuation_allowed": False,
|
||||
"candidate_accepted": False,
|
||||
"commands_enabled": False,
|
||||
"ground_truth": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
):
|
||||
raise M48R3StaticOccupancyShadowError("worker result binding changed")
|
||||
if additive_profile_sha256 is None:
|
||||
if "additive_low_step_profile" in inputs:
|
||||
raise M48R3StaticOccupancyShadowError("baseline is not native-only")
|
||||
elif inputs.get("additive_low_step_profile") != additive_profile_sha256:
|
||||
raise M48R3StaticOccupancyShadowError("candidate profile binding changed")
|
||||
|
||||
|
||||
def _read_worker_result(path: Path) -> dict[str, Any]:
|
||||
return _read_json(path, maximum=128 * 1024 * 1024)
|
||||
|
||||
|
||||
def _frame_row(value: object) -> dict[str, Any]:
|
||||
row = _object(value, "frame row")
|
||||
if row.get("schema_version") != FRAME_EVIDENCE_SCHEMA:
|
||||
raise M48R3StaticOccupancyShadowError("frame schema changed")
|
||||
return row
|
||||
|
||||
|
||||
def _sequence(row: dict[str, Any]) -> int:
|
||||
envelope = _object(row.get("source_envelope"), "source envelope")
|
||||
value = envelope.get("sequence")
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise M48R3StaticOccupancyShadowError("frame sequence changed")
|
||||
return value
|
||||
|
||||
|
||||
def _obstacle_map(row: dict[str, Any]) -> dict[str, Any]:
|
||||
delivery = _object(row.get("delivery"), "delivery")
|
||||
return _object(delivery.get("obstacle_map"), "obstacle map")
|
||||
|
||||
|
||||
def _active_components(obstacle_map: dict[str, Any]) -> list[tuple[str, set[Cell]]]:
|
||||
result: list[tuple[str, set[Cell]]] = []
|
||||
for collection in (obstacle_map.get("occupied"), obstacle_map.get("unknown")):
|
||||
if not isinstance(collection, list):
|
||||
raise M48R3StaticOccupancyShadowError("obstacle collection changed")
|
||||
for value in collection:
|
||||
item = _object(value, "obstacle")
|
||||
component_id = item.get("component_id")
|
||||
cells = item.get("cells")
|
||||
if not isinstance(component_id, str) or not isinstance(cells, list):
|
||||
raise M48R3StaticOccupancyShadowError("obstacle component changed")
|
||||
parsed: set[Cell] = set()
|
||||
for cell in cells:
|
||||
row = _object(cell, "obstacle cell")
|
||||
x, y, z = row.get("x"), row.get("y"), row.get("z")
|
||||
if any(
|
||||
not isinstance(item, int) or isinstance(item, bool)
|
||||
for item in (x, y, z)
|
||||
):
|
||||
raise M48R3StaticOccupancyShadowError("obstacle cell changed")
|
||||
assert isinstance(x, int) and isinstance(y, int) and isinstance(z, int)
|
||||
parsed.add((x, y, z))
|
||||
if parsed:
|
||||
result.append((component_id, parsed))
|
||||
return result
|
||||
|
||||
|
||||
def _cell_union(components: Iterable[tuple[str, set[Cell]]]) -> set[Cell]:
|
||||
result: set[Cell] = set()
|
||||
for _, cells in components:
|
||||
result.update(cells)
|
||||
return result
|
||||
|
||||
|
||||
def _growth(candidate: int, baseline: int) -> float:
|
||||
if baseline <= 0:
|
||||
return math.inf if candidate > 0 else 0.0
|
||||
return max(0.0, (candidate - baseline) / baseline)
|
||||
|
||||
|
||||
def _rate(rows: list[dict[str, Any]], key: str) -> float:
|
||||
return sum(bool(row[key]) for row in rows) / len(rows) if rows else 0.0
|
||||
|
||||
|
||||
def _worker_completed_at(result: dict[str, Any]) -> str:
|
||||
value = result.get("completed_utc_ns")
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
|
||||
raise M48R3StaticOccupancyShadowError("worker completion time changed")
|
||||
return datetime.fromtimestamp(value / 1_000_000_000, UTC).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
|
||||
|
||||
def _publish(
|
||||
destination: Path,
|
||||
*,
|
||||
identity: dict[str, Any],
|
||||
report: dict[str, Any],
|
||||
cases: tuple[dict[str, Any], ...],
|
||||
diff_rows: tuple[dict[str, Any], ...],
|
||||
candidate_result_path: Path,
|
||||
candidate_frames_path: Path,
|
||||
) -> None:
|
||||
destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
staging = destination.parent / f".{destination.name}.{uuid.uuid4().hex}.tmp"
|
||||
staging.mkdir(mode=0o700)
|
||||
try:
|
||||
_write_json(staging / "report.json", report)
|
||||
_write_jsonl(staging / "cases.jsonl", cases)
|
||||
_write_jsonl(staging / "frame-diff.jsonl", diff_rows)
|
||||
shutil.copyfile(candidate_result_path, staging / "worker-result.json")
|
||||
shutil.copyfile(candidate_frames_path, staging / "frames.jsonl")
|
||||
artifacts = [
|
||||
_artifact(staging / name, role)
|
||||
for name, role in (
|
||||
("report.json", "m48r3-shadow-report"),
|
||||
("cases.jsonl", "operator-assisted-anchor-projection"),
|
||||
("frame-diff.jsonl", "baseline-versus-candidate-occupied-diff"),
|
||||
("worker-result.json", "worker-load-result"),
|
||||
("frames.jsonl", "candidate-frame-ledger"),
|
||||
)
|
||||
]
|
||||
manifest = {
|
||||
"schema_version": M48R3_SHADOW_RESULT_SCHEMA,
|
||||
"result_id": destination.name,
|
||||
"identity_sha256": _canonical_sha256(identity),
|
||||
"identity": identity,
|
||||
"created_at_utc": identity["created_at_utc"],
|
||||
"accepted": report["accepted"],
|
||||
"ground_truth": False,
|
||||
"authority": dict(_AUTHORITY),
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
_write_json(staging / "manifest.json", manifest)
|
||||
if destination.exists():
|
||||
raise M48R3StaticOccupancyShadowError("immutable result already exists")
|
||||
os.replace(staging, destination)
|
||||
except BaseException:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
raise
|
||||
|
||||
|
||||
def _artifact(path: Path, role: str) -> dict[str, Any]:
|
||||
return {
|
||||
"path": path.name,
|
||||
"role": role,
|
||||
"byte_length": path.stat().st_size,
|
||||
"sha256": _file_sha256(path),
|
||||
"media_type": "application/x-ndjson" if path.suffix == ".jsonl" else "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _read_json(path: Path, *, maximum: int) -> dict[str, Any]:
|
||||
if path.is_symlink() or not path.is_file() or path.stat().st_size > maximum:
|
||||
raise M48R3StaticOccupancyShadowError(f"{path.name} is unavailable")
|
||||
return _object(json.loads(path.read_text("utf-8")), path.name)
|
||||
|
||||
|
||||
def _read_jsonl(path: Path, *, maximum: int) -> list[dict[str, Any]]:
|
||||
if path.is_symlink() or not path.is_file() or path.stat().st_size > maximum:
|
||||
raise M48R3StaticOccupancyShadowError(f"{path.name} is unavailable")
|
||||
return [
|
||||
_object(json.loads(line), path.name)
|
||||
for line in path.read_text("utf-8").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
path.write_text(
|
||||
json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||
"utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _write_jsonl(path: Path, rows: Iterable[dict[str, Any]]) -> None:
|
||||
with path.open("w", encoding="utf-8") as stream:
|
||||
for row in rows:
|
||||
stream.write(
|
||||
json.dumps(
|
||||
row,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
||||
def _file_sha256(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_sha256(value: object) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode(
|
||||
"utf-8"
|
||||
)
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise M48R3StaticOccupancyShadowError(f"{label} is invalid")
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LedgerComparison",
|
||||
"M48R3StaticOccupancyShadowError",
|
||||
"M48R3StaticOccupancyShadowResult",
|
||||
"build_m48r3_static_occupancy_shadow",
|
||||
"compare_m48r3_frame_ledgers",
|
||||
"read_m48r3_static_occupancy_shadow",
|
||||
]
|
||||
@@ -36,9 +36,7 @@ from .geometry import (
|
||||
from .geometry_math import POINT_OCCUPIED
|
||||
from .providers import SourcePacket
|
||||
|
||||
M48_LOW_STEP_PROFILE_SCHEMA: Final = (
|
||||
"missioncore.m48-additive-low-step-occupancy-profile/v1"
|
||||
)
|
||||
M48_LOW_STEP_PROFILE_SCHEMA: Final = "missioncore.m48-additive-low-step-occupancy-profile/v1"
|
||||
M48_LOW_STEP_PROVIDER_ID: Final = "ravnoves00-additive-low-step-geometry/v1"
|
||||
|
||||
IntArray = npt.NDArray[np.int64]
|
||||
@@ -53,6 +51,10 @@ class LowStepComponentProfile:
|
||||
voxel_size_m: float
|
||||
neighbor_radius_cells: int
|
||||
minimum_points: int
|
||||
sparse_persistence_minimum_points: int
|
||||
sparse_persistence_window_frames: int
|
||||
sparse_persistence_minimum_hits: int
|
||||
sparse_persistence_maximum_range_m: float
|
||||
minimum_voxels: int
|
||||
local_radius_m: float
|
||||
maximum_candidate_points_per_frame: int
|
||||
@@ -65,6 +67,13 @@ class LowStepComponentProfile:
|
||||
or not 0.05 <= self.voxel_size_m <= 2.0
|
||||
or self.neighbor_radius_cells != 1
|
||||
or not 1 <= self.minimum_points <= 256
|
||||
or not 1 <= self.sparse_persistence_minimum_points < self.minimum_points
|
||||
or not 2 <= self.sparse_persistence_window_frames <= 32
|
||||
or not 2
|
||||
<= self.sparse_persistence_minimum_hits
|
||||
<= self.sparse_persistence_window_frames
|
||||
or not math.isfinite(self.sparse_persistence_maximum_range_m)
|
||||
or not 1.0 <= self.sparse_persistence_maximum_range_m <= self.local_radius_m
|
||||
or not 1 <= self.minimum_voxels <= 128
|
||||
or not math.isfinite(self.local_radius_m)
|
||||
or not 1.0 <= self.local_radius_m <= 100.0
|
||||
@@ -156,6 +165,11 @@ class M48AdditiveLowStepGeometryProvider:
|
||||
self._peak_additive_observations = 0
|
||||
self._peak_component_voxels = 0
|
||||
self._additive_core_duration_ns = 0
|
||||
self._sparse_lock = Lock()
|
||||
self._sparse_history: deque[tuple[int, tuple[frozenset[tuple[int, int, int]], ...]]] = (
|
||||
deque()
|
||||
)
|
||||
self._last_sparse_sequence: int | None = None
|
||||
|
||||
def associate(
|
||||
self,
|
||||
@@ -175,9 +189,7 @@ class M48AdditiveLowStepGeometryProvider:
|
||||
except Exception:
|
||||
with self._lock:
|
||||
self._failed_frames += 1
|
||||
self._additive_core_duration_ns += max(
|
||||
0, time.perf_counter_ns() - started
|
||||
)
|
||||
self._additive_core_duration_ns += max(0, time.perf_counter_ns() - started)
|
||||
raise
|
||||
with self._lock:
|
||||
self._completed_frames += 1
|
||||
@@ -185,18 +197,10 @@ class M48AdditiveLowStepGeometryProvider:
|
||||
self._candidate_points += candidate_points
|
||||
self._additive_observation_count += len(additive)
|
||||
self._additive_voxels += voxel_count
|
||||
self._peak_candidate_points = max(
|
||||
self._peak_candidate_points, candidate_points
|
||||
)
|
||||
self._peak_additive_observations = max(
|
||||
self._peak_additive_observations, len(additive)
|
||||
)
|
||||
self._peak_component_voxels = max(
|
||||
self._peak_component_voxels, peak_component_voxels
|
||||
)
|
||||
self._additive_core_duration_ns += max(
|
||||
0, time.perf_counter_ns() - started
|
||||
)
|
||||
self._peak_candidate_points = max(self._peak_candidate_points, candidate_points)
|
||||
self._peak_additive_observations = max(self._peak_additive_observations, len(additive))
|
||||
self._peak_component_voxels = max(self._peak_component_voxels, peak_component_voxels)
|
||||
self._additive_core_duration_ns += max(0, time.perf_counter_ns() - started)
|
||||
return tuple(result)
|
||||
|
||||
def _build_additive_observations(
|
||||
@@ -213,9 +217,9 @@ class M48AdditiveLowStepGeometryProvider:
|
||||
claimed = {
|
||||
point_id for observation in baseline for point_id in observation.source_point_ids
|
||||
}
|
||||
candidate = np.flatnonzero(
|
||||
(step > 0) & (frame.point_class != POINT_OCCUPIED)
|
||||
).astype(np.int64)
|
||||
candidate = np.flatnonzero((step > 0) & (frame.point_class != POINT_OCCUPIED)).astype(
|
||||
np.int64
|
||||
)
|
||||
if claimed and candidate.size:
|
||||
candidate = candidate[
|
||||
np.fromiter(
|
||||
@@ -240,12 +244,19 @@ class M48AdditiveLowStepGeometryProvider:
|
||||
candidate,
|
||||
self.profile.component,
|
||||
)
|
||||
qualified = tuple(
|
||||
strong = tuple(
|
||||
item
|
||||
for item in components
|
||||
if item[0].size >= self.profile.component.minimum_points
|
||||
and item[1] >= self.profile.component.minimum_voxels
|
||||
)
|
||||
persistent_sparse = self._persistent_sparse_components(
|
||||
sequence=packet.envelope.sequence,
|
||||
components=components,
|
||||
points_map=frame.points_map,
|
||||
sensor_position_map=frame.sensor_position_map,
|
||||
)
|
||||
qualified = (*strong, *persistent_sparse)
|
||||
qualified = tuple(
|
||||
sorted(
|
||||
qualified,
|
||||
@@ -253,8 +264,7 @@ class M48AdditiveLowStepGeometryProvider:
|
||||
float(
|
||||
np.min(
|
||||
np.linalg.norm(
|
||||
frame.points_map[item[0]]
|
||||
- frame.sensor_position_map,
|
||||
frame.points_map[item[0]] - frame.sensor_position_map,
|
||||
axis=1,
|
||||
)
|
||||
)
|
||||
@@ -279,17 +289,11 @@ class M48AdditiveLowStepGeometryProvider:
|
||||
points = frame.points_map[indices]
|
||||
centroid = np.median(points, axis=0)
|
||||
covariance = points.var(axis=0)
|
||||
nearest = float(
|
||||
np.min(np.linalg.norm(points - frame.sensor_position_map, axis=1))
|
||||
)
|
||||
nearest = float(np.min(np.linalg.norm(points - frame.sensor_position_map, axis=1)))
|
||||
observations.append(
|
||||
ObstacleObservation(
|
||||
observation_id=(
|
||||
f"{packet.envelope.frame_id}:low-step:{component_index}"
|
||||
),
|
||||
occupancy_key=(
|
||||
f"{packet.envelope.frame_id}:low-step:{component_index}"
|
||||
),
|
||||
observation_id=(f"{packet.envelope.frame_id}:low-step:{component_index}"),
|
||||
occupancy_key=(f"{packet.envelope.frame_id}:low-step:{component_index}"),
|
||||
source_id=packet.envelope.source_id,
|
||||
frame_id=packet.envelope.frame_id,
|
||||
evidence_time_ns=packet.envelope.timestamps.source_ns,
|
||||
@@ -323,6 +327,65 @@ class M48AdditiveLowStepGeometryProvider:
|
||||
peak_voxels = max(peak_voxels, cells)
|
||||
return tuple(observations), candidate_count, voxel_count, peak_voxels
|
||||
|
||||
def _persistent_sparse_components(
|
||||
self,
|
||||
*,
|
||||
sequence: int,
|
||||
components: tuple[tuple[IntArray, int], ...],
|
||||
points_map: npt.NDArray[np.float64],
|
||||
sensor_position_map: npt.NDArray[np.float64],
|
||||
) -> tuple[tuple[IntArray, int], ...]:
|
||||
"""Promote only weak geometry repeated in a bounded causal window."""
|
||||
|
||||
component_profile = self.profile.component
|
||||
current = tuple(
|
||||
(
|
||||
item,
|
||||
_component_cells(
|
||||
points_map,
|
||||
item[0],
|
||||
voxel_size_m=component_profile.voxel_size_m,
|
||||
),
|
||||
)
|
||||
for item in components
|
||||
if item[0].size >= component_profile.sparse_persistence_minimum_points
|
||||
and item[1] >= component_profile.minimum_voxels
|
||||
)
|
||||
with self._sparse_lock:
|
||||
if self._last_sparse_sequence is not None and (
|
||||
sequence <= self._last_sparse_sequence
|
||||
or sequence - self._last_sparse_sequence
|
||||
> component_profile.sparse_persistence_window_frames
|
||||
):
|
||||
self._sparse_history.clear()
|
||||
first_allowed = sequence - component_profile.sparse_persistence_window_frames + 1
|
||||
while self._sparse_history and self._sparse_history[0][0] < first_allowed:
|
||||
self._sparse_history.popleft()
|
||||
promoted: list[tuple[IntArray, int]] = []
|
||||
for item, cells in current:
|
||||
if item[0].size >= component_profile.minimum_points:
|
||||
continue
|
||||
hit_count = 1 + sum(
|
||||
any(not cells.isdisjoint(previous) for previous in previous_components)
|
||||
for _, previous_components in self._sparse_history
|
||||
)
|
||||
if (
|
||||
hit_count >= component_profile.sparse_persistence_minimum_hits
|
||||
and float(
|
||||
np.min(
|
||||
np.linalg.norm(
|
||||
points_map[item[0]] - sensor_position_map,
|
||||
axis=1,
|
||||
)
|
||||
)
|
||||
)
|
||||
<= component_profile.sparse_persistence_maximum_range_m
|
||||
):
|
||||
promoted.append(item)
|
||||
self._sparse_history.append((sequence, tuple(cells for _, cells in current)))
|
||||
self._last_sparse_sequence = sequence
|
||||
return tuple(promoted)
|
||||
|
||||
def snapshot(self) -> M48LowStepOccupancySnapshot:
|
||||
with self._lock:
|
||||
return M48LowStepOccupancySnapshot(
|
||||
@@ -335,9 +398,7 @@ class M48AdditiveLowStepGeometryProvider:
|
||||
additive_observation_count=self._additive_observation_count,
|
||||
additive_voxel_count=self._additive_voxels,
|
||||
peak_candidate_points_per_frame=self._peak_candidate_points,
|
||||
peak_additive_observations_per_frame=(
|
||||
self._peak_additive_observations
|
||||
),
|
||||
peak_additive_observations_per_frame=(self._peak_additive_observations),
|
||||
peak_voxels_per_component=self._peak_component_voxels,
|
||||
additive_core_duration_ns=self._additive_core_duration_ns,
|
||||
)
|
||||
@@ -401,6 +462,10 @@ def load_m48_low_step_occupancy_profile(
|
||||
"voxel_size_m",
|
||||
"neighbor_radius_cells",
|
||||
"minimum_points",
|
||||
"sparse_persistence_minimum_points",
|
||||
"sparse_persistence_window_frames",
|
||||
"sparse_persistence_minimum_hits",
|
||||
"sparse_persistence_maximum_range_m",
|
||||
"minimum_voxels",
|
||||
"local_radius_m",
|
||||
"maximum_candidate_points_per_frame",
|
||||
@@ -460,9 +525,7 @@ def load_m48_low_step_occupancy_profile(
|
||||
if _number(acceptance, key) < 0.0:
|
||||
raise M48LowStepOccupancyError("low-step acceptance bounds are invalid")
|
||||
if (
|
||||
not _string(source, "m48r2_result_id").startswith(
|
||||
"m48-static-occupancy-qualification-"
|
||||
)
|
||||
not _string(source, "m48r2_result_id").startswith("m48-static-occupancy-qualification-")
|
||||
or len(_string(source, "m48r2_result_id"))
|
||||
!= len("m48-static-occupancy-qualification-") + 64
|
||||
):
|
||||
@@ -500,9 +563,7 @@ def load_m48_low_step_occupancy_profile(
|
||||
LowStepSeparationExpectation(
|
||||
anchor_id=_string(item, "anchor_id"),
|
||||
sequence=_positive_integer(item, "sequence"),
|
||||
expected_minimum_components=_positive_integer(
|
||||
item, "expected_minimum_components"
|
||||
),
|
||||
expected_minimum_components=_positive_integer(item, "expected_minimum_components"),
|
||||
interpretation=_string(item, "interpretation"),
|
||||
)
|
||||
)
|
||||
@@ -519,18 +580,30 @@ def load_m48_low_step_occupancy_profile(
|
||||
base_geometry_profile_sha256=_digest(base, "sha256"),
|
||||
component=LowStepComponentProfile(
|
||||
voxel_size_m=_number(component, "voxel_size_m"),
|
||||
neighbor_radius_cells=_positive_integer(
|
||||
component, "neighbor_radius_cells"
|
||||
),
|
||||
neighbor_radius_cells=_positive_integer(component, "neighbor_radius_cells"),
|
||||
minimum_points=_positive_integer(component, "minimum_points"),
|
||||
sparse_persistence_minimum_points=_positive_integer(
|
||||
component,
|
||||
"sparse_persistence_minimum_points",
|
||||
),
|
||||
sparse_persistence_window_frames=_positive_integer(
|
||||
component,
|
||||
"sparse_persistence_window_frames",
|
||||
),
|
||||
sparse_persistence_minimum_hits=_positive_integer(
|
||||
component,
|
||||
"sparse_persistence_minimum_hits",
|
||||
),
|
||||
sparse_persistence_maximum_range_m=_number(
|
||||
component,
|
||||
"sparse_persistence_maximum_range_m",
|
||||
),
|
||||
minimum_voxels=_positive_integer(component, "minimum_voxels"),
|
||||
local_radius_m=_number(component, "local_radius_m"),
|
||||
maximum_candidate_points_per_frame=_positive_integer(
|
||||
component, "maximum_candidate_points_per_frame"
|
||||
),
|
||||
maximum_cells_per_component=_positive_integer(
|
||||
component, "maximum_cells_per_component"
|
||||
),
|
||||
maximum_cells_per_component=_positive_integer(component, "maximum_cells_per_component"),
|
||||
maximum_components_per_frame=_positive_integer(
|
||||
component, "maximum_components_per_frame"
|
||||
),
|
||||
@@ -547,9 +620,7 @@ def _voxel_components(
|
||||
) -> tuple[tuple[IntArray, int], ...]:
|
||||
if source_indices.size == 0:
|
||||
return ()
|
||||
cells = np.floor(
|
||||
points_map[source_indices] / profile.voxel_size_m
|
||||
).astype(np.int64)
|
||||
cells = np.floor(points_map[source_indices] / profile.voxel_size_m).astype(np.int64)
|
||||
cell_points: dict[tuple[int, int, int], list[int]] = {}
|
||||
for local_index, row in enumerate(cells):
|
||||
key = (int(row[0]), int(row[1]), int(row[2]))
|
||||
@@ -591,6 +662,16 @@ def _voxel_components(
|
||||
return tuple(components)
|
||||
|
||||
|
||||
def _component_cells(
|
||||
points_map: npt.NDArray[np.float64],
|
||||
source_indices: IntArray,
|
||||
*,
|
||||
voxel_size_m: float,
|
||||
) -> frozenset[tuple[int, int, int]]:
|
||||
rows = np.floor(points_map[source_indices] / voxel_size_m).astype(np.int64)
|
||||
return frozenset((int(row[0]), int(row[1]), int(row[2])) for row in rows)
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, object]:
|
||||
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
||||
raise M48LowStepOccupancyError(f"{label} must be an object")
|
||||
|
||||
@@ -70,6 +70,7 @@ class M48sReplayTimeline:
|
||||
frames_name: str = "reference-graph-replay-frames.jsonl",
|
||||
worker_result_name: str = "reference-graph-replay-worker-result.json",
|
||||
frame_evidence_schema: str = FRAME_EVIDENCE_SCHEMA,
|
||||
frame_diff_name: str | None = None,
|
||||
camera_endpoint_root: str = (
|
||||
"/api/v1/laboratory/m48s/fixed-class-detector"
|
||||
),
|
||||
@@ -80,6 +81,10 @@ class M48sReplayTimeline:
|
||||
if (
|
||||
Path(frames_name).name != frames_name
|
||||
or Path(worker_result_name).name != worker_result_name
|
||||
or (
|
||||
frame_diff_name is not None
|
||||
and Path(frame_diff_name).name != frame_diff_name
|
||||
)
|
||||
or frame_evidence_schema not in {
|
||||
FRAME_EVIDENCE_SCHEMA,
|
||||
M48R3_FRAME_EVIDENCE_SCHEMA,
|
||||
@@ -92,11 +97,23 @@ class M48sReplayTimeline:
|
||||
self.camera_endpoint_root = camera_endpoint_root
|
||||
self.frames_path = (self.result_root / frames_name).resolve(strict=True)
|
||||
self.worker_path = (self.result_root / worker_result_name).resolve(strict=True)
|
||||
self.frame_diff_path = (
|
||||
None
|
||||
if frame_diff_name is None
|
||||
else (self.result_root / frame_diff_name).resolve(strict=True)
|
||||
)
|
||||
if (
|
||||
self.frames_path.parent != self.result_root
|
||||
or self.worker_path.parent != self.result_root
|
||||
or self.frames_path.is_symlink()
|
||||
or self.worker_path.is_symlink()
|
||||
or (
|
||||
self.frame_diff_path is not None
|
||||
and (
|
||||
self.frame_diff_path.parent != self.result_root
|
||||
or self.frame_diff_path.is_symlink()
|
||||
)
|
||||
)
|
||||
):
|
||||
raise M48sReplayTimelineError("M4.8S replay artifacts are invalid")
|
||||
self.profile = load_replay_threat_profile(
|
||||
@@ -123,6 +140,11 @@ class M48sReplayTimeline:
|
||||
self.outcomes,
|
||||
frame_evidence_schema=self.frame_evidence_schema,
|
||||
)
|
||||
self.frame_diff_offsets = (
|
||||
{}
|
||||
if self.frame_diff_path is None
|
||||
else _index_frame_diff(self.frame_diff_path)
|
||||
)
|
||||
self._cache_lock = Lock()
|
||||
self._chunk_json_cache: OrderedDict[tuple[int, int], bytes] = OrderedDict()
|
||||
self._camera_point_json_cache: OrderedDict[int, bytes] = OrderedDict()
|
||||
@@ -156,6 +178,11 @@ class M48sReplayTimeline:
|
||||
"camera_point_window_seconds": CAMERA_ACCUMULATION_WINDOW_SECONDS,
|
||||
"camera_point_sample_limit": CAMERA_ACCUMULATION_POINT_LIMIT,
|
||||
"world_state_delivery": "source-paced-latest-wins",
|
||||
"occupancy_provenance_delivery": (
|
||||
"baseline-versus-additive-component-diff"
|
||||
if self.frame_diff_path is not None
|
||||
else None
|
||||
),
|
||||
"world_state_frame_count": len(self.index.offsets_by_sequence),
|
||||
"superseded_frame_count": sum(
|
||||
value == "superseded" for value in self.outcomes.values()
|
||||
@@ -399,6 +426,12 @@ class M48sReplayTimeline:
|
||||
body_frame,
|
||||
occupied_voxel_size_m=self.profile.corridor.occupied_voxel_size_m,
|
||||
)
|
||||
provenance = self._component_provenance(sequence)
|
||||
for visual in metric_visuals:
|
||||
visual["occupancy_source"] = provenance.get(
|
||||
str(visual["component_id"]),
|
||||
"baseline",
|
||||
)
|
||||
associated = set(_strings(row.get("associated_proposal_ids"), "associated ids"))
|
||||
for proposal in _objects(row.get("detector_proposals"), "detector proposals"):
|
||||
proposal_id = _text(proposal.get("proposal_id"), "proposal id")
|
||||
@@ -480,6 +513,27 @@ class M48sReplayTimeline:
|
||||
raise M48sReplayTimelineError("M4.8S frame row is invalid")
|
||||
return value
|
||||
|
||||
def _component_provenance(self, sequence: int) -> dict[str, str]:
|
||||
if self.frame_diff_path is None:
|
||||
return {}
|
||||
offset = self.frame_diff_offsets.get(sequence)
|
||||
if offset is None:
|
||||
raise M48sReplayTimelineError("M4.8R3 frame diff is incomplete")
|
||||
with self.frame_diff_path.open("rb") as stream:
|
||||
stream.seek(offset)
|
||||
line = stream.readline()
|
||||
value = _object(json.loads(line), "frame diff")
|
||||
if value.get("sequence") != sequence:
|
||||
raise M48sReplayTimelineError("M4.8R3 frame diff binding changed")
|
||||
provenance = value.get("component_provenance")
|
||||
if not isinstance(provenance, dict) or any(
|
||||
not isinstance(key, str)
|
||||
or item not in {"mixed", "additive-low-step"}
|
||||
for key, item in provenance.items()
|
||||
):
|
||||
raise M48sReplayTimelineError("M4.8R3 component provenance changed")
|
||||
return provenance
|
||||
|
||||
|
||||
def _index_ledger(
|
||||
path: Path,
|
||||
@@ -517,6 +571,33 @@ def _index_ledger(
|
||||
return _LedgerIndex(offsets)
|
||||
|
||||
|
||||
def _index_frame_diff(path: Path) -> dict[int, int]:
|
||||
offsets: dict[int, int] = {}
|
||||
with path.open("rb") as stream:
|
||||
while True:
|
||||
offset = stream.tell()
|
||||
line = stream.readline()
|
||||
if not line:
|
||||
break
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
raise M48sReplayTimelineError("M4.8R3 frame diff is invalid") from None
|
||||
if not isinstance(value, dict):
|
||||
raise M48sReplayTimelineError("M4.8R3 frame diff is invalid")
|
||||
sequence = value.get("sequence")
|
||||
if (
|
||||
not isinstance(sequence, int)
|
||||
or isinstance(sequence, bool)
|
||||
or sequence != len(offsets)
|
||||
):
|
||||
raise M48sReplayTimelineError("M4.8R3 frame diff sequence changed")
|
||||
offsets[sequence] = offset
|
||||
if len(offsets) != EXPECTED_FRAME_COUNT:
|
||||
raise M48sReplayTimelineError("M4.8R3 frame diff is incomplete")
|
||||
return offsets
|
||||
|
||||
|
||||
def _ledger_source_envelope(
|
||||
line: bytes,
|
||||
*,
|
||||
|
||||
@@ -297,9 +297,17 @@ class SimulationProjectStore:
|
||||
result: dict[str, Any],
|
||||
artifacts: list[dict[str, Any]],
|
||||
world_manifest: dict[str, Any],
|
||||
provider_job_id: str | None = None,
|
||||
provider_progress: object = None,
|
||||
) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
document = self._read(project_id)
|
||||
if provider_job_id is not None:
|
||||
if PROVIDER_JOB_ID_PATTERN.fullmatch(provider_job_id) is None:
|
||||
raise SimulationProjectError("simulation provider job id is invalid")
|
||||
document["provider"]["job_id"] = provider_job_id
|
||||
if provider_progress is not None:
|
||||
document["provider"]["progress"] = provider_progress
|
||||
document["status"] = "ready"
|
||||
document["provider"]["state"] = "ready"
|
||||
document["provider"]["runtime"] = result.get("runtime")
|
||||
@@ -464,10 +472,10 @@ class SimulationProjectService:
|
||||
"outputs": {
|
||||
"preview_sog": True,
|
||||
"streamed_sog": True,
|
||||
"collision": False,
|
||||
"collision": True,
|
||||
},
|
||||
"preview_lod": "coarsest",
|
||||
"collision_profile": None,
|
||||
"collision_profile": _collision_profile(str(project["scene_type"])),
|
||||
}
|
||||
submitted = provider.submit_build(request)
|
||||
job_id = submitted.get("job_id")
|
||||
@@ -607,12 +615,24 @@ def _world_manifest(project_id: str, artifacts: list[dict[str, Any]]) -> dict[st
|
||||
"available": url_for("collision-mesh") is not None,
|
||||
},
|
||||
"transforms": {
|
||||
"world_from_visual": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
|
||||
"world_from_visual": [1, 0, 0, 0, 0, -1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1],
|
||||
"world_from_collision": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _collision_profile(scene_type: str) -> dict[str, Any]:
|
||||
"""Build the portable walkable-volume contract around the scanner origin."""
|
||||
return {
|
||||
"scene_type": scene_type,
|
||||
"seed_position": [0, 1, 0],
|
||||
"capsule_height": 1.6,
|
||||
"capsule_radius": 0.2,
|
||||
"voxel_size": 0.05,
|
||||
"mesh_shape": "smooth",
|
||||
}
|
||||
|
||||
|
||||
def _project_name(value: str) -> str:
|
||||
normalized = " ".join(value.split())
|
||||
if not 1 <= len(normalized) <= 120:
|
||||
|
||||
@@ -126,6 +126,9 @@ from k1link.web.lidar_api import build_lidar_router
|
||||
from k1link.web.lidar_local_surface_service import K1LocalSurfaceReadService
|
||||
from k1link.web.m4_threat_replay_api import build_m4_threat_replay_router
|
||||
from k1link.web.m48_object_quality_api import build_m48_object_quality_router
|
||||
from k1link.web.m48r3_static_occupancy_api import (
|
||||
build_m48r3_static_occupancy_router,
|
||||
)
|
||||
from k1link.web.m48s_fixed_class_detector_lab_api import (
|
||||
build_m48s_fixed_class_detector_lab_router,
|
||||
)
|
||||
@@ -961,6 +964,23 @@ app.include_router(
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_m48r3_static_occupancy_router(
|
||||
root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "m48"
|
||||
/ "static-occupancy-shadow-results"
|
||||
),
|
||||
repository_root_provider=lambda: REPOSITORY_ROOT,
|
||||
camera_frame_provider=(
|
||||
session_recorded_camera_frame_service.extract
|
||||
if session_recorded_camera_frame_service is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_m48s_fixed_class_detector_lab_router(
|
||||
root_provider=lambda: (
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Read-only API for sealed M4.8R3 static-occupancy Worker shadows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Response
|
||||
|
||||
from k1link.laboratory.m48r3_static_occupancy_shadow import (
|
||||
M48R3_SHADOW_PREFIX,
|
||||
M48R3StaticOccupancyShadowError,
|
||||
M48R3StaticOccupancyShadowResult,
|
||||
read_m48r3_static_occupancy_shadow,
|
||||
)
|
||||
from k1link.perception.m48s_replay_timeline import (
|
||||
M48R3_FRAME_EVIDENCE_SCHEMA,
|
||||
M48sReplayTimeline,
|
||||
M48sReplayTimelineError,
|
||||
)
|
||||
from k1link.perception.threat_timeline import RECORDED_SPATIAL_MAX_CHUNK_FRAMES
|
||||
from k1link.sessions import RecordedCameraFrame, SessionIntegrityError
|
||||
|
||||
RootProvider = Callable[[], Path | None]
|
||||
CameraFrameProvider = Callable[[str, int], RecordedCameraFrame]
|
||||
|
||||
RESULT_ID: Final = re.compile(rf"^{re.escape(M48R3_SHADOW_PREFIX)}[a-f0-9]{{64}}$")
|
||||
RESULT_VIEW_SCHEMA: Final = "missioncore.m48r3-static-occupancy-shadow-view/v1"
|
||||
RESULT_CATALOG_SCHEMA: Final = "missioncore.m48r3-static-occupancy-shadow-catalog/v1"
|
||||
CASE_CATALOG_SCHEMA: Final = "missioncore.m48r3-static-occupancy-shadow-cases/v1"
|
||||
ENDPOINT_ROOT: Final = "/api/v1/laboratory/m48r3/static-occupancy"
|
||||
|
||||
|
||||
def build_m48r3_static_occupancy_router(
|
||||
*,
|
||||
root_provider: RootProvider = lambda: None,
|
||||
repository_root_provider: RootProvider = lambda: None,
|
||||
camera_frame_provider: CameraFrameProvider | None = None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix=ENDPOINT_ROOT, tags=["laboratory"])
|
||||
|
||||
def result(result_id: str) -> M48R3StaticOccupancyShadowResult:
|
||||
candidate = _resolve_candidate(root_provider, result_id)
|
||||
try:
|
||||
return _read_result_cached(str(candidate), _result_signature(candidate))
|
||||
except (M48R3StaticOccupancyShadowError, OSError, ValueError):
|
||||
raise HTTPException(status_code=404, detail="M4.8R3 result not found") from None
|
||||
|
||||
def timeline(result_id: str) -> M48sReplayTimeline:
|
||||
candidate = _resolve_candidate(root_provider, result_id)
|
||||
repository = _configured_root(repository_root_provider)
|
||||
if repository is None:
|
||||
raise HTTPException(status_code=503, detail="M4.8R3 timeline source unavailable")
|
||||
result(result_id)
|
||||
try:
|
||||
return _read_timeline_cached(
|
||||
str(repository),
|
||||
str(candidate),
|
||||
result_id,
|
||||
_timeline_signature(candidate),
|
||||
)
|
||||
except (M48sReplayTimelineError, OSError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="M4.8R3 bounded timeline failed verification",
|
||||
) from None
|
||||
|
||||
@router.get("/results")
|
||||
def list_results(limit: int = Query(default=1, ge=1, le=10)) -> dict[str, object]:
|
||||
root = _configured_root(root_provider)
|
||||
if root is None:
|
||||
return _empty_catalog(configured=False)
|
||||
items: list[dict[str, object]] = []
|
||||
invalid_total = 0
|
||||
for candidate in sorted(root.iterdir()):
|
||||
if not candidate.is_dir() or RESULT_ID.fullmatch(candidate.name) is None:
|
||||
continue
|
||||
try:
|
||||
sealed = _read_result_cached(str(candidate), _result_signature(candidate))
|
||||
items.append(_project_result(sealed))
|
||||
except (M48R3StaticOccupancyShadowError, OSError, ValueError):
|
||||
invalid_total += 1
|
||||
items.sort(
|
||||
key=lambda item: (str(item["created_at_utc"]), str(item["result_id"])),
|
||||
reverse=True,
|
||||
)
|
||||
return {
|
||||
"schema_version": RESULT_CATALOG_SCHEMA,
|
||||
"configured": True,
|
||||
"items": items[:limit],
|
||||
"candidate_total": len(items) + invalid_total,
|
||||
"invalid_total": invalid_total,
|
||||
"access": "read-only",
|
||||
}
|
||||
|
||||
@router.get("/{result_id}")
|
||||
def get_result(result_id: str) -> dict[str, object]:
|
||||
return _project_result(result(result_id))
|
||||
|
||||
@router.get("/{result_id}/cases")
|
||||
def get_cases(result_id: str) -> dict[str, object]:
|
||||
sealed = result(result_id)
|
||||
return {
|
||||
"schema_version": CASE_CATALOG_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"cases": copy.deepcopy(sealed.cases),
|
||||
"case_count": len(sealed.cases),
|
||||
"ground_truth": False,
|
||||
"authority": "replay-simulated",
|
||||
"access": "read-only",
|
||||
}
|
||||
|
||||
@router.get("/{result_id}/timeline")
|
||||
def get_timeline(result_id: str) -> dict[str, object]:
|
||||
return copy.deepcopy(timeline(result_id).metadata())
|
||||
|
||||
@router.get("/{result_id}/timeline/chunk")
|
||||
def get_timeline_chunk(
|
||||
result_id: str,
|
||||
start: int = Query(default=0, ge=0),
|
||||
count: int = Query(default=12, ge=1, le=RECORDED_SPATIAL_MAX_CHUNK_FRAMES),
|
||||
) -> Response:
|
||||
try:
|
||||
content = timeline(result_id).chunk_json(
|
||||
start_sequence=start,
|
||||
frame_count=count,
|
||||
)
|
||||
except M48sReplayTimelineError:
|
||||
raise HTTPException(status_code=404, detail="M4.8R3 chunk not found") from None
|
||||
return _immutable_json(content)
|
||||
|
||||
@router.get("/{result_id}/timeline/frames/{sequence}/camera-points")
|
||||
def get_camera_points(result_id: str, sequence: int) -> Response:
|
||||
try:
|
||||
content = timeline(result_id).camera_point_overlay_json(sequence=sequence)
|
||||
except M48sReplayTimelineError:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="M4.8R3 camera points not found",
|
||||
) from None
|
||||
return _immutable_json(content)
|
||||
|
||||
@router.get("/{result_id}/timeline/frames/{sequence}/camera")
|
||||
def get_camera(result_id: str, sequence: int) -> Response:
|
||||
if camera_frame_provider is None:
|
||||
raise HTTPException(status_code=503, detail="M4.8R3 camera decoder unavailable")
|
||||
projected = timeline(result_id)
|
||||
if not 0 <= sequence < len(projected.source_times_ns):
|
||||
raise HTTPException(status_code=404, detail="M4.8R3 frame not found")
|
||||
try:
|
||||
camera = camera_frame_provider(projected.profile.session_id, sequence)
|
||||
except (OSError, SessionIntegrityError, ValueError):
|
||||
raise HTTPException(status_code=503, detail="M4.8R3 camera unavailable") from None
|
||||
if camera.width != 800 or camera.height != 600:
|
||||
raise HTTPException(status_code=503, detail="M4.8R3 camera size changed")
|
||||
return Response(
|
||||
content=camera.payload,
|
||||
media_type=camera.media_type,
|
||||
headers={
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
"ETag": f'"{camera.sha256}"',
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _read_result_cached(
|
||||
result_root: str,
|
||||
signature: tuple[int, ...],
|
||||
) -> M48R3StaticOccupancyShadowResult:
|
||||
del signature
|
||||
return read_m48r3_static_occupancy_shadow(Path(result_root))
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _read_timeline_cached(
|
||||
repository_root: str,
|
||||
result_root: str,
|
||||
result_id: str,
|
||||
signature: tuple[int, ...],
|
||||
) -> M48sReplayTimeline:
|
||||
del signature
|
||||
return M48sReplayTimeline(
|
||||
repository_root=Path(repository_root),
|
||||
result_root=Path(result_root),
|
||||
result_id=result_id,
|
||||
frames_name="frames.jsonl",
|
||||
worker_result_name="worker-result.json",
|
||||
frame_evidence_schema=M48R3_FRAME_EVIDENCE_SCHEMA,
|
||||
frame_diff_name="frame-diff.jsonl",
|
||||
camera_endpoint_root=ENDPOINT_ROOT,
|
||||
)
|
||||
|
||||
|
||||
def _project_result(result: M48R3StaticOccupancyShadowResult) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": RESULT_VIEW_SCHEMA,
|
||||
"result_id": result.result_id,
|
||||
"created_at_utc": result.manifest["created_at_utc"],
|
||||
"accepted": result.manifest["accepted"],
|
||||
"profile": copy.deepcopy(result.report["profile"]),
|
||||
"metrics": copy.deepcopy(result.report["metrics"]),
|
||||
"gates": copy.deepcopy(result.report["gates"]),
|
||||
"decision": copy.deepcopy(result.report["decision"]),
|
||||
"limitations": copy.deepcopy(result.report["limitations"]),
|
||||
"ground_truth": False,
|
||||
"authority": copy.deepcopy(result.report["authority"]),
|
||||
"access": "read-only",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_candidate(provider: RootProvider, result_id: str) -> Path:
|
||||
if RESULT_ID.fullmatch(result_id) is None:
|
||||
raise HTTPException(status_code=404, detail="M4.8R3 result not found")
|
||||
root = _configured_root(provider)
|
||||
if root is None:
|
||||
raise HTTPException(status_code=404, detail="M4.8R3 result not found")
|
||||
candidate = root / result_id
|
||||
if candidate.is_symlink() or not candidate.is_dir():
|
||||
raise HTTPException(status_code=404, detail="M4.8R3 result not found")
|
||||
resolved = candidate.resolve(strict=True)
|
||||
if resolved.parent != root:
|
||||
raise HTTPException(status_code=404, detail="M4.8R3 result not found")
|
||||
return resolved
|
||||
|
||||
|
||||
def _configured_root(provider: RootProvider) -> Path | None:
|
||||
value = provider()
|
||||
if value is None:
|
||||
return None
|
||||
if value.is_symlink() or not value.is_dir():
|
||||
return None
|
||||
return value.resolve(strict=True)
|
||||
|
||||
|
||||
def _result_signature(candidate: Path) -> tuple[int, ...]:
|
||||
return _signature(
|
||||
candidate,
|
||||
("manifest.json", "report.json", "cases.jsonl", "worker-result.json", "frames.jsonl"),
|
||||
)
|
||||
|
||||
|
||||
def _timeline_signature(candidate: Path) -> tuple[int, ...]:
|
||||
return _signature(
|
||||
candidate,
|
||||
("worker-result.json", "frames.jsonl", "frame-diff.jsonl"),
|
||||
)
|
||||
|
||||
|
||||
def _signature(candidate: Path, names: tuple[str, ...]) -> tuple[int, ...]:
|
||||
result: list[int] = []
|
||||
for name in names:
|
||||
path = candidate / name
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ValueError("M4.8R3 artifact unavailable")
|
||||
stat = path.stat()
|
||||
result.extend((stat.st_size, stat.st_mtime_ns))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _immutable_json(content: bytes) -> Response:
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/json",
|
||||
headers={
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _empty_catalog(*, configured: bool) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": RESULT_CATALOG_SCHEMA,
|
||||
"configured": configured,
|
||||
"items": [],
|
||||
"candidate_total": 0,
|
||||
"invalid_total": 0,
|
||||
"access": "read-only",
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["build_m48r3_static_occupancy_router"]
|
||||
@@ -23,10 +23,7 @@ from k1link.perception.providers import SourcePacket
|
||||
from k1link.perception.recorded_source import RecordedFrameReference
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
PROFILE_PATH = (
|
||||
REPOSITORY_ROOT
|
||||
/ "config/perception/m48r3-additive-low-step-occupancy-v1.json"
|
||||
)
|
||||
PROFILE_PATH = REPOSITORY_ROOT / "config/perception/m48r3-additive-low-step-occupancy-v1.json"
|
||||
R2_CASES_PATH = (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime/compute-experiments/m48/static-occupancy-qualification-results"
|
||||
@@ -126,9 +123,7 @@ def test_wide_operator_region_cannot_bridge_two_spatial_components() -> None:
|
||||
|
||||
observations = provider.associate(_packet(), ())
|
||||
additive = tuple(
|
||||
item
|
||||
for item in observations
|
||||
if "additive-low-step-current-component" in item.reason_codes
|
||||
item for item in observations if "additive-low-step-current-component" in item.reason_codes
|
||||
)
|
||||
|
||||
assert len(additive) == 2
|
||||
@@ -144,6 +139,42 @@ def test_wide_operator_region_cannot_bridge_two_spatial_components() -> None:
|
||||
assert snapshot.failed_frames == 0
|
||||
|
||||
|
||||
def test_sparse_component_requires_bounded_causal_persistence() -> None:
|
||||
points = np.asarray(
|
||||
((0.00, 0.0, 5.00), (0.04, 0.0, 5.00)),
|
||||
dtype=np.float64,
|
||||
)
|
||||
store = _Store(_frame(points), np.ones(2, dtype=np.uint8))
|
||||
provider = M48AdditiveLowStepGeometryProvider( # type: ignore[arg-type]
|
||||
store=store,
|
||||
profile=load_m48_low_step_occupancy_profile(PROFILE_PATH),
|
||||
)
|
||||
|
||||
first_five = tuple(provider.associate(_packet(sequence), ()) for sequence in range(5))
|
||||
sixth = provider.associate(_packet(5), ())
|
||||
|
||||
assert first_five == ((), (), (), (), ())
|
||||
assert len(sixth) == 1
|
||||
assert sixth[0].source_point_ids == (0, 1)
|
||||
assert "additive-low-step-current-component" in sixth[0].reason_codes
|
||||
|
||||
|
||||
def test_persistent_sparse_component_is_bounded_to_critical_range() -> None:
|
||||
points = np.asarray(
|
||||
((0.00, 0.0, 8.20), (0.04, 0.0, 8.20)),
|
||||
dtype=np.float64,
|
||||
)
|
||||
store = _Store(_frame(points), np.ones(2, dtype=np.uint8))
|
||||
provider = M48AdditiveLowStepGeometryProvider( # type: ignore[arg-type]
|
||||
store=store,
|
||||
profile=load_m48_low_step_occupancy_profile(PROFILE_PATH),
|
||||
)
|
||||
|
||||
observations = tuple(provider.associate(_packet(sequence), ()) for sequence in range(6))
|
||||
|
||||
assert observations == ((), (), (), (), (), ())
|
||||
|
||||
|
||||
def test_frame_1856_preserves_baseline_posts_and_splits_low_hemisphere_support() -> None:
|
||||
store = RecordedGeometryStore.from_repository(REPOSITORY_ROOT)
|
||||
provider = M48AdditiveLowStepGeometryProvider(
|
||||
@@ -163,8 +194,7 @@ def test_frame_1856_preserves_baseline_posts_and_splits_low_hemisphere_support()
|
||||
profile=frame.projection,
|
||||
)
|
||||
source_rows = {
|
||||
int(source_index): row
|
||||
for row, source_index in enumerate(projected.source_indices)
|
||||
int(source_index): row for row, source_index in enumerate(projected.source_indices)
|
||||
}
|
||||
cases = [
|
||||
json.loads(line)
|
||||
@@ -176,14 +206,62 @@ def test_frame_1856_preserves_baseline_posts_and_splits_low_hemisphere_support()
|
||||
hemispheres = by_anchor["anchor-924a4623077fe5df18816b47"]
|
||||
|
||||
assert posts["accepted_graph"]["component_count"] >= 2
|
||||
assert _component_hits(
|
||||
observations,
|
||||
hemispheres["extent_xyxy"],
|
||||
projected.pixels_xy,
|
||||
source_rows,
|
||||
width=frame.projection.width,
|
||||
height=frame.projection.height,
|
||||
) >= 2
|
||||
assert (
|
||||
_component_hits(
|
||||
observations,
|
||||
hemispheres["extent_xyxy"],
|
||||
projected.pixels_xy,
|
||||
source_rows,
|
||||
width=frame.projection.width,
|
||||
height=frame.projection.height,
|
||||
)
|
||||
>= 2
|
||||
)
|
||||
|
||||
|
||||
def test_exact_six_frame_persistence_recovers_critical_near_anchor() -> None:
|
||||
store = RecordedGeometryStore.from_repository(REPOSITORY_ROOT)
|
||||
provider = M48AdditiveLowStepGeometryProvider(
|
||||
store=store,
|
||||
profile=load_m48_low_step_occupancy_profile(PROFILE_PATH),
|
||||
)
|
||||
observations: tuple[ObstacleObservation, ...] = ()
|
||||
for source_sequence in range(1084, 1093):
|
||||
observations = provider.associate(_packet(source_sequence), ())
|
||||
|
||||
evidence_frame = store.frame_for_index(1092)
|
||||
target_frame = store.frame_for_index(1093)
|
||||
assert evidence_frame is not None
|
||||
assert target_frame is not None
|
||||
projected = project_map_points_kb4(
|
||||
evidence_frame.points_map,
|
||||
position_map_xyz=target_frame.sensor_position_map,
|
||||
orientation_map_from_lidar_xyzw=target_frame.sensor_orientation_xyzw,
|
||||
profile=target_frame.projection,
|
||||
)
|
||||
source_rows = {
|
||||
int(source_index): row for row, source_index in enumerate(projected.source_indices)
|
||||
}
|
||||
critical = next(
|
||||
json.loads(line)
|
||||
for line in R2_CASES_PATH.read_text("utf-8").splitlines()
|
||||
if "anchor-0df056d9c565b74a25d3cca3" in line
|
||||
)
|
||||
additive = tuple(
|
||||
item for item in observations if "additive-low-step-current-component" in item.reason_codes
|
||||
)
|
||||
|
||||
assert (
|
||||
_component_hits(
|
||||
additive,
|
||||
critical["extent_xyxy"],
|
||||
projected.pixels_xy,
|
||||
source_rows,
|
||||
width=target_frame.projection.width,
|
||||
height=target_frame.projection.height,
|
||||
)
|
||||
>= 1
|
||||
)
|
||||
|
||||
|
||||
def _component_hits(
|
||||
@@ -207,9 +285,6 @@ def _component_hits(
|
||||
if not indices:
|
||||
continue
|
||||
rows = [source_rows[index] for index in indices if index in source_rows]
|
||||
if any(
|
||||
x1 <= pixels[row, 0] <= x2 and y1 <= pixels[row, 1] <= y2
|
||||
for row in rows
|
||||
):
|
||||
if any(x1 <= pixels[row, 0] <= x2 and y1 <= pixels[row, 1] <= y2 for row in rows):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from k1link.laboratory.m48r3_static_occupancy_shadow import (
|
||||
M48R3StaticOccupancyShadowResult,
|
||||
)
|
||||
from k1link.web import m48r3_static_occupancy_api as api
|
||||
|
||||
|
||||
def test_m48r3_api_projects_result_cases_and_provenance_timeline(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
result_id = "m48r3-static-occupancy-shadow-" + "0" * 64
|
||||
root = tmp_path / "results"
|
||||
result_root = root / result_id
|
||||
result_root.mkdir(parents=True)
|
||||
for name in (
|
||||
"manifest.json",
|
||||
"report.json",
|
||||
"cases.jsonl",
|
||||
"worker-result.json",
|
||||
"frames.jsonl",
|
||||
"frame-diff.jsonl",
|
||||
):
|
||||
(result_root / name).write_text("{}\n", encoding="utf-8")
|
||||
sealed = M48R3StaticOccupancyShadowResult(
|
||||
result_id=result_id,
|
||||
result_root=result_root,
|
||||
manifest={"created_at_utc": "2026-08-26T00:00:00Z", "accepted": True},
|
||||
report={
|
||||
"profile": {"id": "profile"},
|
||||
"metrics": {"frames": {"candidate_delivered": 4489}},
|
||||
"gates": {"complete_frame_accounting": True},
|
||||
"decision": {"state": "accepted-bounded-worker-shadow"},
|
||||
"limitations": ["replay only"],
|
||||
"authority": {"mode": "replay-simulated"},
|
||||
},
|
||||
cases=({"anchor_id": "anchor", "source_sequence": 1855},),
|
||||
)
|
||||
|
||||
class Timeline:
|
||||
source_times_ns = tuple(range(4489))
|
||||
profile = SimpleNamespace(session_id="20260720T065719Z_viewer_live")
|
||||
|
||||
def metadata(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": "missioncore.recorded-spatial-evidence-timeline/v1",
|
||||
"result_id": result_id,
|
||||
"occupancy_provenance_delivery": (
|
||||
"baseline-versus-additive-component-diff"
|
||||
),
|
||||
}
|
||||
|
||||
def chunk_json(self, *, start_sequence: int, frame_count: int) -> bytes:
|
||||
return json.dumps(
|
||||
{"start_sequence": start_sequence, "frame_count": frame_count}
|
||||
).encode()
|
||||
|
||||
def camera_point_overlay_json(self, *, sequence: int) -> bytes:
|
||||
return json.dumps({"sequence": sequence}).encode()
|
||||
|
||||
monkeypatch.setattr(api, "_read_result_cached", lambda *_args: sealed)
|
||||
monkeypatch.setattr(api, "_read_timeline_cached", lambda *_args: Timeline())
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
api.build_m48r3_static_occupancy_router(
|
||||
root_provider=lambda: root,
|
||||
repository_root_provider=lambda: tmp_path,
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
catalog = client.get("/api/v1/laboratory/m48r3/static-occupancy/results")
|
||||
assert catalog.status_code == 200
|
||||
assert catalog.json()["items"][0]["result_id"] == result_id
|
||||
result = client.get(f"/api/v1/laboratory/m48r3/static-occupancy/{result_id}")
|
||||
assert result.status_code == 200
|
||||
assert result.json()["accepted"] is True
|
||||
cases = client.get(f"/api/v1/laboratory/m48r3/static-occupancy/{result_id}/cases")
|
||||
assert cases.status_code == 200
|
||||
assert cases.json()["cases"][0]["source_sequence"] == 1855
|
||||
timeline = client.get(
|
||||
f"/api/v1/laboratory/m48r3/static-occupancy/{result_id}/timeline"
|
||||
)
|
||||
assert timeline.status_code == 200
|
||||
assert timeline.json()["occupancy_provenance_delivery"] == (
|
||||
"baseline-versus-additive-component-diff"
|
||||
)
|
||||
chunk = client.get(
|
||||
f"/api/v1/laboratory/m48r3/static-occupancy/{result_id}/timeline/chunk",
|
||||
params={"start": 1855, "count": 1},
|
||||
)
|
||||
assert chunk.status_code == 200
|
||||
assert chunk.json() == {"start_sequence": 1855, "frame_count": 1}
|
||||
assert (
|
||||
client.get("/api/v1/laboratory/m48r3/static-occupancy/not-a-result").status_code
|
||||
== 404
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from k1link.laboratory.m48r3_static_occupancy_shadow import (
|
||||
FRAME_EVIDENCE_SCHEMA,
|
||||
compare_m48r3_frame_ledgers,
|
||||
)
|
||||
|
||||
|
||||
def _component(component_id: str, cells: list[tuple[int, int, int]]) -> dict[str, object]:
|
||||
return {
|
||||
"component_id": component_id,
|
||||
"cells": [{"x": x, "y": y, "z": z} for x, y, z in cells],
|
||||
}
|
||||
|
||||
|
||||
def _row(
|
||||
sequence: int,
|
||||
occupied: list[dict[str, object]],
|
||||
*,
|
||||
unknown: list[dict[str, object]] | None = None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": FRAME_EVIDENCE_SCHEMA,
|
||||
"source_envelope": {"sequence": sequence},
|
||||
"delivery": {
|
||||
"obstacle_map": {
|
||||
"occupied": occupied,
|
||||
"unknown": unknown or [],
|
||||
"free_space_claimed": False,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _write(path: Path, rows: list[dict[str, object]]) -> None:
|
||||
path.write_text(
|
||||
"".join(
|
||||
json.dumps(row, sort_keys=True, separators=(",", ":")) + "\n"
|
||||
for row in rows
|
||||
),
|
||||
"utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_streaming_diff_preserves_gaps_and_marks_additive_components(tmp_path: Path) -> None:
|
||||
baseline = tmp_path / "baseline.jsonl"
|
||||
candidate = tmp_path / "candidate.jsonl"
|
||||
_write(
|
||||
baseline,
|
||||
[
|
||||
_row(0, [_component("base-0", [(0, 0, 0)])]),
|
||||
_row(1, [_component("base-1", [(10, 0, 0)])]),
|
||||
],
|
||||
)
|
||||
_write(
|
||||
candidate,
|
||||
[
|
||||
_row(0, [_component("mixed-0", [(0, 0, 0), (1, 0, 0)])]),
|
||||
_row(
|
||||
1,
|
||||
[_component("base-1", [(10, 0, 0)])],
|
||||
unknown=[_component("step-1", [(20, 0, 0)])],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
result = compare_m48r3_frame_ledgers(
|
||||
baseline,
|
||||
candidate,
|
||||
selected_sequences={1},
|
||||
expected_frames=2,
|
||||
)
|
||||
|
||||
assert result.frame_count == 2
|
||||
assert result.baseline_cell_total == 2
|
||||
assert result.candidate_cell_total == 4
|
||||
assert result.added_cell_total == 2
|
||||
assert result.lost_cell_total == 0
|
||||
assert result.mean_cell_growth_fraction == 1.0
|
||||
assert result.mean_component_growth_fraction == 0.5
|
||||
assert result.diff_rows[0]["component_provenance"] == {"mixed-0": "mixed"}
|
||||
assert result.diff_rows[1]["component_provenance"] == {
|
||||
"step-1": "additive-low-step"
|
||||
}
|
||||
assert result.diff_rows[0]["added_cells"] == [[1, 0, 0]]
|
||||
assert set(result.selected_candidate_rows) == {1}
|
||||
|
||||
@@ -93,6 +93,7 @@ class _ReadyProvider:
|
||||
self.deleted: list[str] = []
|
||||
self.upload_calls = 0
|
||||
self.submit_calls = 0
|
||||
self.submitted_document: dict[str, object] | None = None
|
||||
|
||||
def capabilities(self) -> dict[str, object]:
|
||||
return {"outputs": ["preview.sog", "streamed-sog"]}
|
||||
@@ -118,8 +119,9 @@ class _ReadyProvider:
|
||||
members=members,
|
||||
)
|
||||
|
||||
def submit_build(self, _document: dict[str, object]) -> dict[str, object]:
|
||||
def submit_build(self, document: dict[str, object]) -> dict[str, object]:
|
||||
self.submit_calls += 1
|
||||
self.submitted_document = document
|
||||
return {
|
||||
"schema_version": "gaussian-pipeline.job/v1",
|
||||
"job_id": "gsp-20260826000000-deadbeef",
|
||||
@@ -195,8 +197,28 @@ def test_service_materializes_world_manifest_and_deletes_both_copies(tmp_path: P
|
||||
assert ready["status"] == "ready"
|
||||
assert ready["world_manifest"]["visual"]["preview_sog_url"].endswith("/preview.sog")
|
||||
assert ready["world_manifest"]["collision"]["available"] is False
|
||||
assert ready["world_manifest"]["transforms"]["world_from_visual"] == [
|
||||
1, 0, 0, 0, 0, -1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1,
|
||||
]
|
||||
assert ready["world_manifest"]["transforms"]["world_from_collision"] == [
|
||||
1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1,
|
||||
]
|
||||
assert provider.upload_calls == 1
|
||||
assert provider.submit_calls == 1
|
||||
assert provider.submitted_document is not None
|
||||
assert provider.submitted_document["outputs"] == {
|
||||
"preview_sog": True,
|
||||
"streamed_sog": True,
|
||||
"collision": True,
|
||||
}
|
||||
assert provider.submitted_document["collision_profile"] == {
|
||||
"scene_type": "interior",
|
||||
"seed_position": [0, 1, 0],
|
||||
"capsule_height": 1.6,
|
||||
"capsule_radius": 0.2,
|
||||
"voxel_size": 0.05,
|
||||
"mesh_shape": "smooth",
|
||||
}
|
||||
service.delete(project["project_id"])
|
||||
assert provider.deleted == ["gsp-20260826000000-deadbeef"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user