3 Commits
10 changed files with 462 additions and 42 deletions
@@ -62,6 +62,7 @@ export function SimulationViewport({
project.viewerSettings.camera.invertVertical, project.viewerSettings.camera.invertVertical,
); );
const [settingsError, setSettingsError] = useState<string | null>(null); const [settingsError, setSettingsError] = useState<string | null>(null);
const worldManifestRevision = JSON.stringify(project.worldManifest);
useEffect(() => { useEffect(() => {
const settings = project.viewerSettings; const settings = project.viewerSettings;
@@ -142,7 +143,10 @@ export function SimulationViewport({
runtime.dispose(); runtime.dispose();
runtimeRef.current = null; runtimeRef.current = null;
}; };
}, [project.projectId, project.worldManifest]); // Saving viewer preferences returns a fresh project object. Restart the GPU
// runtime only when the manifest content changes, not when its object identity
// changes after an otherwise unrelated settings PUT.
}, [project.projectId, worldManifestRevision]);
const collisionAvailable = project.worldManifest?.collision.available ?? false; const collisionAvailable = project.worldManifest?.collision.available ?? false;
const commitViewerSettings = (settings: SimulationViewerSettings) => { const commitViewerSettings = (settings: SimulationViewerSettings) => {
@@ -186,7 +186,7 @@ export interface M4ThreatTimeline {
cameraPointSampleLimit: number; cameraPointSampleLimit: number;
worldStateDelivery: "source-paced-latest-wins" | null; worldStateDelivery: "source-paced-latest-wins" | null;
occupancyProvenanceDelivery: "baseline-versus-additive-component-diff" | null; occupancyProvenanceDelivery: "baseline-versus-additive-component-diff" | null;
cameraObstacleProjectionDelivery: "factory-kb4-occupied-voxel-bounds" | null; cameraObstacleProjectionDelivery: "factory-kb4-actionable-added-corridor-bounds" | null;
worldStateFrameCount: number; worldStateFrameCount: number;
supersededFrameCount: number; supersededFrameCount: number;
sourceRepresentationId: "registered-map-increment-v1"; sourceRepresentationId: "registered-map-increment-v1";
@@ -747,7 +747,7 @@ export async function fetchM4ThreatTimeline(
? null ? null
: exact( : exact(
payload.camera_obstacle_projection_delivery, payload.camera_obstacle_projection_delivery,
"factory-kb4-occupied-voxel-bounds", "factory-kb4-actionable-added-corridor-bounds",
"M4.8R3 camera obstacle projection delivery", "M4.8R3 camera obstacle projection delivery",
), ),
worldStateFrameCount: payload.world_state_frame_count === undefined worldStateFrameCount: payload.world_state_frame_count === undefined
@@ -513,7 +513,7 @@ export function M4ReplayThreatVisual({
shape="pill" shape="pill"
variant={showStaticObstacles ? "primary" : "secondary"} variant={showStaticObstacles ? "primary" : "secondary"}
aria-pressed={showStaticObstacles} aria-pressed={showStaticObstacles}
title="Автоматические рамки занятых LiDAR-компонентов · без ручной разметки" title="Только LOW-STEP LiDAR-препятствия с решением threat · без ручной разметки"
onClick={() => setShowStaticObstacles((visible) => !visible)} onClick={() => setShowStaticObstacles((visible) => !visible)}
> >
OBSTACLES OBSTACLES
@@ -2,6 +2,12 @@ import type { RecordedEvidenceBox } from "../../components/laboratory/RecordedEv
import type { M4ThreatMetricVisual } from "../../core/laboratory/m4ReplayThreat"; import type { M4ThreatMetricVisual } from "../../core/laboratory/m4ReplayThreat";
const MINIMUM_BOX_SIZE_PX = 12; const MINIMUM_BOX_SIZE_PX = 12;
const RETAINED_CURRENT_OVERLAP_LIMIT = 0.5;
interface ObstacleBoxCandidate {
readonly box: RecordedEvidenceBox;
readonly state: M4ThreatMetricVisual["state"];
}
function shortComponentId(componentId: string): string { function shortComponentId(componentId: string): string {
const finalSegment = componentId.split(/[-_]/).pop(); const finalSegment = componentId.split(/[-_]/).pop();
@@ -37,26 +43,42 @@ function visibleBox(
return [left, top, right, bottom]; return [left, top, right, bottom];
} }
function tone(obstacle: M4ThreatMetricVisual): RecordedEvidenceBox["tone"] { function overlapFractionOfSmaller(
if (obstacle.assessment.decision === "threat") return "danger"; left: readonly [number, number, number, number],
if (obstacle.assessment.decision === "not-threat") return "success"; right: readonly [number, number, number, number],
return "warning"; ): number {
const intersectionWidth = Math.max(
0,
Math.min(left[2], right[2]) - Math.max(left[0], right[0]),
);
const intersectionHeight = Math.max(
0,
Math.min(left[3], right[3]) - Math.max(left[1], right[1]),
);
const intersectionArea = intersectionWidth * intersectionHeight;
const leftArea = (left[2] - left[0]) * (left[3] - left[1]);
const rightArea = (right[2] - right[0]) * (right[3] - right[1]);
const smallerArea = Math.min(leftArea, rightArea);
return smallerArea > 0 ? intersectionArea / smallerArea : 0;
} }
/** /**
* Build camera evidence from the same world-state components shown in 3D. * Build actionable camera evidence from the same world-state shown in 3D.
* No image detector or manual review extent participates in these boxes. * Unknown and clear components remain in world-state, but do not compete with
* actual corridor threats for the operator's attention.
*/ */
export function buildM4StaticObstacleBoxes( export function buildM4StaticObstacleBoxes(
obstacles: readonly M4ThreatMetricVisual[], obstacles: readonly M4ThreatMetricVisual[],
imageWidth: number, imageWidth: number,
imageHeight: number, imageHeight: number,
): readonly RecordedEvidenceBox[] { ): readonly RecordedEvidenceBox[] {
const result: RecordedEvidenceBox[] = []; const candidates: ObstacleBoxCandidate[] = [];
for (const obstacle of obstacles) { for (const obstacle of obstacles) {
if ( if (
obstacle.occupancySource === "baseline" obstacle.occupancySource === "baseline"
|| (obstacle.state !== "current" && obstacle.state !== "retained") || (obstacle.state !== "current" && obstacle.state !== "retained")
|| obstacle.assessment.decision !== "threat"
|| obstacle.assessment.corridorIntersection !== "intersects"
|| obstacle.cameraProjection === null || obstacle.cameraProjection === null
) continue; ) continue;
const projection = obstacle.cameraProjection; const projection = obstacle.cameraProjection;
@@ -65,16 +87,33 @@ export function buildM4StaticObstacleBoxes(
const depth = projection.nearestDepthM.toLocaleString("ru-RU", { const depth = projection.nearestDepthM.toLocaleString("ru-RU", {
maximumFractionDigits: 1, maximumFractionDigits: 1,
}); });
result.push({ candidates.push({
state: obstacle.state,
box: {
boxXyxy, boxXyxy,
label: `OBS #${shortComponentId(obstacle.componentId)} · ${depth} м`, label: `OBS #${shortComponentId(obstacle.componentId)} · ${depth} м`,
tone: tone(obstacle), tone: "danger",
dashed: false, dashed: false,
},
}); });
} }
return result.sort((left, right) => { const currentBoxes = candidates
const leftArea = (left.boxXyxy[2] - left.boxXyxy[0]) * (left.boxXyxy[3] - left.boxXyxy[1]); .filter((candidate) => candidate.state === "current")
const rightArea = (right.boxXyxy[2] - right.boxXyxy[0]) * (right.boxXyxy[3] - right.boxXyxy[1]); .map((candidate) => candidate.box.boxXyxy);
return candidates
.filter((candidate) => (
candidate.state !== "retained"
|| !currentBoxes.some((currentBox) => (
overlapFractionOfSmaller(candidate.box.boxXyxy, currentBox)
>= RETAINED_CURRENT_OVERLAP_LIMIT
))
))
.map((candidate) => candidate.box)
.sort((left, right) => {
const leftArea = (left.boxXyxy[2] - left.boxXyxy[0])
* (left.boxXyxy[3] - left.boxXyxy[1]);
const rightArea = (right.boxXyxy[2] - right.boxXyxy[0])
* (right.boxXyxy[3] - right.boxXyxy[1]);
return rightArea - leftArea; return rightArea - leftArea;
}); });
} }
@@ -370,7 +370,7 @@ test("M4.8S timeline binds factory-KB4 camera points through its exact endpoint"
camera_point_sample_limit: 20000, camera_point_sample_limit: 20000,
world_state_delivery: "source-paced-latest-wins", world_state_delivery: "source-paced-latest-wins",
occupancy_provenance_delivery: "baseline-versus-additive-component-diff", occupancy_provenance_delivery: "baseline-versus-additive-component-diff",
camera_obstacle_projection_delivery: "factory-kb4-occupied-voxel-bounds", camera_obstacle_projection_delivery: "factory-kb4-actionable-added-corridor-bounds",
world_state_frame_count: 4481, world_state_frame_count: 4481,
superseded_frame_count: 8, superseded_frame_count: 8,
local_surface_visualization: { local_surface_visualization: {
@@ -400,7 +400,7 @@ test("M4.8S timeline binds factory-KB4 camera points through its exact endpoint"
assert.equal(timeline.supersededFrameCount, 8); assert.equal(timeline.supersededFrameCount, 8);
assert.equal( assert.equal(
timeline.cameraObstacleProjectionDelivery, timeline.cameraObstacleProjectionDelivery,
"factory-kb4-occupied-voxel-bounds", "factory-kb4-actionable-added-corridor-bounds",
); );
const chunk = await fetchM4ThreatTimelineChunk(replayResultId, 1, 1, { const chunk = await fetchM4ThreatTimelineChunk(replayResultId, 1, 1, {
@@ -455,7 +455,7 @@ test("M4.8S timeline binds factory-KB4 camera points through its exact endpoint"
assert.match(requested, new RegExp(`^${endpointRoot}/${replayResultId}/timeline/chunk`)); assert.match(requested, new RegExp(`^${endpointRoot}/${replayResultId}/timeline/chunk`));
assert.match( assert.match(
requested, requested,
/obstacle_projection=factory-kb4-occupied-voxel-bounds/, /obstacle_projection=factory-kb4-actionable-added-corridor-bounds/,
); );
assert.equal(chunk.frames[0].worldStateAvailable, false); assert.equal(chunk.frames[0].worldStateAvailable, false);
assert.equal(chunk.frames[0].terminalOutcome, "superseded"); assert.equal(chunk.frames[0].terminalOutcome, "superseded");
@@ -525,6 +525,24 @@ test("M4.8R3 turns active low-step components into native fisheye obstacle boxes
obstacle, obstacle,
{ ...obstacle, componentId: "baseline", occupancySource: "baseline" }, { ...obstacle, componentId: "baseline", occupancySource: "baseline" },
{ ...obstacle, componentId: "held", state: "held" }, { ...obstacle, componentId: "held", state: "held" },
{
...obstacle,
componentId: "unknown",
assessment: {
...obstacle.assessment,
decision: "unknown",
corridorIntersection: "unknown",
},
},
{
...obstacle,
componentId: "clear",
assessment: {
...obstacle.assessment,
decision: "not-threat",
corridorIntersection: "clear",
},
},
], 800, 600); ], 800, 600);
assert.equal(boxes.length, 1); assert.equal(boxes.length, 1);
assert.deepEqual(boxes[0].boxXyxy, [394, 294, 406, 306]); assert.deepEqual(boxes[0].boxXyxy, [394, 294, 406, 306]);
@@ -533,6 +551,59 @@ test("M4.8R3 turns active low-step components into native fisheye obstacle boxes
assert.equal(boxes[0].dashed, false); assert.equal(boxes[0].dashed, false);
}); });
test("M4.8R3 keeps separate CURRENT threats and suppresses their spanning ROLLING box", () => {
const current = {
componentId: "temporal-left-1",
state: "current",
motion: "stationary",
centroidBodyXyzM: [3, 0, 0.3],
cellCentersBodyXyzM: [[3, 0, 0.3]],
occupancySource: "additive-low-step",
cameraProjection: {
bboxXyxy: [350, 260, 410, 310],
nearestDepthM: 3,
projectedCellCount: 2,
projection: "factory-kb4-occupied-voxel-bounds",
authority: "visual-derived",
},
assessment: {
componentId: "temporal-left-1",
decision: "threat",
corridorIntersection: "intersects",
relativeSpeedMps: null,
closestApproachM: 2.5,
ttcSeconds: null,
reasonCodes: ["current-corridor-intersection"],
},
};
const boxes = buildM4StaticObstacleBoxes([
current,
{
...current,
componentId: "temporal-right-2",
cameraProjection: {
...current.cameraProjection,
bboxXyxy: [405, 265, 445, 320],
},
},
{
...current,
componentId: "rolling-spanning",
state: "retained",
cameraProjection: {
...current.cameraProjection,
bboxXyxy: [340, 255, 450, 325],
},
},
], 800, 600);
assert.equal(boxes.length, 2);
assert.deepEqual(boxes.map((box) => box.label).sort(), [
"OBS #1 · 3 м",
"OBS #2 · 3 м",
]);
});
test("M4.6 local SLAM surface reprojects registered increments into the active body frame", () => { test("M4.6 local SLAM surface reprojects registered increments into the active body frame", () => {
const frames = [ const frames = [
timelineFrame(0, 10, { timelineFrame(0, 10, {
@@ -19,7 +19,7 @@ test("simulation is a dedicated Polygon workspace with a bounded feature slice",
assert.match(productModel, /id: "simulations"[\s\S]*root: "polygon"[\s\S]*kind: "simulations"/); assert.match(productModel, /id: "simulations"[\s\S]*root: "polygon"[\s\S]*kind: "simulations"/);
assert.match(workspaceHub, /case "simulations":[\s\S]*<SimulationWorkspace \/>/); assert.match(workspaceHub, /case "simulations":[\s\S]*<SimulationWorkspace \/>/);
assert.match(workspace, /simulation-catalog__table/); assert.match(workspace, /simulation-catalog__table/);
assert.match(workspace, /<SimulationViewport project=\{selected\}/); assert.match(workspace, /<SimulationViewport[\s\S]*project=\{selected\}/);
assert.match(workspace, /<ConfirmationModal/); assert.match(workspace, /<ConfirmationModal/);
assert.match(styles, /styles\/simulation\.css/); assert.match(styles, /styles\/simulation\.css/);
}); });
@@ -103,4 +103,7 @@ test("PlayCanvas owns the realtime scene graph without an iframe or React entity
assert.match(viewport, /Ось инверсии collision-слоя/); assert.match(viewport, /Ось инверсии collision-слоя/);
assert.match(viewport, /saveSimulationViewerSettings/); assert.match(viewport, /saveSimulationViewerSettings/);
assert.match(viewport, /onProjectChange\?\.\(saved\)/); assert.match(viewport, /onProjectChange\?\.\(saved\)/);
assert.match(viewport, /const worldManifestRevision = JSON\.stringify\(project\.worldManifest\)/);
assert.match(viewport, /\[project\.projectId, worldManifestRevision\]/);
assert.doesNotMatch(viewport, /\[project\.projectId, project\.worldManifest\]/);
}); });
+120 -8
View File
@@ -27,6 +27,8 @@ from .spatial_evidence import (
from .threat import ( from .threat import (
DEFAULT_REPLAY_THREAT_PROFILE_PATH, DEFAULT_REPLAY_THREAT_PROFILE_PATH,
RecordedReplayBodyFrameResolver, RecordedReplayBodyFrameResolver,
ReplayBodyFrame,
ReplayThreatProfile,
load_replay_threat_profile, load_replay_threat_profile,
) )
from .threat_timeline import ( from .threat_timeline import (
@@ -49,8 +51,12 @@ EXPECTED_FRAME_COUNT: Final = 4_489
CAMERA_ACCUMULATION_WINDOW_SECONDS: Final = 2.0 CAMERA_ACCUMULATION_WINDOW_SECONDS: Final = 2.0
CAMERA_ACCUMULATION_POINT_LIMIT: Final = 20_000 CAMERA_ACCUMULATION_POINT_LIMIT: Final = 20_000
CAMERA_POINT_OVERLAY_SCHEMA: Final = "missioncore.m48s-camera-point-overlay/v1" CAMERA_POINT_OVERLAY_SCHEMA: Final = "missioncore.m48s-camera-point-overlay/v1"
ACTIONABLE_CAMERA_OBSTACLE_PROJECTION: Final = (
"factory-kb4-actionable-added-corridor-bounds"
)
_SOURCE_ENVELOPE_MARKER: Final = b'"source_envelope":' _SOURCE_ENVELOPE_MARKER: Final = b'"source_envelope":'
_JSON_DECODER: Final = json.JSONDecoder() _JSON_DECODER: Final = json.JSONDecoder()
Cell = tuple[int, int, int]
class M48sReplayTimelineError(RuntimeError): class M48sReplayTimelineError(RuntimeError):
@@ -62,6 +68,12 @@ class _LedgerIndex:
offsets_by_sequence: dict[int, int] offsets_by_sequence: dict[int, int]
@dataclass(frozen=True, slots=True)
class _FrameDiff:
component_provenance: dict[str, str]
added_cells: frozenset[Cell]
class M48sReplayTimeline: class M48sReplayTimeline:
"""Read source-indexed chunks while preserving latest-wins world-state gaps.""" """Read source-indexed chunks while preserving latest-wins world-state gaps."""
@@ -188,7 +200,7 @@ class M48sReplayTimeline:
else None else None
), ),
"camera_obstacle_projection_delivery": ( "camera_obstacle_projection_delivery": (
"factory-kb4-occupied-voxel-bounds" ACTIONABLE_CAMERA_OBSTACLE_PROJECTION
if self.frame_diff_path is not None if self.frame_diff_path is not None
else None else None
), ),
@@ -435,19 +447,31 @@ 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) frame_diff = self._frame_diff(sequence)
provenance = (
{} if frame_diff is None else frame_diff.component_provenance
)
actionable_metric_rows = (
[]
if frame_diff is None
else _actionable_camera_metric_rows(
metric_rows,
added_cells=frame_diff.added_cells,
body_frame=body_frame,
profile=self.profile,
)
)
camera_projections = ( camera_projections = (
{} {}
if frame is None or not provenance if frame is None or not actionable_metric_rows
else project_metric_obstacles_to_camera( else project_metric_obstacles_to_camera(
metric_rows, actionable_metric_rows,
position_map_xyz=frame.sensor_position_map, position_map_xyz=frame.sensor_position_map,
orientation_map_from_lidar_xyzw=frame.sensor_orientation_xyzw, orientation_map_from_lidar_xyzw=frame.sensor_orientation_xyzw,
profile=frame.projection, profile=frame.projection,
occupied_voxel_size_m=( occupied_voxel_size_m=(
self.profile.corridor.occupied_voxel_size_m self.profile.corridor.occupied_voxel_size_m
), ),
component_ids=set(provenance),
) )
) )
for visual in metric_visuals: for visual in metric_visuals:
@@ -537,9 +561,9 @@ 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]: def _frame_diff(self, sequence: int) -> _FrameDiff | None:
if self.frame_diff_path is None: if self.frame_diff_path is None:
return {} return None
offset = self.frame_diff_offsets.get(sequence) offset = self.frame_diff_offsets.get(sequence)
if offset is None: if offset is None:
raise M48sReplayTimelineError("M4.8R3 frame diff is incomplete") raise M48sReplayTimelineError("M4.8R3 frame diff is incomplete")
@@ -556,7 +580,88 @@ class M48sReplayTimeline:
for key, item in provenance.items() for key, item in provenance.items()
): ):
raise M48sReplayTimelineError("M4.8R3 component provenance changed") raise M48sReplayTimelineError("M4.8R3 component provenance changed")
return provenance raw_added_cells = value.get("added_cells")
if not isinstance(raw_added_cells, list):
raise M48sReplayTimelineError("M4.8R3 added cells changed")
added_cells: set[Cell] = set()
for raw_cell in raw_added_cells:
if (
not isinstance(raw_cell, list)
or len(raw_cell) != 3
or any(not isinstance(item, int) or isinstance(item, bool) for item in raw_cell)
):
raise M48sReplayTimelineError("M4.8R3 added cell is invalid")
added_cells.add((raw_cell[0], raw_cell[1], raw_cell[2]))
return _FrameDiff(
component_provenance=provenance,
added_cells=frozenset(added_cells),
)
def _actionable_camera_metric_rows(
metric_rows: list[dict[str, object]],
*,
added_cells: frozenset[Cell],
body_frame: ReplayBodyFrame,
profile: ReplayThreatProfile,
) -> list[dict[str, object]]:
"""Keep only newly added voxel cells that cause a current corridor threat.
A rolling component can join spatially distant baseline and LOW-STEP cells.
Projecting its complete envelope produces an honest component bound but not
an operator-usable obstacle box. The camera layer therefore visualizes the
exact added cells that participate in the already accepted corridor
intersection; threat authority and the complete 3D component stay intact.
"""
voxel_size_m = profile.corridor.occupied_voxel_size_m
expansion_m = voxel_size_m * math.sqrt(2) / 2
minimum_x = -(
profile.rig.body_length_m / 2
+ profile.corridor.rear_margin_m
+ expansion_m
)
maximum_x = (
profile.rig.body_length_m / 2
+ profile.corridor.forward_length_m
+ expansion_m
)
half_width = (
profile.rig.body_width_m / 2
+ profile.corridor.lateral_clearance_m
+ expansion_m
)
actionable: list[dict[str, object]] = []
for row in metric_rows:
assessment = _object(row.get("assessment"), "metric assessment")
if (
assessment.get("decision") != "threat"
or assessment.get("corridor_intersection") != "intersects"
):
continue
raw_cells = row.get("cells")
if not isinstance(raw_cells, list):
raise M48sReplayTimelineError("M4.8R3 metric cells changed")
selected_cells: list[dict[str, object]] = []
for raw_cell in raw_cells:
cell = _object(raw_cell, "metric cell")
indices = (
_signed_integer(cell.get("x"), "metric cell x"),
_signed_integer(cell.get("y"), "metric cell y"),
_signed_integer(cell.get("z"), "metric cell z"),
)
if indices not in added_cells:
continue
center_map = tuple((index + 0.5) * voxel_size_m for index in indices)
center_body = body_frame.map_point_to_body(center_map)
if (
minimum_x <= center_body[0] <= maximum_x
and -half_width <= center_body[1] <= half_width
):
selected_cells.append(cell)
if selected_cells:
actionable.append({**row, "cells": selected_cells})
return actionable
def _index_ledger( def _index_ledger(
@@ -708,7 +813,14 @@ def _text(value: object, label: str) -> str:
return value return value
def _signed_integer(value: object, label: str) -> int:
if not isinstance(value, int) or isinstance(value, bool):
raise M48sReplayTimelineError(f"M4.8S {label} is invalid")
return value
__all__ = [ __all__ = [
"ACTIONABLE_CAMERA_OBSTACLE_PROJECTION",
"CAMERA_ACCUMULATION_POINT_LIMIT", "CAMERA_ACCUMULATION_POINT_LIMIT",
"CAMERA_ACCUMULATION_WINDOW_SECONDS", "CAMERA_ACCUMULATION_WINDOW_SECONDS",
"CAMERA_POINT_OVERLAY_SCHEMA", "CAMERA_POINT_OVERLAY_SCHEMA",
+59 -7
View File
@@ -9,9 +9,10 @@ import re
import shutil import shutil
import threading import threading
import time import time
from collections.abc import Callable
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
from typing import Any, Final from typing import Any, Final, TypeVar
from urllib.parse import quote from urllib.parse import quote
from uuid import uuid4 from uuid import uuid4
@@ -20,6 +21,7 @@ from k1link.simulation.gaussian_pipeline_gateway import (
BUILD_REQUEST_SCHEMA, BUILD_REQUEST_SCHEMA,
GaussianPipelineGateway, GaussianPipelineGateway,
GaussianPipelineGatewayError, GaussianPipelineGatewayError,
GaussianPipelineUnavailableError,
configured_gaussian_pipeline_gateway, configured_gaussian_pipeline_gateway,
discover_gaussian_source_bundle, discover_gaussian_source_bundle,
) )
@@ -46,6 +48,8 @@ PROVIDER_JOB_STATES: Final = {
"failed", "failed",
} }
PROVIDER_POLL_TIMEOUT_SECONDS: Final = 2 * 60 * 60 + 5 * 60 PROVIDER_POLL_TIMEOUT_SECONDS: Final = 2 * 60 * 60 + 5 * 60
PROVIDER_UNAVAILABLE_RETRY_LIMIT: Final = 6
_T = TypeVar("_T")
class SimulationProjectError(RuntimeError): class SimulationProjectError(RuntimeError):
@@ -301,6 +305,7 @@ class SimulationProjectStore:
document["provider"]["progress"] = progress document["provider"]["progress"] = progress
if bundle_sha256 is not None: if bundle_sha256 is not None:
document["source"]["bundle_sha256"] = bundle_sha256 document["source"]["bundle_sha256"] = bundle_sha256
document["error"] = None
document["updated_at_utc"] = utc_now_iso() document["updated_at_utc"] = utc_now_iso()
self._write(document) self._write(document)
return document return document
@@ -437,6 +442,35 @@ class SimulationProjectService:
def begin_build(self, project_id: str) -> dict[str, Any]: def begin_build(self, project_id: str) -> dict[str, Any]:
project = self.store.get(project_id) project = self.store.get(project_id)
if project.get("status") == "failed":
job_id = project["provider"].get("job_id")
provider_state = project["provider"].get("state")
if (
isinstance(job_id, str)
and isinstance(provider_state, str)
and provider_state in PROVIDER_JOB_STATES - {"failed"}
):
provider = self.provider_factory()
if provider is None:
raise SimulationProjectConflictError(
"Gaussian Pipeline недоступен для продолжения сборки."
)
try:
job = _retry_provider_unavailable(lambda: provider.get_job(job_id))
finally:
provider.close()
current_state = job.get("state")
if (
isinstance(current_state, str)
and current_state in PROVIDER_JOB_STATES - {"failed"}
):
return self.store.update_processing(
project_id,
status="processing",
provider_job_id=job_id,
provider_state=current_state,
progress=job.get("progress"),
)
if project.get("status") in {"failed", "ready"}: if project.get("status") in {"failed", "ready"}:
job_id = project["provider"].get("job_id") job_id = project["provider"].get("job_id")
if isinstance(job_id, str): if isinstance(job_id, str):
@@ -446,7 +480,7 @@ class SimulationProjectService:
"Gaussian Pipeline недоступен для повторной сборки." "Gaussian Pipeline недоступен для повторной сборки."
) )
try: try:
provider.delete_job(job_id) _retry_provider_unavailable(lambda: provider.delete_job(job_id))
finally: finally:
provider.close() provider.close()
return self.store.begin_build(project_id) return self.store.begin_build(project_id)
@@ -458,7 +492,7 @@ class SimulationProjectService:
provider = self.provider_factory() provider = self.provider_factory()
if provider is None: if provider is None:
raise SimulationProjectError("Gaussian Pipeline не настроен.") raise SimulationProjectError("Gaussian Pipeline не настроен.")
provider.capabilities() _retry_provider_unavailable(provider.capabilities)
existing_job_id = project["provider"].get("job_id") existing_job_id = project["provider"].get("job_id")
if isinstance(existing_job_id, str): if isinstance(existing_job_id, str):
job_id = existing_job_id job_id = existing_job_id
@@ -511,7 +545,7 @@ class SimulationProjectService:
raise SimulationProjectError( raise SimulationProjectError(
"Gaussian Pipeline превысил лимит ожидания сборки." "Gaussian Pipeline превысил лимит ожидания сборки."
) )
job = provider.get_job(job_id) job = _retry_provider_unavailable(lambda: provider.get_job(job_id))
state = job.get("state") state = job.get("state")
if not isinstance(state, str) or state not in PROVIDER_JOB_STATES: if not isinstance(state, str) or state not in PROVIDER_JOB_STATES:
raise SimulationProjectError( raise SimulationProjectError(
@@ -537,14 +571,19 @@ class SimulationProjectService:
status="importing", status="importing",
provider_state="ready", provider_state="ready",
) )
result = provider.get_result(job_id) result = _retry_provider_unavailable(lambda: provider.get_result(job_id))
artifacts = _artifact_descriptors(result.get("artifacts")) artifacts = _artifact_descriptors(result.get("artifacts"))
artifacts_root = self.store.artifacts_root(project_id) artifacts_root = self.store.artifacts_root(project_id)
for descriptor in artifacts: for descriptor in artifacts:
provider.download_artifact( _retry_provider_unavailable(
lambda descriptor=descriptor: provider.download_artifact(
job_id, job_id,
descriptor, descriptor,
_confined_path(artifacts_root, str(descriptor["logical_path"])), _confined_path(
artifacts_root,
str(descriptor["logical_path"]),
),
)
) )
world_manifest = _world_manifest(project_id, artifacts) world_manifest = _world_manifest(project_id, artifacts)
self.store.complete( self.store.complete(
@@ -576,6 +615,19 @@ class SimulationProjectService:
self.store.delete(project_id) self.store.delete(project_id)
def _retry_provider_unavailable(operation: Callable[[], _T]) -> _T:
delay_seconds = 1.0
for attempt in range(PROVIDER_UNAVAILABLE_RETRY_LIMIT):
try:
return operation()
except GaussianPipelineUnavailableError:
if attempt + 1 >= PROVIDER_UNAVAILABLE_RETRY_LIMIT:
raise
time.sleep(delay_seconds)
delay_seconds = min(delay_seconds * 2.0, 10.0)
raise AssertionError("provider retry loop exhausted without returning or raising")
def _artifact_descriptors(value: object) -> list[dict[str, Any]]: def _artifact_descriptors(value: object) -> list[dict[str, Any]]:
if not isinstance(value, list) or not value: if not isinstance(value, list) or not value:
raise SimulationProjectError("Gaussian Pipeline не вернул артефакты сцены.") raise SimulationProjectError("Gaussian Pipeline не вернул артефакты сцены.")
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from pathlib import Path
from k1link.perception.m48s_replay_timeline import _actionable_camera_metric_rows
from k1link.perception.threat import ReplayBodyFrame, load_replay_threat_profile
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
def test_camera_obstacles_keep_only_added_cells_inside_an_accepted_threat() -> None:
profile = load_replay_threat_profile(
REPOSITORY_ROOT / "config/perception/m4-replay-threat-v3.json"
)
body_frame = ReplayBodyFrame(
frame_id="frame-000001",
origin_map_xyz_m=(0.0, 0.0, 0.0),
basis_map_from_body=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)),
sensor_height_m=1.25,
surface_slope_deg=0.0,
forward_source="test",
camera_forward_alignment_deg=0.0,
)
threat = {
"component_id": "rolling-actionable",
"cells": [
{"x": 0, "y": 0, "z": 0},
{"x": 1, "y": 4, "z": 0},
{"x": 2, "y": 0, "z": 0},
],
"assessment": {
"decision": "threat",
"corridor_intersection": "intersects",
},
}
unknown = {
**threat,
"component_id": "rolling-unknown",
"assessment": {
"decision": "unknown",
"corridor_intersection": "unknown",
},
}
selected = _actionable_camera_metric_rows(
[threat, unknown],
added_cells=frozenset({(0, 0, 0), (1, 4, 0)}),
body_frame=body_frame,
profile=profile,
)
assert [row["component_id"] for row in selected] == ["rolling-actionable"]
assert selected[0]["cells"] == [{"x": 0, "y": 0, "z": 0}]
+86
View File
@@ -8,6 +8,7 @@ from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from k1link.simulation.gaussian_pipeline_gateway import ( from k1link.simulation.gaussian_pipeline_gateway import (
GaussianPipelineUnavailableError,
GaussianSourceBundleUpload, GaussianSourceBundleUpload,
GaussianSourceMemberUpload, GaussianSourceMemberUpload,
) )
@@ -281,6 +282,91 @@ def test_failed_project_retries_from_retained_source_and_releases_old_job(tmp_pa
assert queued["source"]["uploaded_byte_length"] == queued["source"]["total_byte_length"] assert queued["source"]["uploaded_byte_length"] == queued["source"]["total_byte_length"]
def test_failed_local_project_reattaches_to_live_provider_job_without_rebuild(
tmp_path: Path,
) -> None:
store = SimulationProjectStore(tmp_path)
project = store.create(
name="Reconnect scene",
scene_type="outdoor",
source_kind="folder",
files=_folder_files(),
)
_upload_all(store, project)
store.begin_build(project["project_id"])
store.update_processing(
project["project_id"],
status="processing",
provider_job_id="gsp-20260826000000-deadbeef",
provider_state="building_streamed_sog",
progress={"completed_steps": 3, "total_steps": 5},
bundle_sha256="d" * 64,
)
store.fail(project["project_id"], "temporary provider transport failure")
provider = _ReadyProvider()
service = SimulationProjectService(
store,
provider_factory=lambda: provider,
) # type: ignore[arg-type]
resumed = service.begin_build(project["project_id"])
assert resumed["status"] == "processing"
assert resumed["error"] is None
assert resumed["provider"]["job_id"] == "gsp-20260826000000-deadbeef"
assert resumed["provider"]["state"] == "ready"
assert provider.deleted == []
assert provider.upload_calls == 0
assert provider.submit_calls == 0
def test_live_provider_reattach_retries_temporary_unavailability(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
store = SimulationProjectStore(tmp_path)
project = store.create(
name="Flaky tunnel scene",
scene_type="outdoor",
source_kind="folder",
files=_folder_files(),
)
_upload_all(store, project)
store.begin_build(project["project_id"])
store.update_processing(
project["project_id"],
status="processing",
provider_job_id="gsp-20260826000000-deadbeef",
provider_state="building_collision",
bundle_sha256="d" * 64,
)
store.fail(project["project_id"], "temporary provider transport failure")
class _FlakyProvider(_ReadyProvider):
def __init__(self) -> None:
super().__init__()
self.get_job_calls = 0
def get_job(self, job_id: str) -> dict[str, object]:
self.get_job_calls += 1
if self.get_job_calls < 3:
raise GaussianPipelineUnavailableError("temporary tunnel failure")
return super().get_job(job_id)
provider = _FlakyProvider()
monkeypatch.setattr("k1link.simulation.projects.time.sleep", lambda _delay: None)
service = SimulationProjectService(
store,
provider_factory=lambda: provider,
) # type: ignore[arg-type]
resumed = service.begin_build(project["project_id"])
assert resumed["status"] == "processing"
assert provider.get_job_calls == 3
assert provider.deleted == []
def test_ready_project_rebuilds_from_retained_source_and_preserves_viewer_settings( def test_ready_project_rebuilds_from_retained_source_and_preserves_viewer_settings(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None: