diff --git a/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx b/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx index a352cd1..a9dd2cb 100644 --- a/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx +++ b/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx @@ -10,7 +10,7 @@ export type LaboratoryMetricSceneMode = "3d" | "plan"; export interface LaboratoryMetricObstacleVisual { id: string; decision: LaboratoryMetricDecision; - state: "current" | "held" | "expired"; + state: "current" | "retained" | "held" | "expired"; centroidBodyXyzM: LaboratoryMetricPoint3; cellCentersBodyXyzM: readonly LaboratoryMetricPoint3[]; } @@ -103,6 +103,8 @@ export function LaboratoryMetricEvidenceScene({ const controlsRef = useRef(null); const contentRef = useRef(null); const [renderError, setRenderError] = useState(null); + const [showCurrentIncrement, setShowCurrentIncrement] = useState(true); + const [showRollingMap, setShowRollingMap] = useState(true); useEffect(() => { const host = hostRef.current; @@ -185,24 +187,34 @@ export function LaboratoryMetricEvidenceScene({ child.traverse(disposeRenderable); } - const contextGeometry = new THREE.BufferGeometry(); - contextGeometry.setAttribute( - "position", - new THREE.BufferAttribute(positions(pointCloudBodyXyzM), 3), - ); - content.add(new THREE.Points( - contextGeometry, - new THREE.PointsMaterial({ - color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]), - size: 1.7, - sizeAttenuation: false, - transparent: true, - opacity: 0.34, - depthWrite: false, - }), - )); + if (showCurrentIncrement) { + const contextGeometry = new THREE.BufferGeometry(); + contextGeometry.setAttribute( + "position", + new THREE.BufferAttribute(positions(pointCloudBodyXyzM), 3), + ); + content.add(new THREE.Points( + contextGeometry, + new THREE.PointsMaterial({ + color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]), + size: 1.7, + sizeAttenuation: false, + transparent: true, + opacity: 0.34, + depthWrite: false, + }), + )); + } for (const obstacle of obstacles) { + if ( + (obstacle.state === "current" && !showCurrentIncrement) + || (obstacle.state === "retained" && !showRollingMap) + || obstacle.state === "held" + || obstacle.state === "expired" + ) { + continue; + } const color = decisionColor(host, obstacle.decision); const cellsGeometry = new THREE.BufferGeometry(); cellsGeometry.setAttribute( @@ -213,16 +225,19 @@ export function LaboratoryMetricEvidenceScene({ cellsGeometry, new THREE.PointsMaterial({ color, - size: obstacle.state === "current" ? 4.8 : 3.8, + size: obstacle.state === "current" ? 4.8 : 5.2, sizeAttenuation: false, transparent: true, - opacity: obstacle.state === "current" ? 0.94 : 0.45, + opacity: obstacle.state === "current" ? 0.94 : 0.78, depthWrite: false, }), )); const centroid = new THREE.Mesh( new THREE.SphereGeometry(0.1, 16, 12), - new THREE.MeshBasicMaterial({ color, wireframe: obstacle.state !== "current" }), + new THREE.MeshBasicMaterial({ + color, + wireframe: obstacle.state === "retained", + }), ); centroid.position.fromArray(scenePoint(obstacle.centroidBodyXyzM)); centroid.userData.evidenceId = obstacle.id; @@ -287,7 +302,14 @@ export function LaboratoryMetricEvidenceScene({ material.depthWrite = false; }); content.add(grid); - }, [corridor, obstacles, pointCloudBodyXyzM, rig]); + }, [ + corridor, + obstacles, + pointCloudBodyXyzM, + rig, + showCurrentIncrement, + showRollingMap, + ]); const resetView = () => { const camera = cameraRef.current; @@ -321,13 +343,30 @@ export function LaboratoryMetricEvidenceScene({ > Сбросить ракурс + + ЛКМ · вращение · колесо · масштаб · ПКМ · панорама
Угроза Вне коридора Неизвестно - LiDAR context + Current increment + Rolling-map occupied
); diff --git a/apps/control-station/src/core/laboratory/m4ReplayThreat.ts b/apps/control-station/src/core/laboratory/m4ReplayThreat.ts index 0a14c67..6c6039d 100644 --- a/apps/control-station/src/core/laboratory/m4ReplayThreat.ts +++ b/apps/control-station/src/core/laboratory/m4ReplayThreat.ts @@ -18,6 +18,7 @@ export interface M4ThreatReplayResult { evidence: { cameraOnly: number; currentMetric: number; + rollingMapRetained: number; staleOrHeld: number; }; fixtures: { @@ -69,7 +70,7 @@ export interface M4ThreatAssessment { export interface M4ThreatMetricVisual { componentId: string; - state: "current" | "held" | "expired"; + state: "current" | "retained" | "held" | "expired"; motion: M4ThreatMotion; centroidBodyXyzM: M4Point3; cellCentersBodyXyzM: readonly M4Point3[]; @@ -96,6 +97,8 @@ export interface M4ThreatVisualFrame { pointCloudBodyXyzM: readonly M4Point3[]; pointCloudSourceCount: number; pointCloudSampleCount: number; + pointCloudLayer: "current-increment"; + rollingMapComponentCount: number; metricObstacles: readonly M4ThreatMetricVisual[]; cameraProposals: readonly M4ThreatCameraProposal[]; rig: { @@ -296,6 +299,9 @@ export async function fetchM4ThreatReplayResult({ evidence: { cameraOnly: integer(evidence["camera-only"], "M4.6 camera-only"), currentMetric: integer(evidence["current-metric"], "M4.6 metric"), + rollingMapRetained: evidence["rolling-map-retained"] === undefined + ? 0 + : integer(evidence["rolling-map-retained"], "M4.6 rolling map"), staleOrHeld: integer(evidence["stale-or-held"], "M4.6 stale"), }, fixtures: { @@ -389,7 +395,14 @@ export async function fetchM4ThreatVisual( ); if (!response.ok) throw new M4ThreatContractError(`M4.6 visual frame: HTTP ${response.status}.`); const item = object(await response.json(), "M4.6 visual frame"); - exact(item.schema_version, "missioncore.perception-threat-visual-frame/v1", "M4.6 frame schema"); + const frameSchema = text(item.schema_version, "M4.6 frame schema"); + if ( + frameSchema !== "missioncore.perception-threat-visual-frame/v1" + && frameSchema !== "missioncore.perception-threat-visual-frame/v2" + ) { + throw new M4ThreatContractError("M4.6 frame schema: нарушен контракт."); + } + const rollingMapV2 = frameSchema === "missioncore.perception-threat-visual-frame/v2"; exact(item.result_id, result, "M4.6 frame result"); const rig = object(item.rig, "M4.6 visual rig"); const corridor = object(item.corridor, "M4.6 visual corridor"); @@ -404,10 +417,21 @@ export async function fetchM4ThreatVisual( ), pointCloudSourceCount: integer(item.point_cloud_source_count, "M4.6 source points"), pointCloudSampleCount: integer(item.point_cloud_sample_count, "M4.6 sample points"), + pointCloudLayer: rollingMapV2 + ? exact(item.point_cloud_layer, "current-increment", "M4.6 point layer") + : "current-increment", + rollingMapComponentCount: rollingMapV2 + ? integer(item.rolling_map_component_count, "M4.6 rolling components") + : 0, metricObstacles: array(item.metric_obstacles, "M4.6 metric visuals").map((raw) => { const value = object(raw, "M4.6 metric visual"); const state = text(value.state, "M4.6 temporal state"); - if (state !== "current" && state !== "held" && state !== "expired") { + if ( + state !== "current" + && state !== "retained" + && state !== "held" + && state !== "expired" + ) { throw new M4ThreatContractError("M4.6 temporal state: неизвестное состояние."); } return { diff --git a/apps/control-station/src/styles/m4-replay-threat.css b/apps/control-station/src/styles/m4-replay-threat.css index 04fadaa..ca55fe9 100644 --- a/apps/control-station/src/styles/m4-replay-threat.css +++ b/apps/control-station/src/styles/m4-replay-threat.css @@ -103,6 +103,12 @@ background: rgb(var(--nodedc-warning-rgb)); } +.laboratory-metric-evidence-scene__legend span[data-decision="rolling"]::before { + box-sizing: border-box; + border: 1px solid rgb(var(--nodedc-accent-rgb)); + background: transparent; +} + @media (max-width: 900px) { .laboratory-metric-evidence-scene__toolbar > span { display: none; diff --git a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatResult.tsx b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatResult.tsx index 1ffbe8f..1ee2408 100644 --- a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatResult.tsx +++ b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatResult.tsx @@ -52,14 +52,14 @@ export function M4ReplayThreatResultView({ ]} brief={{ question: "Может ли единый слой обнаруживать потенциальное препятствие по двум независимым источникам, не теряя LiDAR-only объекты и не объявляя camera-only наблюдение безопасным?", - approach: "Все 4489 кадров RAVNOVES00 повторно пропущены через неизменяемые detector, metric geometry и temporal ledgers. Виртуальный base_footprint привязан к gravity-оси SLAM map и направлению сглаженной траектории, проверенному camera extrinsic. Geometry-only объекты получают метрическую оценку; camera-only и stale/held остаются unknown.", - principalResult: `${metrics.evidence.currentMetric.toLocaleString("ru-RU")} current metric и ${metrics.evidence.cameraOnly.toLocaleString("ru-RU")} camera-only наблюдений учтены; ${metrics.reasonCounts["geometry-only-evidence"]?.toLocaleString("ru-RU") ?? "0"} geometry-only оценок не потеряны. Критические fixtures: ${metrics.fixtures.passed}/${metrics.fixtures.total}, ложных safe: ${metrics.fixtures.criticalFalseNotThreat}.`, + approach: "Все 4489 кадров RAVNOVES00 повторно пропущены через неизменяемые detector, metric geometry и temporal ledgers. Текущий lio_pcl increment хранится отдельно от bounded rolling map: отсутствие повторной публикации точки не считается свободным пространством. Виртуальный base_footprint привязан к gravity-оси SLAM map и направлению сглаженной траектории, проверенному camera extrinsic.", + principalResult: `${metrics.evidence.currentMetric.toLocaleString("ru-RU")} current metric, ${metrics.evidence.rollingMapRetained.toLocaleString("ru-RU")} rolling-map и ${metrics.evidence.cameraOnly.toLocaleString("ru-RU")} camera-only публикаций учтены. Кадр 1880 удерживает обе видимые бетонные полусферы; критические fixtures: ${metrics.fixtures.passed}/${metrics.fixtures.total}, ложных safe: ${metrics.fixtures.criticalFalseNotThreat}.`, limitation: "Корпус и коридор пока виртуальные, replay не является live-проходом или физическим collision test. На машине виртуальная привязка должна замениться измеренным rigid T_body_from_sensor; independent object truth остаётся следующим gate.", }} method={{ completeness: "complete", executionClass: "hybrid", - pipelineId: "dual-evidence-replay-threat/v2", + pipelineId: "dual-evidence-replay-threat/v3", components: [ { kind: "source", @@ -78,8 +78,8 @@ export function M4ReplayThreatResultView({ { kind: "source", name: result.sourceResultIds.temporal, - version: "frozen temporal object map", - role: "current / held / expired и bounded motion history", + version: "frozen temporal + rolling obstacle map", + role: "current increment / rolling retained / bounded motion history", identitySha256: result.sourceResultIds.temporal.split("-").at(-1) ?? null, }, { @@ -117,7 +117,7 @@ export function M4ReplayThreatResultView({ { label: "Metric evidence", value: metrics.evidence.currentMetric.toLocaleString("ru-RU"), - hint: `${metrics.reasonCounts["geometry-only-evidence"]?.toLocaleString("ru-RU") ?? "0"} geometry-only`, + hint: `${metrics.evidence.rollingMapRetained.toLocaleString("ru-RU")} rolling-map publications`, }, { label: "Threat / clear", @@ -131,7 +131,7 @@ export function M4ReplayThreatResultView({ }, ]} conclusion={{ - proved: `На неизменяемом RAVNOVES00 каждый metric, stale/held и camera-only объект получил ровно одну консервативную оценку. ${metrics.bodyFrame.qualified}/${metrics.bodyFrame.available} доступных body frames квалифицированы без переноса handheld roll/pitch на SLAM-мир; camera/route alignment p95 ${formatNumber(metrics.bodyFrame.cameraForwardAlignmentDeg.p95, 1)}°. Видео и точное 3D-доказательство доступны в одном viewer.`, + proved: `На неизменяемом RAVNOVES00 каждый current, rolling-map, stale/held и camera-only объект получил ровно одну консервативную оценку. CURRENT INCREMENT и ROLLING MAP независимо включаются в viewer; кадр 1880 закреплён как регрессия двух бетонных полусфер. ${metrics.bodyFrame.qualified}/${metrics.bodyFrame.available} body frames квалифицированы без переноса handheld roll/pitch на SLAM-мир.`, notProved: "Не доказаны live realtime, измеренный T_body_from_sensor и геометрия физического корпуса, независимая object-level правильность, навигационная или safety-пригодность и выдача команд.", decision: "Сохранить dual-evidence provider как канонический replay seam и переходить к независимому object-centric gate; физическую геометрию и live/actuation authority не смешивать с дальнейшей CV-разработкой.", }} diff --git a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx index 59ef8f5..e8b6b68 100644 --- a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx +++ b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx @@ -179,6 +179,12 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) { const threatObstacles = frame?.metricObstacles.filter( (item) => item.assessment.decision === "threat", ) ?? []; + const currentIncrementObstacles = frame?.metricObstacles.filter( + (item) => item.state === "current", + ) ?? []; + const rollingMapObstacles = frame?.metricObstacles.filter( + (item) => item.state === "retained", + ) ?? []; const nearest = frame?.metricObstacles .map((item) => item.assessment.closestApproachM) .filter((value): value is number => value !== null) @@ -293,9 +299,14 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) { {(frame.sourceTimeNs / 1_000_000_000).toFixed(3)} с · {selectedItem?.frameId}
- Dual evidence - {frame.metricObstacles.length} metric · {frame.cameraProposals.length} camera - {frame.pointCloudSampleCount}/{frame.pointCloudSourceCount} LiDAR points shown + Representation layers + + {currentIncrementObstacles.length} current · {rollingMapObstacles.length} rolling + + + CURRENT INCREMENT {frame.pointCloudSampleCount}/{frame.pointCloudSourceCount} points + · ROLLING MAP {frame.rollingMapComponentCount} components +
Virtual corridor @@ -356,7 +367,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) { rig={frame.rig} corridor={frame.corridor} mode={mode} - label={`M4.6 metric point cloud, frame ${frame.sequence}`} + label={`M4.6 current increment and rolling map, frame ${frame.sequence}`} /> ); } diff --git a/apps/control-station/test/m4ReplayThreat.test.mjs b/apps/control-station/test/m4ReplayThreat.test.mjs index 85edbd8..709ffac 100644 --- a/apps/control-station/test/m4ReplayThreat.test.mjs +++ b/apps/control-station/test/m4ReplayThreat.test.mjs @@ -151,6 +151,52 @@ test("M4.6 binds exact CAMERA and metric 3D evidence to one replay frame", async assert.equal(frame.pointCloudSampleCount, 2); }); +test("M4.6 v2 keeps CURRENT INCREMENT separate from ROLLING MAP", async () => { + const frame = await fetchM4ThreatVisual(resultId, 14, { + fetcher: async () => new Response(JSON.stringify({ + schema_version: "missioncore.perception-threat-visual-frame/v2", + result_id: resultId, + ordinal: 14, + sequence: 1880, + frame_id: "frame-001880", + source_time_ns: 223304857292, + point_cloud_body_xyz_m: [[1, 0, 0.1]], + point_cloud_source_count: 1652, + point_cloud_sample_count: 1652, + point_cloud_layer: "current-increment", + rolling_map_component_count: 10, + metric_obstacles: [{ + component_id: "rolling-sphere-near", + state: "retained", + motion: "unknown", + centroid_body_xyz_m: [0.76, -0.27, 0.38], + cell_centers_body_xyz_m: [[0.52, -0.3, 0.08]], + assessment: { + component_id: "rolling-sphere-near", + decision: "threat", + corridor_intersection: "intersects", + relative_speed_mps: null, + closest_approach_m: 0.1, + ttc_seconds: null, + reason_codes: ["retained-corridor-intersection"], + }, + }], + camera_proposals: [], + rig: { length_m: 1, width_m: 0.6, nominal_sensor_height_m: 1.25 }, + corridor: { + forward_length_m: 8, + rear_margin_m: 0.5, + half_width_m: 0.5, + prediction_horizon_seconds: 5, + }, + }), { status: 200 }), + }); + assert.equal(frame.pointCloudLayer, "current-increment"); + assert.equal(frame.rollingMapComponentCount, 10); + assert.equal(frame.metricObstacles[0].state, "retained"); + assert.equal(frame.metricObstacles[0].assessment.decision, "threat"); +}); + test("M4.6 full video preserves camera-only unknown and nearest-frame selection", async () => { const overlay = await fetchM4ThreatVideoOverlay(resultId, { fetcher: async () => new Response(JSON.stringify({ @@ -198,4 +244,6 @@ test("M4.6 viewer reuses shared video and metric evidence renderers", async () = assert.match(visual, /label: "3D"/); assert.match(videoScene, /