Compare commits
8
Commits
0b01bb7775
...
424374263e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
424374263e | ||
|
|
906a6ce37b | ||
|
|
1496184167 | ||
|
|
9561a068ab | ||
|
|
9e686b3311 | ||
|
|
936ee479ed | ||
|
|
b82a97fe8b | ||
|
|
90bdc27785 |
@@ -26,6 +26,7 @@ export interface LaboratoryMetricObstacleVisual {
|
|||||||
state: "current" | "retained" | "held" | "expired";
|
state: "current" | "retained" | "held" | "expired";
|
||||||
centroidBodyXyzM: LaboratoryMetricPoint3;
|
centroidBodyXyzM: LaboratoryMetricPoint3;
|
||||||
cellCentersBodyXyzM: readonly LaboratoryMetricPoint3[];
|
cellCentersBodyXyzM: readonly LaboratoryMetricPoint3[];
|
||||||
|
occupancySource?: "baseline" | "mixed" | "additive-low-step";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LaboratoryMetricRigVisual {
|
export interface LaboratoryMetricRigVisual {
|
||||||
@@ -41,7 +42,7 @@ export interface LaboratoryMetricCorridorVisual {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LaboratoryMetricLegendEntry {
|
export interface LaboratoryMetricLegendEntry {
|
||||||
id: LaboratoryMetricDecision | "context" | "local-surface" | "rolling";
|
id: LaboratoryMetricDecision | "context" | "local-surface" | "rolling" | "low-step";
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ export function laboratoryMetricLegendEntries({
|
|||||||
showCurrentIncrement,
|
showCurrentIncrement,
|
||||||
showLocalSurface,
|
showLocalSurface,
|
||||||
showRollingMap,
|
showRollingMap,
|
||||||
|
showLowStep = true,
|
||||||
}: {
|
}: {
|
||||||
pointCloudCount: number;
|
pointCloudCount: number;
|
||||||
localSurfaceCount: number;
|
localSurfaceCount: number;
|
||||||
@@ -59,19 +61,29 @@ export function laboratoryMetricLegendEntries({
|
|||||||
showCurrentIncrement: boolean;
|
showCurrentIncrement: boolean;
|
||||||
showLocalSurface: boolean;
|
showLocalSurface: boolean;
|
||||||
showRollingMap: boolean;
|
showRollingMap: boolean;
|
||||||
|
showLowStep?: boolean;
|
||||||
}): readonly LaboratoryMetricLegendEntry[] {
|
}): readonly LaboratoryMetricLegendEntry[] {
|
||||||
const visibleObstacles = obstacles.filter((obstacle) => (
|
const visibleObstacles = obstacles.filter((obstacle) => (
|
||||||
obstacle.state === "current"
|
(showLowStep || obstacle.occupancySource === undefined || obstacle.occupancySource === "baseline")
|
||||||
|
&& (obstacle.state === "current"
|
||||||
? showCurrentIncrement
|
? showCurrentIncrement
|
||||||
: obstacle.state === "retained"
|
: obstacle.state === "retained"
|
||||||
? showRollingMap
|
? showRollingMap
|
||||||
: false
|
: false)
|
||||||
));
|
));
|
||||||
const decisions = new Set(visibleObstacles.map(({ decision }) => decision));
|
const decisions = new Set(visibleObstacles.map(({ decision }) => decision));
|
||||||
const entries: LaboratoryMetricLegendEntry[] = [];
|
const entries: LaboratoryMetricLegendEntry[] = [];
|
||||||
if (decisions.has("threat")) entries.push({ id: "threat", label: "Угроза" });
|
if (decisions.has("threat")) entries.push({ id: "threat", label: "Угроза" });
|
||||||
if (decisions.has("not-threat")) entries.push({ id: "not-threat", label: "Вне коридора" });
|
if (decisions.has("not-threat")) entries.push({ id: "not-threat", label: "Вне коридора" });
|
||||||
if (decisions.has("unknown")) entries.push({ id: "unknown", 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) {
|
if (showCurrentIncrement && pointCloudCount > 0) {
|
||||||
entries.push({ id: "context", label: "Текущий кадр" });
|
entries.push({ id: "context", label: "Текущий кадр" });
|
||||||
}
|
}
|
||||||
@@ -153,6 +165,16 @@ function decisionColor(
|
|||||||
return tokenColor(host, "--nodedc-warning-rgb", [255, 197, 92]);
|
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 {
|
export interface LaboratoryMetricEvidenceSceneHandle {
|
||||||
resetView: () => void;
|
resetView: () => void;
|
||||||
}
|
}
|
||||||
@@ -171,6 +193,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
showCurrentIncrement: boolean;
|
showCurrentIncrement: boolean;
|
||||||
showLocalSurface: boolean;
|
showLocalSurface: boolean;
|
||||||
showRollingMap: boolean;
|
showRollingMap: boolean;
|
||||||
|
showLowStep?: boolean;
|
||||||
pointSemanticClassIds?: readonly (number | null)[];
|
pointSemanticClassIds?: readonly (number | null)[];
|
||||||
semanticClasses?: readonly RecordedEvidenceSemanticClass[];
|
semanticClasses?: readonly RecordedEvidenceSemanticClass[];
|
||||||
semanticPalette?: readonly RecordedEvidenceSemanticPaletteEntry[];
|
semanticPalette?: readonly RecordedEvidenceSemanticPaletteEntry[];
|
||||||
@@ -187,6 +210,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
showCurrentIncrement,
|
showCurrentIncrement,
|
||||||
showLocalSurface,
|
showLocalSurface,
|
||||||
showRollingMap,
|
showRollingMap,
|
||||||
|
showLowStep = true,
|
||||||
pointSemanticClassIds,
|
pointSemanticClassIds,
|
||||||
semanticClasses,
|
semanticClasses,
|
||||||
semanticPalette,
|
semanticPalette,
|
||||||
@@ -352,6 +376,12 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
|
|
||||||
for (const obstacle of obstacles) {
|
for (const obstacle of obstacles) {
|
||||||
if (
|
if (
|
||||||
|
(
|
||||||
|
!showLowStep
|
||||||
|
&& obstacle.occupancySource !== undefined
|
||||||
|
&& obstacle.occupancySource !== "baseline"
|
||||||
|
)
|
||||||
|
||
|
||||||
(obstacle.state === "current" && !showCurrentIncrement)
|
(obstacle.state === "current" && !showCurrentIncrement)
|
||||||
|| (obstacle.state === "retained" && !showRollingMap)
|
|| (obstacle.state === "retained" && !showRollingMap)
|
||||||
|| obstacle.state === "held"
|
|| obstacle.state === "held"
|
||||||
@@ -359,7 +389,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const color = decisionColor(host, obstacle.decision);
|
const color = obstacleColor(host, obstacle);
|
||||||
if (obstacle.state === "retained") {
|
if (obstacle.state === "retained") {
|
||||||
const geometry = new THREE.BoxGeometry(
|
const geometry = new THREE.BoxGeometry(
|
||||||
occupiedVoxelSizeM * 0.82,
|
occupiedVoxelSizeM * 0.82,
|
||||||
@@ -424,6 +454,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
showCurrentIncrement,
|
showCurrentIncrement,
|
||||||
showLocalSurface,
|
showLocalSurface,
|
||||||
showRollingMap,
|
showRollingMap,
|
||||||
|
showLowStep,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -538,6 +569,7 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
showCurrentIncrement,
|
showCurrentIncrement,
|
||||||
showLocalSurface,
|
showLocalSurface,
|
||||||
showRollingMap,
|
showRollingMap,
|
||||||
|
showLowStep,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import {
|
import {
|
||||||
Application,
|
Application,
|
||||||
Asset,
|
Asset,
|
||||||
|
BLEND_NORMAL,
|
||||||
Color,
|
Color,
|
||||||
|
CULLFACE_NONE,
|
||||||
Entity,
|
Entity,
|
||||||
|
FILLMODE_NONE,
|
||||||
|
Mat4,
|
||||||
|
RESOLUTION_AUTO,
|
||||||
|
StandardMaterial,
|
||||||
Vec2,
|
Vec2,
|
||||||
Vec3,
|
Vec3,
|
||||||
|
type ContainerResource,
|
||||||
|
type RenderComponent,
|
||||||
type ScriptType,
|
type ScriptType,
|
||||||
} from "playcanvas";
|
} from "playcanvas";
|
||||||
import { CameraControls } from "playcanvas/scripts/esm/camera-controls.mjs";
|
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";
|
import type { SimulationWorldManifest } from "../../core/simulation/projects";
|
||||||
|
|
||||||
export type SimulationViewMode = "visual" | "collision" | "combined";
|
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 {
|
export interface SimulationRuntime {
|
||||||
mount(canvas: HTMLCanvasElement): Promise<void>;
|
mount(canvas: HTMLCanvasElement): Promise<void>;
|
||||||
loadWorld(manifest: SimulationWorldManifest): Promise<void>;
|
loadWorld(manifest: SimulationWorldManifest): Promise<void>;
|
||||||
setViewMode(mode: SimulationViewMode): void;
|
setViewMode(mode: SimulationViewMode): Promise<void>;
|
||||||
setQuality(quality: SimulationQuality): void;
|
setQuality(quality: SimulationQuality): void;
|
||||||
|
setLayerWorldTransform(layer: SimulationLayer, transform: number[]): void;
|
||||||
|
home(): void;
|
||||||
focusBounds(): void;
|
focusBounds(): void;
|
||||||
dispose(): 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 {
|
export class PlayCanvasRuntime implements SimulationRuntime {
|
||||||
private app: Application | null = null;
|
private app: Application | null = null;
|
||||||
@@ -32,15 +80,21 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
private cameraController: CameraController | null = null;
|
private cameraController: CameraController | null = null;
|
||||||
private visualEntity: Entity | null = null;
|
private visualEntity: Entity | null = null;
|
||||||
private visualAsset: Asset | 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 resizeObserver: ResizeObserver | null = null;
|
||||||
private viewMode: SimulationViewMode = "visual";
|
private viewMode: SimulationViewMode = "visual";
|
||||||
private quality: SimulationQuality = "auto";
|
private quality: SimulationQuality = "maximum";
|
||||||
|
|
||||||
async mount(canvas: HTMLCanvasElement): Promise<void> {
|
async mount(canvas: HTMLCanvasElement): Promise<void> {
|
||||||
if (this.app) throw new Error("PlayCanvas runtime уже смонтирован.");
|
if (this.app) throw new Error("PlayCanvas runtime уже смонтирован.");
|
||||||
this.canvas = canvas;
|
this.canvas = canvas;
|
||||||
canvas.tabIndex = 0;
|
canvas.tabIndex = 0;
|
||||||
canvas.addEventListener("contextmenu", preventContextMenu);
|
canvas.addEventListener("contextmenu", preventContextMenu);
|
||||||
|
canvas.addEventListener("pointerdown", focusCanvas, true);
|
||||||
const app = new Application(canvas, {
|
const app = new Application(canvas, {
|
||||||
graphicsDeviceOptions: {
|
graphicsDeviceOptions: {
|
||||||
antialias: true,
|
antialias: true,
|
||||||
@@ -50,6 +104,8 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.app = app;
|
this.app = app;
|
||||||
|
app.setCanvasFillMode(FILLMODE_NONE, 1, 1);
|
||||||
|
app.setCanvasResolution(RESOLUTION_AUTO);
|
||||||
app.scene.ambientLight = new Color(0.35, 0.37, 0.42);
|
app.scene.ambientLight = new Color(0.35, 0.37, 0.42);
|
||||||
|
|
||||||
const camera = new Entity("SimulationCamera");
|
const camera = new Entity("SimulationCamera");
|
||||||
@@ -59,8 +115,8 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
farClip: 20_000,
|
farClip: 20_000,
|
||||||
fov: 58,
|
fov: 58,
|
||||||
});
|
});
|
||||||
camera.setPosition(5, 3, 5);
|
camera.setPosition(HOME_POSITION);
|
||||||
camera.lookAt(0, 0, 0);
|
camera.lookAt(HOME_FOCUS);
|
||||||
camera.addComponent("script");
|
camera.addComponent("script");
|
||||||
const controller = camera.script?.create(CameraControls, {
|
const controller = camera.script?.create(CameraControls, {
|
||||||
properties: {
|
properties: {
|
||||||
@@ -70,6 +126,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
zoomRange: new Vec2(0.05, 20_000),
|
zoomRange: new Vec2(0.05, 20_000),
|
||||||
},
|
},
|
||||||
}) as CameraController | null;
|
}) as CameraController | null;
|
||||||
|
if (controller) invertHorizontalCameraDrag(controller);
|
||||||
app.root.addChild(camera);
|
app.root.addChild(camera);
|
||||||
this.camera = camera;
|
this.camera = camera;
|
||||||
this.cameraController = controller;
|
this.cameraController = controller;
|
||||||
@@ -103,6 +160,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
const ready = (loaded: Asset) => {
|
const ready = (loaded: Asset) => {
|
||||||
const visual = new Entity("GaussianWorld");
|
const visual = new Entity("GaussianWorld");
|
||||||
visual.enabled = false;
|
visual.enabled = false;
|
||||||
|
applyWorldTransform(visual, manifest.transforms.worldFromVisual);
|
||||||
app.root.addChild(visual);
|
app.root.addChild(visual);
|
||||||
visual.addComponent("gsplat", {
|
visual.addComponent("gsplat", {
|
||||||
asset: loaded,
|
asset: loaded,
|
||||||
@@ -112,7 +170,6 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
this.visualEntity = visual;
|
this.visualEntity = visual;
|
||||||
this.applyQuality();
|
this.applyQuality();
|
||||||
this.applyViewMode();
|
this.applyViewMode();
|
||||||
this.focusBounds();
|
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
const failed = (error: unknown) => {
|
const failed = (error: unknown) => {
|
||||||
@@ -122,11 +179,24 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
asset.once("error", failed);
|
asset.once("error", failed);
|
||||||
app.assets.load(asset);
|
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.viewMode = mode;
|
||||||
this.applyViewMode();
|
this.applyViewMode();
|
||||||
|
if (mode !== "visual" && !this.collisionEntity) {
|
||||||
|
await this.ensureCollision();
|
||||||
|
this.applyViewMode();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setQuality(quality: SimulationQuality): void {
|
setQuality(quality: SimulationQuality): void {
|
||||||
@@ -134,6 +204,28 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
this.applyQuality();
|
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 {
|
focusBounds(): void {
|
||||||
const bounds = this.visualEntity?.gsplat?.customAabb;
|
const bounds = this.visualEntity?.gsplat?.customAabb;
|
||||||
const focus = bounds?.center.clone() ?? new Vec3(0, 0, 0);
|
const focus = bounds?.center.clone() ?? new Vec3(0, 0, 0);
|
||||||
@@ -153,7 +245,10 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
this.resizeObserver = null;
|
this.resizeObserver = null;
|
||||||
this.unloadWorld();
|
this.unloadWorld();
|
||||||
if (this.app) this.app.destroy();
|
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.app = null;
|
||||||
this.canvas = null;
|
this.canvas = null;
|
||||||
this.camera = null;
|
this.camera = null;
|
||||||
@@ -175,22 +270,100 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
|
|
||||||
private applyViewMode(): void {
|
private applyViewMode(): void {
|
||||||
if (this.visualEntity) {
|
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 {
|
private applyQuality(): void {
|
||||||
const gsplat = this.visualEntity?.gsplat;
|
const gsplat = this.visualEntity?.gsplat;
|
||||||
if (!gsplat) return;
|
const profile = QUALITY_PROFILES[this.quality];
|
||||||
const profiles: Record<SimulationQuality, [number, number]> = {
|
if (gsplat) {
|
||||||
auto: [5, 2],
|
gsplat.lodBaseDistance = profile.lodBaseDistance;
|
||||||
low: [2.5, 1.7],
|
gsplat.lodMultiplier = profile.lodMultiplier;
|
||||||
medium: [5, 2],
|
gsplat.lodRangeMin = profile.lodRangeMin;
|
||||||
high: [9, 2.3],
|
gsplat.lodRangeMax = profile.lodRangeMax;
|
||||||
};
|
}
|
||||||
const [baseDistance, multiplier] = profiles[this.quality];
|
if (this.app) {
|
||||||
gsplat.lodBaseDistance = baseDistance;
|
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||||
gsplat.lodMultiplier = multiplier;
|
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 {
|
private unloadWorld(): void {
|
||||||
@@ -203,9 +376,46 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
this.visualAsset.unload();
|
this.visualAsset.unload();
|
||||||
this.visualAsset = null;
|
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 {
|
function preventContextMenu(event: Event): void {
|
||||||
event.preventDefault();
|
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 { useEffect, useId, useRef, useState } from "react";
|
||||||
import { ActivityIndicator, Button, SegmentedControl, StatusBadge } from "@nodedc/ui-react";
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Button,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
SegmentedControl,
|
||||||
|
Select,
|
||||||
|
StatusBadge,
|
||||||
|
Switch,
|
||||||
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
import type { SimulationProject } from "../../core/simulation/projects";
|
import type { SimulationProject } from "../../core/simulation/projects";
|
||||||
import {
|
import {
|
||||||
|
PLAYCANVAS_IDENTITY_TRANSFORM,
|
||||||
|
PLAYCANVAS_X_180_TRANSFORM,
|
||||||
PlayCanvasRuntime,
|
PlayCanvasRuntime,
|
||||||
type SimulationQuality,
|
type SimulationQuality,
|
||||||
type SimulationViewMode,
|
type SimulationViewMode,
|
||||||
@@ -11,10 +22,25 @@ import {
|
|||||||
export function SimulationViewport({ project }: { project: SimulationProject }) {
|
export function SimulationViewport({ project }: { project: SimulationProject }) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const runtimeRef = useRef<PlayCanvasRuntime | null>(null);
|
const runtimeRef = useRef<PlayCanvasRuntime | null>(null);
|
||||||
|
const settingsId = useId();
|
||||||
const [state, setState] = useState<"mounting" | "loading" | "ready" | "failed">("mounting");
|
const [state, setState] = useState<"mounting" | "loading" | "ready" | "failed">("mounting");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [viewMode, setViewMode] = useState<SimulationViewMode>("visual");
|
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(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -48,17 +74,53 @@ export function SimulationViewport({ project }: { project: SimulationProject })
|
|||||||
<section className="simulation-viewport" aria-label={`Сцена ${project.name}`}>
|
<section className="simulation-viewport" aria-label={`Сцена ${project.name}`}>
|
||||||
<header className="simulation-viewport__toolbar">
|
<header className="simulation-viewport__toolbar">
|
||||||
<div>
|
<div>
|
||||||
<StatusBadge tone={state === "ready" ? "success" : state === "failed" ? "warning" : "accent"}>
|
<StatusBadge tone={state === "failed" || collisionState === "failed" ? "warning" : state === "ready" && collisionState !== "loading" ? "success" : "accent"}>
|
||||||
{state === "ready" ? "Runtime готов" : state === "failed" ? "Ошибка runtime" : "Загрузка сцены"}
|
{collisionState === "loading"
|
||||||
|
? "Загрузка collision"
|
||||||
|
: collisionState === "failed"
|
||||||
|
? "Ошибка collision"
|
||||||
|
: state === "ready"
|
||||||
|
? "Runtime готов"
|
||||||
|
: state === "failed"
|
||||||
|
? "Ошибка runtime"
|
||||||
|
: "Загрузка сцены"}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
<span>PlayCanvas Engine 2.21.4</span>
|
<span>PlayCanvas Engine 2.21.4</span>
|
||||||
</div>
|
</div>
|
||||||
|
<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
|
<SegmentedControl
|
||||||
label="Слой сцены"
|
label="Слой сцены"
|
||||||
value={viewMode}
|
value={viewMode}
|
||||||
onChange={(next) => {
|
onChange={(next) => {
|
||||||
setViewMode(next);
|
setViewMode(next);
|
||||||
runtimeRef.current?.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={[
|
items={[
|
||||||
{ value: "visual", label: "Визуал" },
|
{ value: "visual", label: "Визуал" },
|
||||||
@@ -66,23 +128,70 @@ export function SimulationViewport({ project }: { project: SimulationProject })
|
|||||||
{ value: "combined", label: "Вместе", disabled: !collisionAvailable },
|
{ value: "combined", label: "Вместе", disabled: !collisionAvailable },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<SegmentedControl
|
<Button size="compact" variant="secondary" onClick={() => runtimeRef.current?.home()}>
|
||||||
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>
|
</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>
|
</header>
|
||||||
<div className="simulation-viewport__stage">
|
<div className="simulation-viewport__stage">
|
||||||
<canvas ref={canvasRef} aria-label={`PlayCanvas сцена ${project.name}`} />
|
<canvas ref={canvasRef} aria-label={`PlayCanvas сцена ${project.name}`} />
|
||||||
@@ -99,7 +208,17 @@ export function SimulationViewport({ project }: { project: SimulationProject })
|
|||||||
<StatusBadge tone="warning">Collision недоступен</StatusBadge>
|
<StatusBadge tone="warning">Collision недоступен</StatusBadge>
|
||||||
<span>Для этой сборки collision GLB не был запрошен; визуальный слой настоящий и не подменяется.</span>
|
<span>Для этой сборки collision GLB не был запрошен; визуальный слой настоящий и не подменяется.</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
) : collisionState === "failed" ? (
|
||||||
|
<footer className="simulation-viewport__notice" role="alert">
|
||||||
|
<StatusBadge tone="warning">Collision не открылся</StatusBadge>
|
||||||
|
<span>{error ?? "PlayCanvas не смог загрузить collision GLB."}</span>
|
||||||
|
</footer>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</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";
|
} from "./m48ObjectCentricQuality";
|
||||||
import { fetchM48SmallStaticRegression } from "./m48SmallStaticRegression";
|
import { fetchM48SmallStaticRegression } from "./m48SmallStaticRegression";
|
||||||
import { fetchM48StaticOccupancyQualification } from "./m48StaticOccupancyQualification";
|
import { fetchM48StaticOccupancyQualification } from "./m48StaticOccupancyQualification";
|
||||||
|
import { fetchM48R3StaticOccupancyShadow } from "./m48r3StaticOccupancyShadow";
|
||||||
import { fetchM48SFixedClassDetectorResult } from "./m48sFixedClassDetector";
|
import { fetchM48SFixedClassDetectorResult } from "./m48sFixedClassDetector";
|
||||||
import { fetchM48TRiskQualityResult } from "./m48tRiskQuality";
|
import { fetchM48TRiskQualityResult } from "./m48tRiskQuality";
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ export type AdvancedLaboratoryWorkId =
|
|||||||
| "m48-object-centric-quality"
|
| "m48-object-centric-quality"
|
||||||
| "m48-small-static-passage-regression"
|
| "m48-small-static-passage-regression"
|
||||||
| "m48-static-occupancy-qualification"
|
| "m48-static-occupancy-qualification"
|
||||||
|
| "m48r3-static-occupancy-shadow"
|
||||||
| "m48s-fixed-class-detector"
|
| "m48s-fixed-class-detector"
|
||||||
| "m48t-risk-quality-temporal"
|
| "m48t-risk-quality-temporal"
|
||||||
| "m47-reference-graph-shadow"
|
| "m47-reference-graph-shadow"
|
||||||
@@ -97,6 +99,7 @@ const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [
|
|||||||
"m48-object-centric-quality",
|
"m48-object-centric-quality",
|
||||||
"m48-small-static-passage-regression",
|
"m48-small-static-passage-regression",
|
||||||
"m48-static-occupancy-qualification",
|
"m48-static-occupancy-qualification",
|
||||||
|
"m48r3-static-occupancy-shadow",
|
||||||
"m48s-fixed-class-detector",
|
"m48s-fixed-class-detector",
|
||||||
"m48t-risk-quality-temporal",
|
"m48t-risk-quality-temporal",
|
||||||
"m47-reference-graph-shadow",
|
"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-object-centric-quality": "m48-object-quality-(?:pack|result)",
|
||||||
"m48-small-static-passage-regression": "m48-small-static-passage-regression",
|
"m48-small-static-passage-regression": "m48-small-static-passage-regression",
|
||||||
"m48-static-occupancy-qualification": "m48-static-occupancy-qualification",
|
"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",
|
"m48s-fixed-class-detector": "m48s-fixed-class-detector-lab",
|
||||||
"m48t-risk-quality-temporal": "(?:m48t-risk-quality-temporal-lab|m48q-native-risk-quality-lab)",
|
"m48t-risk-quality-temporal": "(?:m48t-risk-quality-temporal-lab|m48q-native-risk-quality-lab)",
|
||||||
"m47-reference-graph-shadow": "m47-reference-graph-lab",
|
"m47-reference-graph-shadow": "m47-reference-graph-lab",
|
||||||
@@ -189,6 +193,7 @@ export function emptyAdvancedLaboratoryResults(): AdvancedLaboratoryResults {
|
|||||||
m48: null,
|
m48: null,
|
||||||
m48SmallStatic: null,
|
m48SmallStatic: null,
|
||||||
m48StaticOccupancy: null,
|
m48StaticOccupancy: null,
|
||||||
|
m48r3StaticOccupancy: null,
|
||||||
m48s: null,
|
m48s: null,
|
||||||
m48t: null,
|
m48t: null,
|
||||||
m4Threat: null,
|
m4Threat: null,
|
||||||
@@ -318,6 +323,7 @@ export function advancedLaboratoryResultAvailable(
|
|||||||
return workId === "m48-object-centric-quality" ? results.m48 !== null
|
return workId === "m48-object-centric-quality" ? results.m48 !== null
|
||||||
: workId === "m48-small-static-passage-regression" ? results.m48SmallStatic !== null
|
: workId === "m48-small-static-passage-regression" ? results.m48SmallStatic !== null
|
||||||
: workId === "m48-static-occupancy-qualification" ? results.m48StaticOccupancy !== 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 === "m48s-fixed-class-detector" ? results.m48s !== null
|
||||||
: workId === "m48t-risk-quality-temporal" ? results.m48t !== null
|
: workId === "m48t-risk-quality-temporal" ? results.m48t !== null
|
||||||
: workId === "m47-reference-graph-shadow" ? results.m47Graph !== null
|
: workId === "m47-reference-graph-shadow" ? results.m47Graph !== null
|
||||||
@@ -378,6 +384,9 @@ export async function fetchAdvancedLaboratoryResult(
|
|||||||
} else if (workId === "m48-static-occupancy-qualification") {
|
} else if (workId === "m48-static-occupancy-qualification") {
|
||||||
if (!resultId) throw new AdvancedLaboratoryContractError("M4.8R2 qualification identity не выбрана.");
|
if (!resultId) throw new AdvancedLaboratoryContractError("M4.8R2 qualification identity не выбрана.");
|
||||||
results.m48StaticOccupancy = await fetchM48StaticOccupancyQualification(resultId, { fetcher, signal });
|
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") {
|
} else if (workId === "m48s-fixed-class-detector") {
|
||||||
if (!resultId) throw new AdvancedLaboratoryContractError("M4.8S LAB identity не выбрана.");
|
if (!resultId) throw new AdvancedLaboratoryContractError("M4.8S LAB identity не выбрана.");
|
||||||
results.m48s = await fetchM48SFixedClassDetectorResult(resultId, { fetcher, signal });
|
results.m48s = await fetchM48SFixedClassDetectorResult(resultId, { fetcher, signal });
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import type { M47ReferenceGraphLabResult } from "./m47ReferenceGraph";
|
|||||||
import type { M48AdvancedResult } from "./m48ObjectCentricQuality";
|
import type { M48AdvancedResult } from "./m48ObjectCentricQuality";
|
||||||
import type { M48SmallStaticRegressionResult } from "./m48SmallStaticRegression";
|
import type { M48SmallStaticRegressionResult } from "./m48SmallStaticRegression";
|
||||||
import type { M48StaticOccupancyQualificationResult } from "./m48StaticOccupancyQualification";
|
import type { M48StaticOccupancyQualificationResult } from "./m48StaticOccupancyQualification";
|
||||||
|
import type { M48R3StaticOccupancyShadowResult } from "./m48r3StaticOccupancyShadow";
|
||||||
import type { M48SFixedClassDetectorResult } from "./m48sFixedClassDetector";
|
import type { M48SFixedClassDetectorResult } from "./m48sFixedClassDetector";
|
||||||
import type { M48TRiskQualityResult } from "./m48tRiskQuality";
|
import type { M48TRiskQualityResult } from "./m48tRiskQuality";
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ export interface AdvancedLaboratoryResults {
|
|||||||
m48: M48AdvancedResult | null;
|
m48: M48AdvancedResult | null;
|
||||||
m48SmallStatic: M48SmallStaticRegressionResult | null;
|
m48SmallStatic: M48SmallStaticRegressionResult | null;
|
||||||
m48StaticOccupancy: M48StaticOccupancyQualificationResult | null;
|
m48StaticOccupancy: M48StaticOccupancyQualificationResult | null;
|
||||||
|
m48r3StaticOccupancy: M48R3StaticOccupancyShadowResult | null;
|
||||||
m48s: M48SFixedClassDetectorResult | null;
|
m48s: M48SFixedClassDetectorResult | null;
|
||||||
m48t: M48TRiskQualityResult | null;
|
m48t: M48TRiskQualityResult | null;
|
||||||
m4Threat: M4ThreatReplayResult | null;
|
m4Threat: M4ThreatReplayResult | null;
|
||||||
|
|||||||
@@ -968,6 +968,7 @@ export async function fetchAdvancedLaboratoryResults({
|
|||||||
const e40 = settledCatalogValue(settled[8]);
|
const e40 = settledCatalogValue(settled[8]);
|
||||||
return {
|
return {
|
||||||
m47Graph: null, m48: null, m48SmallStatic: null, m48StaticOccupancy: null,
|
m47Graph: null, m48: null, m48SmallStatic: null, m48StaticOccupancy: null,
|
||||||
|
m48r3StaticOccupancy: null,
|
||||||
m48s: null, m48t: null, m4Threat: null,
|
m48s: null, m48t: null, m4Threat: null,
|
||||||
l3: null, l31: null, l32: null, l33: null,
|
l3: null, l31: null, l32: null, l33: null,
|
||||||
e31,
|
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 M4ThreatDecision = "threat" | "not-threat" | "unknown";
|
||||||
export type M4ThreatMotion = "moving" | "stationary" | "unknown";
|
export type M4ThreatMotion = "moving" | "stationary" | "unknown";
|
||||||
|
export type M4OccupancySource = "baseline" | "mixed" | "additive-low-step";
|
||||||
export type M4Point3 = readonly [number, number, number];
|
export type M4Point3 = readonly [number, number, number];
|
||||||
export type M4Matrix3 = readonly [M4Point3, M4Point3, M4Point3];
|
export type M4Matrix3 = readonly [M4Point3, M4Point3, M4Point3];
|
||||||
|
|
||||||
@@ -76,6 +77,7 @@ export interface M4ThreatMetricVisual {
|
|||||||
centroidBodyXyzM: M4Point3;
|
centroidBodyXyzM: M4Point3;
|
||||||
cellCentersBodyXyzM: readonly M4Point3[];
|
cellCentersBodyXyzM: readonly M4Point3[];
|
||||||
assessment: M4ThreatAssessment;
|
assessment: M4ThreatAssessment;
|
||||||
|
occupancySource: M4OccupancySource;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface M4ThreatCameraProposal {
|
export interface M4ThreatCameraProposal {
|
||||||
@@ -174,6 +176,7 @@ export interface M4ThreatTimeline {
|
|||||||
cameraPointWindowSeconds: number;
|
cameraPointWindowSeconds: number;
|
||||||
cameraPointSampleLimit: number;
|
cameraPointSampleLimit: number;
|
||||||
worldStateDelivery: "source-paced-latest-wins" | null;
|
worldStateDelivery: "source-paced-latest-wins" | null;
|
||||||
|
occupancyProvenanceDelivery: "baseline-versus-additive-component-diff" | null;
|
||||||
worldStateFrameCount: number;
|
worldStateFrameCount: number;
|
||||||
supersededFrameCount: number;
|
supersededFrameCount: number;
|
||||||
sourceRepresentationId: "registered-map-increment-v1";
|
sourceRepresentationId: "registered-map-increment-v1";
|
||||||
@@ -331,6 +334,9 @@ function parseMetricVisual(value: unknown): M4ThreatMetricVisual {
|
|||||||
) {
|
) {
|
||||||
throw new M4ThreatContractError("M4.6 temporal state: неизвестное состояние.");
|
throw new M4ThreatContractError("M4.6 temporal state: неизвестное состояние.");
|
||||||
}
|
}
|
||||||
|
const occupancySource = item.occupancy_source === undefined
|
||||||
|
? "baseline"
|
||||||
|
: memberOccupancySource(item.occupancy_source);
|
||||||
return {
|
return {
|
||||||
componentId: text(item.component_id, "M4.6 visual component"),
|
componentId: text(item.component_id, "M4.6 visual component"),
|
||||||
state,
|
state,
|
||||||
@@ -340,9 +346,17 @@ function parseMetricVisual(value: unknown): M4ThreatMetricVisual {
|
|||||||
(point) => vector(point, 3, "M4.6 cell") as [number, number, number],
|
(point) => vector(point, 3, "M4.6 cell") as [number, number, number],
|
||||||
),
|
),
|
||||||
assessment: parseAssessment(item.assessment),
|
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({
|
export async function fetchM4ThreatReplayResult({
|
||||||
resultId: requestedResultId,
|
resultId: requestedResultId,
|
||||||
fetcher = fetch,
|
fetcher = fetch,
|
||||||
@@ -683,6 +697,13 @@ export async function fetchM4ThreatTimeline(
|
|||||||
"source-paced-latest-wins",
|
"source-paced-latest-wins",
|
||||||
"M4.6 world-state delivery",
|
"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
|
worldStateFrameCount: payload.world_state_frame_count === undefined
|
||||||
? frameCount
|
? frameCount
|
||||||
: integer(payload.world_state_frame_count, "M4.6 world-state frames"),
|
: integer(payload.world_state_frame_count, "M4.6 world-state frames"),
|
||||||
|
|||||||
@@ -334,6 +334,12 @@
|
|||||||
background: transparent;
|
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 {
|
.laboratory-metric-evidence-scene__legend span[data-decision="local-surface"]::before {
|
||||||
background: rgb(var(--nodedc-accent-rgb));
|
background: rgb(var(--nodedc-accent-rgb));
|
||||||
opacity: 0.72;
|
opacity: 0.72;
|
||||||
|
|||||||
@@ -332,6 +332,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.simulation-viewport {
|
.simulation-viewport {
|
||||||
|
container-type: inline-size;
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
@@ -341,7 +342,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.simulation-viewport__toolbar {
|
.simulation-viewport__toolbar {
|
||||||
flex-wrap: wrap;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(12rem, 1fr) auto minmax(12rem, 1fr);
|
||||||
|
align-items: center;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
border-bottom: 1px solid var(--station-hairline);
|
border-bottom: 1px solid var(--station-hairline);
|
||||||
background: var(--nodedc-glass-panel-bg-soft);
|
background: var(--nodedc-glass-panel-bg-soft);
|
||||||
@@ -356,6 +359,74 @@
|
|||||||
gap: 0.45rem;
|
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__toolbar > div:first-child > span:last-child,
|
||||||
.simulation-viewport__notice > span:last-child {
|
.simulation-viewport__notice > span:last-child {
|
||||||
color: var(--nodedc-text-muted);
|
color: var(--nodedc-text-muted);
|
||||||
@@ -364,14 +435,24 @@
|
|||||||
|
|
||||||
.simulation-viewport__stage {
|
.simulation-viewport__stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
min-height: 30rem;
|
min-height: 30rem;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.simulation-viewport__stage canvas {
|
.simulation-viewport__stage canvas {
|
||||||
display: block;
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-viewport__stage canvas:active {
|
||||||
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
.simulation-viewport__stage canvas:focus-visible {
|
.simulation-viewport__stage canvas:focus-visible {
|
||||||
@@ -407,6 +488,21 @@
|
|||||||
padding: 0.48rem 0.65rem;
|
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 {
|
.simulation-workspace__processing {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 18rem;
|
min-height: 18rem;
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { M47ReferenceGraphResultView } from "./M47ReferenceGraphResult";
|
|||||||
import { M48ObjectCentricQualityResultView } from "./M48ObjectCentricQualityResult";
|
import { M48ObjectCentricQualityResultView } from "./M48ObjectCentricQualityResult";
|
||||||
import { M48SmallStaticPassageRegressionResultView } from "./M48SmallStaticPassageRegressionResult";
|
import { M48SmallStaticPassageRegressionResultView } from "./M48SmallStaticPassageRegressionResult";
|
||||||
import { M48StaticOccupancyQualificationResultView } from "./M48StaticOccupancyQualificationResult";
|
import { M48StaticOccupancyQualificationResultView } from "./M48StaticOccupancyQualificationResult";
|
||||||
|
import { M48R3StaticOccupancyShadowResultView } from "./M48R3StaticOccupancyShadowResult";
|
||||||
import { M48SFixedClassDetectorResultView } from "./M48SFixedClassDetectorResult";
|
import { M48SFixedClassDetectorResultView } from "./M48SFixedClassDetectorResult";
|
||||||
import { M48TRiskQualityResultView } from "./M48TRiskQualityResult";
|
import { M48TRiskQualityResultView } from "./M48TRiskQualityResult";
|
||||||
|
|
||||||
@@ -98,6 +99,9 @@ export function AdvancedLaboratoryResult({
|
|||||||
if (workId === "m48-static-occupancy-qualification" && results.m48StaticOccupancy) {
|
if (workId === "m48-static-occupancy-qualification" && results.m48StaticOccupancy) {
|
||||||
return <M48StaticOccupancyQualificationResultView rigLabel={rigLabel} result={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) {
|
if (workId === "m48s-fixed-class-detector" && results.m48s) {
|
||||||
return <M48SFixedClassDetectorResultView rigLabel={rigLabel} result={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,
|
resultId,
|
||||||
semantic,
|
semantic,
|
||||||
reviewAnchors = EMPTY_REVIEW_ANCHORS,
|
reviewAnchors = EMPTY_REVIEW_ANCHORS,
|
||||||
|
showReviewAnchorBoxes = true,
|
||||||
|
reviewLabel = "Контрольные примеры M4.8R1",
|
||||||
timelineEndpointRoot,
|
timelineEndpointRoot,
|
||||||
evidenceLabel = "M4.6",
|
evidenceLabel = "M4.6",
|
||||||
}: {
|
}: {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
semantic?: M4ReplayThreatSemanticLayer;
|
semantic?: M4ReplayThreatSemanticLayer;
|
||||||
reviewAnchors?: readonly M4ReplayThreatReviewAnchor[];
|
reviewAnchors?: readonly M4ReplayThreatReviewAnchor[];
|
||||||
|
showReviewAnchorBoxes?: boolean;
|
||||||
|
reviewLabel?: string;
|
||||||
timelineEndpointRoot?: string;
|
timelineEndpointRoot?: string;
|
||||||
evidenceLabel?: string;
|
evidenceLabel?: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -124,6 +128,7 @@ export function M4ReplayThreatVisual({
|
|||||||
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
|
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
|
||||||
const [showLocalSurface, setShowLocalSurface] = useState(true);
|
const [showLocalSurface, setShowLocalSurface] = useState(true);
|
||||||
const [showRollingMap, setShowRollingMap] = useState(true);
|
const [showRollingMap, setShowRollingMap] = useState(true);
|
||||||
|
const [showLowStep, setShowLowStep] = useState(true);
|
||||||
const [showMediaSemantic, setShowMediaSemantic] = useState(true);
|
const [showMediaSemantic, setShowMediaSemantic] = useState(true);
|
||||||
const [showSpatialSemantic, setShowSpatialSemantic] = useState(true);
|
const [showSpatialSemantic, setShowSpatialSemantic] = useState(true);
|
||||||
const [showMediaPoints, setShowMediaPoints] = useState(false);
|
const [showMediaPoints, setShowMediaPoints] = useState(false);
|
||||||
@@ -262,7 +267,7 @@ export function M4ReplayThreatVisual({
|
|||||||
}, [metadata.timeline, resultId, reviewAnchorIdentity, reviewAnchors, seekPlayback, setPlaybackPlaying]);
|
}, [metadata.timeline, resultId, reviewAnchorIdentity, reviewAnchors, seekPlayback, setPlaybackPlaying]);
|
||||||
const reviewAnchorBoxes = useMemo<readonly RecordedEvidenceBox[]>(() => {
|
const reviewAnchorBoxes = useMemo<readonly RecordedEvidenceBox[]>(() => {
|
||||||
const timeline = metadata.timeline;
|
const timeline = metadata.timeline;
|
||||||
if (!frame || !timeline) return [];
|
if (!frame || !timeline || !showReviewAnchorBoxes) return [];
|
||||||
return reviewAnchors
|
return reviewAnchors
|
||||||
.filter((anchor) => anchor.sourceSequence === frame.sequence)
|
.filter((anchor) => anchor.sourceSequence === frame.sequence)
|
||||||
.map((anchor) => {
|
.map((anchor) => {
|
||||||
@@ -281,7 +286,7 @@ export function M4ReplayThreatVisual({
|
|||||||
dashed: true,
|
dashed: true,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [frame, metadata.timeline, reviewAnchors]);
|
}, [frame, metadata.timeline, reviewAnchors, showReviewAnchorBoxes]);
|
||||||
const activeBoxes = useMemo(
|
const activeBoxes = useMemo(
|
||||||
() => [...boxes(frame?.cameraProposals ?? []), ...reviewAnchorBoxes],
|
() => [...boxes(frame?.cameraProposals ?? []), ...reviewAnchorBoxes],
|
||||||
[frame, reviewAnchorBoxes],
|
[frame, reviewAnchorBoxes],
|
||||||
@@ -345,6 +350,7 @@ export function M4ReplayThreatVisual({
|
|||||||
state: obstacle.state,
|
state: obstacle.state,
|
||||||
centroidBodyXyzM: obstacle.centroidBodyXyzM,
|
centroidBodyXyzM: obstacle.centroidBodyXyzM,
|
||||||
cellCentersBodyXyzM: obstacle.cellCentersBodyXyzM,
|
cellCentersBodyXyzM: obstacle.cellCentersBodyXyzM,
|
||||||
|
occupancySource: obstacle.occupancySource,
|
||||||
})) ?? [], [spatialFrame]);
|
})) ?? [], [spatialFrame]);
|
||||||
const currentIncrementObstacles = spatialFrame?.metricObstacles.filter(
|
const currentIncrementObstacles = spatialFrame?.metricObstacles.filter(
|
||||||
(item) => item.state === "current",
|
(item) => item.state === "current",
|
||||||
@@ -352,6 +358,9 @@ export function M4ReplayThreatVisual({
|
|||||||
const rollingMapObstacles = spatialFrame?.metricObstacles.filter(
|
const rollingMapObstacles = spatialFrame?.metricObstacles.filter(
|
||||||
(item) => item.state === "retained",
|
(item) => item.state === "retained",
|
||||||
) ?? [];
|
) ?? [];
|
||||||
|
const lowStepObstacles = spatialFrame?.metricObstacles.filter(
|
||||||
|
(item) => item.occupancySource !== "baseline",
|
||||||
|
) ?? [];
|
||||||
const nearest = spatialFrame?.metricObstacles
|
const nearest = spatialFrame?.metricObstacles
|
||||||
.map((item) => item.assessment.closestApproachM)
|
.map((item) => item.assessment.closestApproachM)
|
||||||
.filter((value): value is number => value !== null)
|
.filter((value): value is number => value !== null)
|
||||||
@@ -513,6 +522,18 @@ export function M4ReplayThreatVisual({
|
|||||||
>
|
>
|
||||||
ROLLING
|
ROLLING
|
||||||
</Button>
|
</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 ? (
|
{semantic ? (
|
||||||
<Button
|
<Button
|
||||||
size="compact"
|
size="compact"
|
||||||
@@ -567,7 +588,7 @@ export function M4ReplayThreatVisual({
|
|||||||
<Icon name="chevron-right" size={16} />
|
<Icon name="chevron-right" size={16} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Select
|
<Select
|
||||||
label="Контрольные примеры M4.8R1"
|
label={reviewLabel}
|
||||||
value={String(selectedReviewAnchorIndex)}
|
value={String(selectedReviewAnchorIndex)}
|
||||||
options={reviewAnchors.map((anchor, index) => ({
|
options={reviewAnchors.map((anchor, index) => ({
|
||||||
value: String(index),
|
value: String(index),
|
||||||
@@ -618,6 +639,9 @@ export function M4ReplayThreatVisual({
|
|||||||
<span>Spatial evidence</span>
|
<span>Spatial evidence</span>
|
||||||
<strong>
|
<strong>
|
||||||
{currentIncrementObstacles.length} current · {rollingMapObstacles.length} rolling
|
{currentIncrementObstacles.length} current · {rollingMapObstacles.length} rolling
|
||||||
|
{metadata.timeline.occupancyProvenanceDelivery
|
||||||
|
? ` · ${lowStepObstacles.length} low-step`
|
||||||
|
: ""}
|
||||||
</strong>
|
</strong>
|
||||||
<small>
|
<small>
|
||||||
{spatialFrame
|
{spatialFrame
|
||||||
@@ -758,6 +782,7 @@ export function M4ReplayThreatVisual({
|
|||||||
showCurrentIncrement={showCurrentIncrement}
|
showCurrentIncrement={showCurrentIncrement}
|
||||||
showLocalSurface={showLocalSurface}
|
showLocalSurface={showLocalSurface}
|
||||||
showRollingMap={showRollingMap}
|
showRollingMap={showRollingMap}
|
||||||
|
showLowStep={showLowStep}
|
||||||
pointSemanticClassIds={alignedSemanticPointIds}
|
pointSemanticClassIds={alignedSemanticPointIds}
|
||||||
semanticClasses={semanticClasses}
|
semanticClasses={semanticClasses}
|
||||||
semanticPalette={semanticPalette}
|
semanticPalette={semanticPalette}
|
||||||
|
|||||||
@@ -84,6 +84,13 @@ const KNOWN_WORKS: Readonly<Record<Exclude<LaboratoryWorkId, `session:${string}`
|
|||||||
experimentName: "M4.8 · conservative static occupancy qualification",
|
experimentName: "M4.8 · conservative static occupancy qualification",
|
||||||
variantName: "M4.8R2 · current/rolling + low-step occupied-only candidate",
|
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": {
|
"m48s-fixed-class-detector": {
|
||||||
profileId: "rig-ravnoves-perception-gate-v1",
|
profileId: "rig-ravnoves-perception-gate-v1",
|
||||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · RAVNOVES00 perception gate`,
|
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · RAVNOVES00 perception gate`,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function mergeResults(
|
|||||||
m48: next.m48 ?? current.m48,
|
m48: next.m48 ?? current.m48,
|
||||||
m48SmallStatic: next.m48SmallStatic ?? current.m48SmallStatic,
|
m48SmallStatic: next.m48SmallStatic ?? current.m48SmallStatic,
|
||||||
m48StaticOccupancy: next.m48StaticOccupancy ?? current.m48StaticOccupancy,
|
m48StaticOccupancy: next.m48StaticOccupancy ?? current.m48StaticOccupancy,
|
||||||
|
m48r3StaticOccupancy: next.m48r3StaticOccupancy ?? current.m48r3StaticOccupancy,
|
||||||
m48s: next.m48s ?? current.m48s,
|
m48s: next.m48s ?? current.m48s,
|
||||||
m48t: next.m48t ?? current.m48t,
|
m48t: next.m48t ?? current.m48t,
|
||||||
m4Threat: next.m4Threat ?? current.m4Threat,
|
m4Threat: next.m4Threat ?? current.m4Threat,
|
||||||
@@ -122,6 +123,7 @@ export function useAdvancedLaboratoryCatalog({
|
|||||||
"m48-object-centric-quality",
|
"m48-object-centric-quality",
|
||||||
"m48-small-static-passage-regression",
|
"m48-small-static-passage-regression",
|
||||||
"m48-static-occupancy-qualification",
|
"m48-static-occupancy-qualification",
|
||||||
|
"m48r3-static-occupancy-shadow",
|
||||||
].includes(selectedWorkId)
|
].includes(selectedWorkId)
|
||||||
&& !indexedResultId
|
&& !indexedResultId
|
||||||
) return;
|
) 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.match(packageDocument, /"playcanvas": "2\.21\.4"/);
|
||||||
assert.doesNotMatch(packageDocument, /@playcanvas\/react/);
|
assert.doesNotMatch(packageDocument, /@playcanvas\/react/);
|
||||||
assert.match(runtime, /new Application\(canvas/);
|
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, /new Asset\([^,]+, "gsplat"/);
|
||||||
assert.match(runtime, /camera\.script\?\.create\(CameraControls/);
|
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, /app\.root\.addChild/);
|
||||||
assert.match(runtime, /dispose\(\)/);
|
assert.match(runtime, /dispose\(\)/);
|
||||||
assert.doesNotMatch(`${runtime}\n${viewport}`, /iframe|<GSplat/);
|
assert.doesNotMatch(`${runtime}\n${viewport}`, /iframe|<GSplat/);
|
||||||
assert.match(viewport, /Collision недоступен/);
|
assert.match(viewport, /Collision недоступен/);
|
||||||
assert.match(viewport, /Для этой сборки collision GLB не был запрошен/);
|
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": [
|
"legacy_work_ids": [
|
||||||
|
"m48r3-static-occupancy-shadow",
|
||||||
"m47-reference-graph-shadow",
|
"m47-reference-graph-shadow",
|
||||||
"e31-source-binding",
|
"e31-source-binding",
|
||||||
"e32-track-geometry",
|
"e32-track-geometry",
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
"voxel_size_m": 0.45,
|
"voxel_size_m": 0.45,
|
||||||
"neighbor_radius_cells": 1,
|
"neighbor_radius_cells": 1,
|
||||||
"minimum_points": 5,
|
"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,
|
"minimum_voxels": 1,
|
||||||
"local_radius_m": 10.0,
|
"local_radius_m": 10.0,
|
||||||
"maximum_candidate_points_per_frame": 768,
|
"maximum_candidate_points_per_frame": 768,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
"provider_id": "ravnoves00-additive-low-step-geometry/v1",
|
"provider_id": "ravnoves00-additive-low-step-geometry/v1",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"revision": "m48r3-ravnoves00-additive-low-step/v1",
|
"revision": "m48r3-ravnoves00-additive-low-step/v1",
|
||||||
"sha256": "0d46dcb28542902849ee31de4c4594ef90407834d9e5551474fc4191cadbf1a9"
|
"sha256": "00fc197ee7200e2f0447b6bec7cc2f21b3c2970d7dd9414cc41e6787d712fee5"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "temporal",
|
"role": "temporal",
|
||||||
|
|||||||
@@ -1504,6 +1504,42 @@ anchors onto its immutable timeline; no third perception instrument exists.
|
|||||||
Navigation, commands, actuation, physical clearance and collision-safety
|
Navigation, commands, actuation, physical clearance and collision-safety
|
||||||
authority remain false.
|
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
|
## Implementation order
|
||||||
|
|
||||||
The implementation sequence is intentionally strict:
|
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) {
|
if ($AdditiveLowStep) {
|
||||||
$expectedConfigs.Remove("m48n-rf-detr-native-reference-graph-shadow-v0.json")
|
$expectedConfigs.Remove("m48n-rf-detr-native-reference-graph-shadow-v0.json")
|
||||||
$expectedConfigs["m48r3-native-low-step-reference-graph-shadow-v1.json"] = (
|
$expectedConfigs["m48r3-native-low-step-reference-graph-shadow-v1.json"] = (
|
||||||
"5f5832a0a0c1879374b166071efed02de14aeda815046da5711d6f4053eb9af4"
|
"e2e307347265076908c4ec1b7da75028998490dfa74ba64f12939e233406d9d0"
|
||||||
)
|
)
|
||||||
$expectedConfigs["m48r3-additive-low-step-occupancy-v1.json"] = (
|
$expectedConfigs["m48r3-additive-low-step-occupancy-v1.json"] = (
|
||||||
"0d46dcb28542902849ee31de4c4594ef90407834d9e5551474fc4191cadbf1a9"
|
"00fc197ee7200e2f0447b6bec7cc2f21b3c2970d7dd9414cc41e6787d712fee5"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
foreach ($entry in $expectedConfigs.GetEnumerator()) {
|
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 .geometry_math import POINT_OCCUPIED
|
||||||
from .providers import SourcePacket
|
from .providers import SourcePacket
|
||||||
|
|
||||||
M48_LOW_STEP_PROFILE_SCHEMA: Final = (
|
M48_LOW_STEP_PROFILE_SCHEMA: Final = "missioncore.m48-additive-low-step-occupancy-profile/v1"
|
||||||
"missioncore.m48-additive-low-step-occupancy-profile/v1"
|
|
||||||
)
|
|
||||||
M48_LOW_STEP_PROVIDER_ID: Final = "ravnoves00-additive-low-step-geometry/v1"
|
M48_LOW_STEP_PROVIDER_ID: Final = "ravnoves00-additive-low-step-geometry/v1"
|
||||||
|
|
||||||
IntArray = npt.NDArray[np.int64]
|
IntArray = npt.NDArray[np.int64]
|
||||||
@@ -53,6 +51,10 @@ class LowStepComponentProfile:
|
|||||||
voxel_size_m: float
|
voxel_size_m: float
|
||||||
neighbor_radius_cells: int
|
neighbor_radius_cells: int
|
||||||
minimum_points: 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
|
minimum_voxels: int
|
||||||
local_radius_m: float
|
local_radius_m: float
|
||||||
maximum_candidate_points_per_frame: int
|
maximum_candidate_points_per_frame: int
|
||||||
@@ -65,6 +67,13 @@ class LowStepComponentProfile:
|
|||||||
or not 0.05 <= self.voxel_size_m <= 2.0
|
or not 0.05 <= self.voxel_size_m <= 2.0
|
||||||
or self.neighbor_radius_cells != 1
|
or self.neighbor_radius_cells != 1
|
||||||
or not 1 <= self.minimum_points <= 256
|
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 1 <= self.minimum_voxels <= 128
|
||||||
or not math.isfinite(self.local_radius_m)
|
or not math.isfinite(self.local_radius_m)
|
||||||
or not 1.0 <= self.local_radius_m <= 100.0
|
or not 1.0 <= self.local_radius_m <= 100.0
|
||||||
@@ -156,6 +165,11 @@ class M48AdditiveLowStepGeometryProvider:
|
|||||||
self._peak_additive_observations = 0
|
self._peak_additive_observations = 0
|
||||||
self._peak_component_voxels = 0
|
self._peak_component_voxels = 0
|
||||||
self._additive_core_duration_ns = 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(
|
def associate(
|
||||||
self,
|
self,
|
||||||
@@ -175,9 +189,7 @@ class M48AdditiveLowStepGeometryProvider:
|
|||||||
except Exception:
|
except Exception:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._failed_frames += 1
|
self._failed_frames += 1
|
||||||
self._additive_core_duration_ns += max(
|
self._additive_core_duration_ns += max(0, time.perf_counter_ns() - started)
|
||||||
0, time.perf_counter_ns() - started
|
|
||||||
)
|
|
||||||
raise
|
raise
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._completed_frames += 1
|
self._completed_frames += 1
|
||||||
@@ -185,18 +197,10 @@ class M48AdditiveLowStepGeometryProvider:
|
|||||||
self._candidate_points += candidate_points
|
self._candidate_points += candidate_points
|
||||||
self._additive_observation_count += len(additive)
|
self._additive_observation_count += len(additive)
|
||||||
self._additive_voxels += voxel_count
|
self._additive_voxels += voxel_count
|
||||||
self._peak_candidate_points = max(
|
self._peak_candidate_points = max(self._peak_candidate_points, candidate_points)
|
||||||
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._peak_additive_observations = max(
|
self._additive_core_duration_ns += max(0, time.perf_counter_ns() - started)
|
||||||
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)
|
return tuple(result)
|
||||||
|
|
||||||
def _build_additive_observations(
|
def _build_additive_observations(
|
||||||
@@ -213,9 +217,9 @@ class M48AdditiveLowStepGeometryProvider:
|
|||||||
claimed = {
|
claimed = {
|
||||||
point_id for observation in baseline for point_id in observation.source_point_ids
|
point_id for observation in baseline for point_id in observation.source_point_ids
|
||||||
}
|
}
|
||||||
candidate = np.flatnonzero(
|
candidate = np.flatnonzero((step > 0) & (frame.point_class != POINT_OCCUPIED)).astype(
|
||||||
(step > 0) & (frame.point_class != POINT_OCCUPIED)
|
np.int64
|
||||||
).astype(np.int64)
|
)
|
||||||
if claimed and candidate.size:
|
if claimed and candidate.size:
|
||||||
candidate = candidate[
|
candidate = candidate[
|
||||||
np.fromiter(
|
np.fromiter(
|
||||||
@@ -240,12 +244,19 @@ class M48AdditiveLowStepGeometryProvider:
|
|||||||
candidate,
|
candidate,
|
||||||
self.profile.component,
|
self.profile.component,
|
||||||
)
|
)
|
||||||
qualified = tuple(
|
strong = tuple(
|
||||||
item
|
item
|
||||||
for item in components
|
for item in components
|
||||||
if item[0].size >= self.profile.component.minimum_points
|
if item[0].size >= self.profile.component.minimum_points
|
||||||
and item[1] >= self.profile.component.minimum_voxels
|
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(
|
qualified = tuple(
|
||||||
sorted(
|
sorted(
|
||||||
qualified,
|
qualified,
|
||||||
@@ -253,8 +264,7 @@ class M48AdditiveLowStepGeometryProvider:
|
|||||||
float(
|
float(
|
||||||
np.min(
|
np.min(
|
||||||
np.linalg.norm(
|
np.linalg.norm(
|
||||||
frame.points_map[item[0]]
|
frame.points_map[item[0]] - frame.sensor_position_map,
|
||||||
- frame.sensor_position_map,
|
|
||||||
axis=1,
|
axis=1,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -279,17 +289,11 @@ class M48AdditiveLowStepGeometryProvider:
|
|||||||
points = frame.points_map[indices]
|
points = frame.points_map[indices]
|
||||||
centroid = np.median(points, axis=0)
|
centroid = np.median(points, axis=0)
|
||||||
covariance = points.var(axis=0)
|
covariance = points.var(axis=0)
|
||||||
nearest = float(
|
nearest = float(np.min(np.linalg.norm(points - frame.sensor_position_map, axis=1)))
|
||||||
np.min(np.linalg.norm(points - frame.sensor_position_map, axis=1))
|
|
||||||
)
|
|
||||||
observations.append(
|
observations.append(
|
||||||
ObstacleObservation(
|
ObstacleObservation(
|
||||||
observation_id=(
|
observation_id=(f"{packet.envelope.frame_id}:low-step:{component_index}"),
|
||||||
f"{packet.envelope.frame_id}:low-step:{component_index}"
|
occupancy_key=(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,
|
source_id=packet.envelope.source_id,
|
||||||
frame_id=packet.envelope.frame_id,
|
frame_id=packet.envelope.frame_id,
|
||||||
evidence_time_ns=packet.envelope.timestamps.source_ns,
|
evidence_time_ns=packet.envelope.timestamps.source_ns,
|
||||||
@@ -323,6 +327,65 @@ class M48AdditiveLowStepGeometryProvider:
|
|||||||
peak_voxels = max(peak_voxels, cells)
|
peak_voxels = max(peak_voxels, cells)
|
||||||
return tuple(observations), candidate_count, voxel_count, peak_voxels
|
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:
|
def snapshot(self) -> M48LowStepOccupancySnapshot:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return M48LowStepOccupancySnapshot(
|
return M48LowStepOccupancySnapshot(
|
||||||
@@ -335,9 +398,7 @@ class M48AdditiveLowStepGeometryProvider:
|
|||||||
additive_observation_count=self._additive_observation_count,
|
additive_observation_count=self._additive_observation_count,
|
||||||
additive_voxel_count=self._additive_voxels,
|
additive_voxel_count=self._additive_voxels,
|
||||||
peak_candidate_points_per_frame=self._peak_candidate_points,
|
peak_candidate_points_per_frame=self._peak_candidate_points,
|
||||||
peak_additive_observations_per_frame=(
|
peak_additive_observations_per_frame=(self._peak_additive_observations),
|
||||||
self._peak_additive_observations
|
|
||||||
),
|
|
||||||
peak_voxels_per_component=self._peak_component_voxels,
|
peak_voxels_per_component=self._peak_component_voxels,
|
||||||
additive_core_duration_ns=self._additive_core_duration_ns,
|
additive_core_duration_ns=self._additive_core_duration_ns,
|
||||||
)
|
)
|
||||||
@@ -401,6 +462,10 @@ def load_m48_low_step_occupancy_profile(
|
|||||||
"voxel_size_m",
|
"voxel_size_m",
|
||||||
"neighbor_radius_cells",
|
"neighbor_radius_cells",
|
||||||
"minimum_points",
|
"minimum_points",
|
||||||
|
"sparse_persistence_minimum_points",
|
||||||
|
"sparse_persistence_window_frames",
|
||||||
|
"sparse_persistence_minimum_hits",
|
||||||
|
"sparse_persistence_maximum_range_m",
|
||||||
"minimum_voxels",
|
"minimum_voxels",
|
||||||
"local_radius_m",
|
"local_radius_m",
|
||||||
"maximum_candidate_points_per_frame",
|
"maximum_candidate_points_per_frame",
|
||||||
@@ -460,9 +525,7 @@ def load_m48_low_step_occupancy_profile(
|
|||||||
if _number(acceptance, key) < 0.0:
|
if _number(acceptance, key) < 0.0:
|
||||||
raise M48LowStepOccupancyError("low-step acceptance bounds are invalid")
|
raise M48LowStepOccupancyError("low-step acceptance bounds are invalid")
|
||||||
if (
|
if (
|
||||||
not _string(source, "m48r2_result_id").startswith(
|
not _string(source, "m48r2_result_id").startswith("m48-static-occupancy-qualification-")
|
||||||
"m48-static-occupancy-qualification-"
|
|
||||||
)
|
|
||||||
or len(_string(source, "m48r2_result_id"))
|
or len(_string(source, "m48r2_result_id"))
|
||||||
!= len("m48-static-occupancy-qualification-") + 64
|
!= len("m48-static-occupancy-qualification-") + 64
|
||||||
):
|
):
|
||||||
@@ -500,9 +563,7 @@ def load_m48_low_step_occupancy_profile(
|
|||||||
LowStepSeparationExpectation(
|
LowStepSeparationExpectation(
|
||||||
anchor_id=_string(item, "anchor_id"),
|
anchor_id=_string(item, "anchor_id"),
|
||||||
sequence=_positive_integer(item, "sequence"),
|
sequence=_positive_integer(item, "sequence"),
|
||||||
expected_minimum_components=_positive_integer(
|
expected_minimum_components=_positive_integer(item, "expected_minimum_components"),
|
||||||
item, "expected_minimum_components"
|
|
||||||
),
|
|
||||||
interpretation=_string(item, "interpretation"),
|
interpretation=_string(item, "interpretation"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -519,18 +580,30 @@ def load_m48_low_step_occupancy_profile(
|
|||||||
base_geometry_profile_sha256=_digest(base, "sha256"),
|
base_geometry_profile_sha256=_digest(base, "sha256"),
|
||||||
component=LowStepComponentProfile(
|
component=LowStepComponentProfile(
|
||||||
voxel_size_m=_number(component, "voxel_size_m"),
|
voxel_size_m=_number(component, "voxel_size_m"),
|
||||||
neighbor_radius_cells=_positive_integer(
|
neighbor_radius_cells=_positive_integer(component, "neighbor_radius_cells"),
|
||||||
component, "neighbor_radius_cells"
|
|
||||||
),
|
|
||||||
minimum_points=_positive_integer(component, "minimum_points"),
|
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"),
|
minimum_voxels=_positive_integer(component, "minimum_voxels"),
|
||||||
local_radius_m=_number(component, "local_radius_m"),
|
local_radius_m=_number(component, "local_radius_m"),
|
||||||
maximum_candidate_points_per_frame=_positive_integer(
|
maximum_candidate_points_per_frame=_positive_integer(
|
||||||
component, "maximum_candidate_points_per_frame"
|
component, "maximum_candidate_points_per_frame"
|
||||||
),
|
),
|
||||||
maximum_cells_per_component=_positive_integer(
|
maximum_cells_per_component=_positive_integer(component, "maximum_cells_per_component"),
|
||||||
component, "maximum_cells_per_component"
|
|
||||||
),
|
|
||||||
maximum_components_per_frame=_positive_integer(
|
maximum_components_per_frame=_positive_integer(
|
||||||
component, "maximum_components_per_frame"
|
component, "maximum_components_per_frame"
|
||||||
),
|
),
|
||||||
@@ -547,9 +620,7 @@ def _voxel_components(
|
|||||||
) -> tuple[tuple[IntArray, int], ...]:
|
) -> tuple[tuple[IntArray, int], ...]:
|
||||||
if source_indices.size == 0:
|
if source_indices.size == 0:
|
||||||
return ()
|
return ()
|
||||||
cells = np.floor(
|
cells = np.floor(points_map[source_indices] / profile.voxel_size_m).astype(np.int64)
|
||||||
points_map[source_indices] / profile.voxel_size_m
|
|
||||||
).astype(np.int64)
|
|
||||||
cell_points: dict[tuple[int, int, int], list[int]] = {}
|
cell_points: dict[tuple[int, int, int], list[int]] = {}
|
||||||
for local_index, row in enumerate(cells):
|
for local_index, row in enumerate(cells):
|
||||||
key = (int(row[0]), int(row[1]), int(row[2]))
|
key = (int(row[0]), int(row[1]), int(row[2]))
|
||||||
@@ -591,6 +662,16 @@ def _voxel_components(
|
|||||||
return tuple(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]:
|
def _object(value: object, label: str) -> dict[str, object]:
|
||||||
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
||||||
raise M48LowStepOccupancyError(f"{label} must be an object")
|
raise M48LowStepOccupancyError(f"{label} must be an object")
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ class M48sReplayTimeline:
|
|||||||
frames_name: str = "reference-graph-replay-frames.jsonl",
|
frames_name: str = "reference-graph-replay-frames.jsonl",
|
||||||
worker_result_name: str = "reference-graph-replay-worker-result.json",
|
worker_result_name: str = "reference-graph-replay-worker-result.json",
|
||||||
frame_evidence_schema: str = FRAME_EVIDENCE_SCHEMA,
|
frame_evidence_schema: str = FRAME_EVIDENCE_SCHEMA,
|
||||||
|
frame_diff_name: str | None = None,
|
||||||
camera_endpoint_root: str = (
|
camera_endpoint_root: str = (
|
||||||
"/api/v1/laboratory/m48s/fixed-class-detector"
|
"/api/v1/laboratory/m48s/fixed-class-detector"
|
||||||
),
|
),
|
||||||
@@ -80,6 +81,10 @@ class M48sReplayTimeline:
|
|||||||
if (
|
if (
|
||||||
Path(frames_name).name != frames_name
|
Path(frames_name).name != frames_name
|
||||||
or Path(worker_result_name).name != worker_result_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 {
|
or frame_evidence_schema not in {
|
||||||
FRAME_EVIDENCE_SCHEMA,
|
FRAME_EVIDENCE_SCHEMA,
|
||||||
M48R3_FRAME_EVIDENCE_SCHEMA,
|
M48R3_FRAME_EVIDENCE_SCHEMA,
|
||||||
@@ -92,11 +97,23 @@ class M48sReplayTimeline:
|
|||||||
self.camera_endpoint_root = camera_endpoint_root
|
self.camera_endpoint_root = camera_endpoint_root
|
||||||
self.frames_path = (self.result_root / frames_name).resolve(strict=True)
|
self.frames_path = (self.result_root / frames_name).resolve(strict=True)
|
||||||
self.worker_path = (self.result_root / worker_result_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 (
|
if (
|
||||||
self.frames_path.parent != self.result_root
|
self.frames_path.parent != self.result_root
|
||||||
or self.worker_path.parent != self.result_root
|
or self.worker_path.parent != self.result_root
|
||||||
or self.frames_path.is_symlink()
|
or self.frames_path.is_symlink()
|
||||||
or self.worker_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")
|
raise M48sReplayTimelineError("M4.8S replay artifacts are invalid")
|
||||||
self.profile = load_replay_threat_profile(
|
self.profile = load_replay_threat_profile(
|
||||||
@@ -123,6 +140,11 @@ class M48sReplayTimeline:
|
|||||||
self.outcomes,
|
self.outcomes,
|
||||||
frame_evidence_schema=self.frame_evidence_schema,
|
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._cache_lock = Lock()
|
||||||
self._chunk_json_cache: OrderedDict[tuple[int, int], bytes] = OrderedDict()
|
self._chunk_json_cache: OrderedDict[tuple[int, int], bytes] = OrderedDict()
|
||||||
self._camera_point_json_cache: OrderedDict[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_window_seconds": CAMERA_ACCUMULATION_WINDOW_SECONDS,
|
||||||
"camera_point_sample_limit": CAMERA_ACCUMULATION_POINT_LIMIT,
|
"camera_point_sample_limit": CAMERA_ACCUMULATION_POINT_LIMIT,
|
||||||
"world_state_delivery": "source-paced-latest-wins",
|
"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),
|
"world_state_frame_count": len(self.index.offsets_by_sequence),
|
||||||
"superseded_frame_count": sum(
|
"superseded_frame_count": sum(
|
||||||
value == "superseded" for value in self.outcomes.values()
|
value == "superseded" for value in self.outcomes.values()
|
||||||
@@ -399,6 +426,12 @@ class M48sReplayTimeline:
|
|||||||
body_frame,
|
body_frame,
|
||||||
occupied_voxel_size_m=self.profile.corridor.occupied_voxel_size_m,
|
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"))
|
associated = set(_strings(row.get("associated_proposal_ids"), "associated ids"))
|
||||||
for proposal in _objects(row.get("detector_proposals"), "detector proposals"):
|
for proposal in _objects(row.get("detector_proposals"), "detector proposals"):
|
||||||
proposal_id = _text(proposal.get("proposal_id"), "proposal id")
|
proposal_id = _text(proposal.get("proposal_id"), "proposal id")
|
||||||
@@ -480,6 +513,27 @@ class M48sReplayTimeline:
|
|||||||
raise M48sReplayTimelineError("M4.8S frame row is invalid")
|
raise M48sReplayTimelineError("M4.8S frame row is invalid")
|
||||||
return value
|
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(
|
def _index_ledger(
|
||||||
path: Path,
|
path: Path,
|
||||||
@@ -517,6 +571,33 @@ def _index_ledger(
|
|||||||
return _LedgerIndex(offsets)
|
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(
|
def _ledger_source_envelope(
|
||||||
line: bytes,
|
line: bytes,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -297,9 +297,17 @@ class SimulationProjectStore:
|
|||||||
result: dict[str, Any],
|
result: dict[str, Any],
|
||||||
artifacts: list[dict[str, Any]],
|
artifacts: list[dict[str, Any]],
|
||||||
world_manifest: dict[str, Any],
|
world_manifest: dict[str, Any],
|
||||||
|
provider_job_id: str | None = None,
|
||||||
|
provider_progress: object = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
document = self._read(project_id)
|
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["status"] = "ready"
|
||||||
document["provider"]["state"] = "ready"
|
document["provider"]["state"] = "ready"
|
||||||
document["provider"]["runtime"] = result.get("runtime")
|
document["provider"]["runtime"] = result.get("runtime")
|
||||||
@@ -464,10 +472,10 @@ class SimulationProjectService:
|
|||||||
"outputs": {
|
"outputs": {
|
||||||
"preview_sog": True,
|
"preview_sog": True,
|
||||||
"streamed_sog": True,
|
"streamed_sog": True,
|
||||||
"collision": False,
|
"collision": True,
|
||||||
},
|
},
|
||||||
"preview_lod": "coarsest",
|
"preview_lod": "coarsest",
|
||||||
"collision_profile": None,
|
"collision_profile": _collision_profile(str(project["scene_type"])),
|
||||||
}
|
}
|
||||||
submitted = provider.submit_build(request)
|
submitted = provider.submit_build(request)
|
||||||
job_id = submitted.get("job_id")
|
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,
|
"available": url_for("collision-mesh") is not None,
|
||||||
},
|
},
|
||||||
"transforms": {
|
"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],
|
"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:
|
def _project_name(value: str) -> str:
|
||||||
normalized = " ".join(value.split())
|
normalized = " ".join(value.split())
|
||||||
if not 1 <= len(normalized) <= 120:
|
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.lidar_local_surface_service import K1LocalSurfaceReadService
|
||||||
from k1link.web.m4_threat_replay_api import build_m4_threat_replay_router
|
from k1link.web.m4_threat_replay_api import build_m4_threat_replay_router
|
||||||
from k1link.web.m48_object_quality_api import build_m48_object_quality_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 (
|
from k1link.web.m48s_fixed_class_detector_lab_api import (
|
||||||
build_m48s_fixed_class_detector_lab_router,
|
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(
|
app.include_router(
|
||||||
build_m48s_fixed_class_detector_lab_router(
|
build_m48s_fixed_class_detector_lab_router(
|
||||||
root_provider=lambda: (
|
root_provider=lambda: (
|
||||||
|
|||||||
@@ -0,0 +1,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
|
from k1link.perception.recorded_source import RecordedFrameReference
|
||||||
|
|
||||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||||
PROFILE_PATH = (
|
PROFILE_PATH = REPOSITORY_ROOT / "config/perception/m48r3-additive-low-step-occupancy-v1.json"
|
||||||
REPOSITORY_ROOT
|
|
||||||
/ "config/perception/m48r3-additive-low-step-occupancy-v1.json"
|
|
||||||
)
|
|
||||||
R2_CASES_PATH = (
|
R2_CASES_PATH = (
|
||||||
REPOSITORY_ROOT
|
REPOSITORY_ROOT
|
||||||
/ ".runtime/compute-experiments/m48/static-occupancy-qualification-results"
|
/ ".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(), ())
|
observations = provider.associate(_packet(), ())
|
||||||
additive = tuple(
|
additive = tuple(
|
||||||
item
|
item for item in observations if "additive-low-step-current-component" in item.reason_codes
|
||||||
for item in observations
|
|
||||||
if "additive-low-step-current-component" in item.reason_codes
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(additive) == 2
|
assert len(additive) == 2
|
||||||
@@ -144,6 +139,42 @@ def test_wide_operator_region_cannot_bridge_two_spatial_components() -> None:
|
|||||||
assert snapshot.failed_frames == 0
|
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:
|
def test_frame_1856_preserves_baseline_posts_and_splits_low_hemisphere_support() -> None:
|
||||||
store = RecordedGeometryStore.from_repository(REPOSITORY_ROOT)
|
store = RecordedGeometryStore.from_repository(REPOSITORY_ROOT)
|
||||||
provider = M48AdditiveLowStepGeometryProvider(
|
provider = M48AdditiveLowStepGeometryProvider(
|
||||||
@@ -163,8 +194,7 @@ def test_frame_1856_preserves_baseline_posts_and_splits_low_hemisphere_support()
|
|||||||
profile=frame.projection,
|
profile=frame.projection,
|
||||||
)
|
)
|
||||||
source_rows = {
|
source_rows = {
|
||||||
int(source_index): row
|
int(source_index): row for row, source_index in enumerate(projected.source_indices)
|
||||||
for row, source_index in enumerate(projected.source_indices)
|
|
||||||
}
|
}
|
||||||
cases = [
|
cases = [
|
||||||
json.loads(line)
|
json.loads(line)
|
||||||
@@ -176,14 +206,62 @@ def test_frame_1856_preserves_baseline_posts_and_splits_low_hemisphere_support()
|
|||||||
hemispheres = by_anchor["anchor-924a4623077fe5df18816b47"]
|
hemispheres = by_anchor["anchor-924a4623077fe5df18816b47"]
|
||||||
|
|
||||||
assert posts["accepted_graph"]["component_count"] >= 2
|
assert posts["accepted_graph"]["component_count"] >= 2
|
||||||
assert _component_hits(
|
assert (
|
||||||
|
_component_hits(
|
||||||
observations,
|
observations,
|
||||||
hemispheres["extent_xyxy"],
|
hemispheres["extent_xyxy"],
|
||||||
projected.pixels_xy,
|
projected.pixels_xy,
|
||||||
source_rows,
|
source_rows,
|
||||||
width=frame.projection.width,
|
width=frame.projection.width,
|
||||||
height=frame.projection.height,
|
height=frame.projection.height,
|
||||||
) >= 2
|
)
|
||||||
|
>= 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(
|
def _component_hits(
|
||||||
@@ -207,9 +285,6 @@ def _component_hits(
|
|||||||
if not indices:
|
if not indices:
|
||||||
continue
|
continue
|
||||||
rows = [source_rows[index] for index in indices if index in source_rows]
|
rows = [source_rows[index] for index in indices if index in source_rows]
|
||||||
if any(
|
if any(x1 <= pixels[row, 0] <= x2 and y1 <= pixels[row, 1] <= y2 for row in rows):
|
||||||
x1 <= pixels[row, 0] <= x2 and y1 <= pixels[row, 1] <= y2
|
|
||||||
for row in rows
|
|
||||||
):
|
|
||||||
count += 1
|
count += 1
|
||||||
return count
|
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.deleted: list[str] = []
|
||||||
self.upload_calls = 0
|
self.upload_calls = 0
|
||||||
self.submit_calls = 0
|
self.submit_calls = 0
|
||||||
|
self.submitted_document: dict[str, object] | None = None
|
||||||
|
|
||||||
def capabilities(self) -> dict[str, object]:
|
def capabilities(self) -> dict[str, object]:
|
||||||
return {"outputs": ["preview.sog", "streamed-sog"]}
|
return {"outputs": ["preview.sog", "streamed-sog"]}
|
||||||
@@ -118,8 +119,9 @@ class _ReadyProvider:
|
|||||||
members=members,
|
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.submit_calls += 1
|
||||||
|
self.submitted_document = document
|
||||||
return {
|
return {
|
||||||
"schema_version": "gaussian-pipeline.job/v1",
|
"schema_version": "gaussian-pipeline.job/v1",
|
||||||
"job_id": "gsp-20260826000000-deadbeef",
|
"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["status"] == "ready"
|
||||||
assert ready["world_manifest"]["visual"]["preview_sog_url"].endswith("/preview.sog")
|
assert ready["world_manifest"]["visual"]["preview_sog_url"].endswith("/preview.sog")
|
||||||
assert ready["world_manifest"]["collision"]["available"] is False
|
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.upload_calls == 1
|
||||||
assert provider.submit_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"])
|
service.delete(project["project_id"])
|
||||||
assert provider.deleted == ["gsp-20260826000000-deadbeef"]
|
assert provider.deleted == ["gsp-20260826000000-deadbeef"]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user