Compare commits
3
Commits
5a9738d6aa
...
1696b4742b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1696b4742b | ||
|
|
ac1dc7d930 | ||
|
|
a98ad929d9 |
@@ -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({
|
||||||
boxXyxy,
|
state: obstacle.state,
|
||||||
label: `OBS #${shortComponentId(obstacle.componentId)} · ${depth} м`,
|
box: {
|
||||||
tone: tone(obstacle),
|
boxXyxy,
|
||||||
dashed: false,
|
label: `OBS #${shortComponentId(obstacle.componentId)} · ${depth} м`,
|
||||||
|
tone: "danger",
|
||||||
|
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 rightArea - leftArea;
|
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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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\]/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
job_id,
|
lambda descriptor=descriptor: provider.download_artifact(
|
||||||
descriptor,
|
job_id,
|
||||||
_confined_path(artifacts_root, str(descriptor["logical_path"])),
|
descriptor,
|
||||||
|
_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 не вернул артефакты сцены.")
|
||||||
|
|||||||
@@ -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}]
|
||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user