feat(perception): add lossless lidar replay v2

This commit is contained in:
DCCONSTRUCTIONS 2026-07-25 01:35:03 +03:00
parent 3f549f91f1
commit e4fdd9fa20
18 changed files with 2538 additions and 11 deletions

View File

@ -0,0 +1,311 @@
export interface LidarDistribution {
sampleCount: number;
minimum: number | null;
mean: number | null;
p50: number | null;
p95: number | null;
maximum: number | null;
}
export interface LidarPackSummary {
packId: string;
sessionId: string;
profileId: string;
pointFrames: number;
poseFrames: number;
points: number;
meanPointsPerFrame: number | null;
p95FrameIntervalMs: number | null;
poseCoverageFraction: number | null;
equivalenceStatus: "passed";
logicalContentSha256: string;
createdAtUtc: string | null;
}
export interface LidarReplayCatalog {
configured: boolean;
validTotal: number;
invalidTotal: number;
duplicateTotal: number;
items: LidarPackSummary[];
}
export interface LidarStageReadiness {
stage: string;
readiness: "ready" | "degraded" | "blocked";
reasons: string[];
}
export interface LidarReplayDetail {
pack: LidarPackSummary;
quality: {
fieldRetention: Record<string, boolean>;
pointCountPerFrame: LidarDistribution;
pointFrameIntervalMs: LidarDistribution;
intensity: LidarDistribution;
poseBinding: {
thresholdMs: number;
coveredPointFrames: number;
coverageFraction: number;
nearestDeltaMs: LidarDistribution;
};
limitations: string[];
};
equivalence: {
status: "passed";
arraysCompared: number;
arrayMismatches: number;
};
stages: LidarStageReadiness[];
}
export class LidarReplayContractError extends Error {}
export class LidarReplayApiError extends Error {
constructor(message: string, readonly status: number | null = null) {
super(message);
}
}
type LidarFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
const SAFE_PACK_ID = /^lidar-replay-pack-[a-f0-9]{64}$/;
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/;
const SHA256 = /^[a-f0-9]{64}$/;
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new LidarReplayContractError(`${label}: ожидался объект`);
}
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) {
throw new LidarReplayContractError(`${label}: ожидался массив`);
}
return value;
}
function string(value: unknown, label: string, pattern?: RegExp): string {
if (typeof value !== "string" || !value || (pattern && !pattern.test(value))) {
throw new LidarReplayContractError(`${label}: некорректная строка`);
}
return value;
}
function number(value: unknown, label: string, nullable = false): number | null {
if (nullable && value === null) return null;
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new LidarReplayContractError(`${label}: некорректное число`);
}
return value;
}
function integer(value: unknown, label: string): number {
const parsed = number(value, label);
if (parsed === null || !Number.isInteger(parsed) || parsed < 0) {
throw new LidarReplayContractError(`${label}: ожидалось неотрицательное целое`);
}
return parsed;
}
function boolean(value: unknown, label: string): boolean {
if (typeof value !== "boolean") {
throw new LidarReplayContractError(`${label}: ожидался boolean`);
}
return value;
}
function distribution(value: unknown, label: string): LidarDistribution {
const source = record(value, label);
return {
sampleCount: integer(source.sample_count, `${label}.sample_count`),
minimum: number(source.minimum, `${label}.minimum`, true),
mean: number(source.mean, `${label}.mean`, true),
p50: number(source.p50, `${label}.p50`, true),
p95: number(source.p95, `${label}.p95`, true),
maximum: number(source.maximum, `${label}.maximum`, true),
};
}
function packSummary(value: unknown): LidarPackSummary {
const source = record(value, "LiDAR pack");
if (source.equivalence_status !== "passed") {
throw new LidarReplayContractError("LiDAR pack не прошёл equivalence gate");
}
return {
packId: string(source.pack_id, "pack_id", SAFE_PACK_ID),
sessionId: string(source.session_id, "session_id", SAFE_ID),
profileId: string(source.profile_id, "profile_id", SAFE_ID),
pointFrames: integer(source.point_frames, "point_frames"),
poseFrames: integer(source.pose_frames, "pose_frames"),
points: integer(source.points, "points"),
meanPointsPerFrame: number(source.mean_points_per_frame, "mean_points_per_frame", true),
p95FrameIntervalMs: number(source.p95_frame_interval_ms, "p95_frame_interval_ms", true),
poseCoverageFraction: number(
source.pose_coverage_fraction,
"pose_coverage_fraction",
true,
),
equivalenceStatus: "passed",
logicalContentSha256: string(
source.logical_content_sha256,
"logical_content_sha256",
SHA256,
),
createdAtUtc:
source.created_at_utc === null || source.created_at_utc === undefined
? null
: string(source.created_at_utc, "created_at_utc"),
};
}
export function parseLidarReplayCatalog(value: unknown): LidarReplayCatalog {
const source = record(value, "LiDAR catalog");
if (source.schema_version !== "missioncore.lidar-replay-pack-catalog/v1") {
throw new LidarReplayContractError("LiDAR catalog schema несовместима");
}
return {
configured: boolean(source.configured, "configured"),
validTotal: integer(source.valid_total, "valid_total"),
invalidTotal: integer(source.invalid_total, "invalid_total"),
duplicateTotal: integer(source.duplicate_total, "duplicate_total"),
items: array(source.items, "items").map(packSummary),
};
}
export function parseLidarReplayDetail(value: unknown): LidarReplayDetail {
const source = record(value, "LiDAR detail");
if (
source.schema_version !== "missioncore.lidar-replay-pack-detail/v1"
|| source.access !== "read-only"
) {
throw new LidarReplayContractError("LiDAR detail contract несовместим");
}
const quality = record(source.quality, "quality");
const retention = record(quality.field_retention, "field_retention");
const fieldRetention: Record<string, boolean> = {};
for (const [key, retained] of Object.entries(retention)) {
fieldRetention[key] = boolean(retained, `field_retention.${key}`);
}
const poseBinding = record(quality.pose_binding, "pose_binding");
const equivalence = record(source.equivalence, "equivalence");
if (equivalence.status !== "passed") {
throw new LidarReplayContractError("LiDAR equivalence gate не пройден");
}
const readiness = record(source.readiness, "readiness");
const stages = array(readiness.stages, "readiness.stages").map((value) => {
const stage = record(value, "readiness stage");
const readinessValue = stage.readiness;
if (
readinessValue !== "ready"
&& readinessValue !== "degraded"
&& readinessValue !== "blocked"
) {
throw new LidarReplayContractError("Неизвестный LiDAR readiness status");
}
const normalizedReadiness: LidarStageReadiness["readiness"] = readinessValue;
return {
stage: string(stage.stage, "stage", SAFE_ID),
readiness: normalizedReadiness,
reasons: array(stage.reasons, "stage.reasons").map((reason) =>
string(reason, "stage reason", SAFE_ID)
),
};
});
return {
pack: packSummary(source.pack),
quality: {
fieldRetention,
pointCountPerFrame: distribution(quality.point_count_per_frame, "point_count"),
pointFrameIntervalMs: distribution(
quality.point_frame_interval_ms,
"point_interval",
),
intensity: distribution(quality.intensity_0_255, "intensity"),
poseBinding: {
thresholdMs: number(poseBinding.threshold_ms, "pose threshold") ?? 0,
coveredPointFrames: integer(
poseBinding.covered_point_frames,
"covered point frames",
),
coverageFraction: number(
poseBinding.coverage_fraction,
"pose coverage",
) ?? 0,
nearestDeltaMs: distribution(
poseBinding.nearest_delta_ms,
"nearest pose delta",
),
},
limitations: array(quality.limitations, "limitations").map((item) =>
string(item, "limitation")
),
},
equivalence: {
status: "passed",
arraysCompared: integer(equivalence.arrays_compared, "arrays_compared"),
arrayMismatches: integer(
equivalence.array_mismatches,
"array_mismatches",
),
},
stages,
};
}
async function responseJson(
response: Response,
fallback: string,
): Promise<unknown> {
let value: unknown;
try {
value = await response.json();
} catch {
throw new LidarReplayApiError(fallback, response.status);
}
if (!response.ok) {
const detail = record(value, "API error").detail;
throw new LidarReplayApiError(
typeof detail === "string" && detail.trim() ? detail : fallback,
response.status,
);
}
return value;
}
export async function fetchLidarReplayCatalog(
options: { signal?: AbortSignal; fetcher?: LidarFetch } = {},
): Promise<LidarReplayCatalog> {
const fetcher = options.fetcher ?? fetch;
const response = await fetcher("/api/v1/lidar/replay-packs?limit=50", {
method: "GET",
headers: { Accept: "application/json" },
signal: options.signal,
});
return parseLidarReplayCatalog(
await responseJson(response, "Не удалось получить каталог LiDAR replay."),
);
}
export async function fetchLidarReplayDetail(
packId: string,
options: { signal?: AbortSignal; fetcher?: LidarFetch } = {},
): Promise<LidarReplayDetail> {
if (!SAFE_PACK_ID.test(packId)) {
throw new LidarReplayContractError("Некорректный LiDAR pack id");
}
const fetcher = options.fetcher ?? fetch;
const response = await fetcher(`/api/v1/lidar/replay-packs/${packId}`, {
method: "GET",
headers: { Accept: "application/json" },
signal: options.signal,
});
return parseLidarReplayDetail(
await responseJson(response, "Не удалось получить LiDAR quality report."),
);
}

View File

@ -18,6 +18,7 @@ export type WorkspaceKind =
| "timeline"
| "missions"
| "catalog"
| "lidar-quality"
| "polygon-run";
export type CapabilityStatus = "active" | "ready" | "contract" | "later";
@ -586,6 +587,18 @@ export const workspaces: WorkspaceDefinition[] = [
},
],
},
{
id: "lidar-quality",
root: "data",
label: "Качество LiDAR",
title: "Качество LiDAR",
eyebrow: "ДАННЫЕ / LIDAR EVIDENCE",
description:
"Проверенные поля сканера, частота, плотность, intensity, pose coverage и допуск следующих алгоритмов.",
icon: "activity",
kind: "lidar-quality",
groups: [],
},
{
id: "entities",
root: "data",

View File

@ -13,7 +13,8 @@
.mission-layout,
.polygon-live-layout,
.polygon-run-layout,
.polygon-run-evidence-grid {
.polygon-run-evidence-grid,
.lidar-quality-grid {
grid-template-columns: 1fr;
}
@ -157,6 +158,10 @@
grid-template-columns: 1fr;
}
.lidar-quality-facts {
grid-template-columns: 1fr;
}
.polygon-run-identity dl {
grid-template-columns: 1fr;
}

View File

@ -803,6 +803,157 @@
font-weight: 650;
}
.lidar-quality-workspace {
gap: 1rem;
}
.lidar-quality-message {
display: grid;
min-height: 12rem;
align-content: center;
justify-items: start;
gap: 0.7rem;
}
.lidar-quality-message h2,
.lidar-quality-message p {
margin: 0;
}
.lidar-quality-message p {
max-width: 46rem;
color: var(--nodedc-text-muted);
font-size: 0.76rem;
line-height: 1.55;
}
.lidar-quality-grid {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
gap: 0.85rem;
}
.lidar-quality-panel {
min-width: 0;
}
.lidar-quality-facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
margin-top: 1rem;
}
.lidar-quality-facts > div {
display: grid;
gap: 0.32rem;
border: 1px solid var(--station-hairline);
border-radius: 0.8rem;
background: rgb(255 255 255 / 0.025);
padding: 0.72rem;
}
.lidar-quality-facts span,
.lidar-pack-catalog footer,
.lidar-readiness-list small {
color: var(--nodedc-text-muted);
font-size: 0.62rem;
line-height: 1.45;
}
.lidar-quality-facts strong {
overflow: hidden;
color: var(--nodedc-text-primary);
font-size: 0.72rem;
text-overflow: ellipsis;
}
.lidar-retention-list {
display: flex;
flex-wrap: wrap;
gap: 0.42rem;
margin-top: 0.8rem;
}
.lidar-retention-list span {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid var(--station-hairline);
border-radius: 999px;
padding: 0.34rem 0.52rem;
color: var(--nodedc-text-secondary);
font-size: 0.58rem;
}
.lidar-retention-list i {
width: 0.38rem;
height: 0.38rem;
border-radius: 50%;
background: rgb(185 255 74 / 0.9);
}
.lidar-retention-list span[data-retained="false"] i {
background: rgb(255 97 97 / 0.9);
}
.lidar-readiness-list,
.lidar-pack-list {
display: grid;
gap: 0.48rem;
margin-top: 1rem;
}
.lidar-readiness-list > div,
.lidar-pack-list > button {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
border: 1px solid var(--station-hairline);
border-radius: 0.78rem;
background: rgb(255 255 255 / 0.02);
padding: 0.68rem 0.76rem;
}
.lidar-readiness-list > div > div,
.lidar-pack-list > button > div {
display: grid;
min-width: 0;
gap: 0.24rem;
}
.lidar-readiness-list strong,
.lidar-pack-list strong {
color: var(--nodedc-text-primary);
font-size: 0.68rem;
}
.lidar-pack-list > button {
width: 100%;
color: inherit;
text-align: left;
cursor: pointer;
}
.lidar-pack-list > button:hover,
.lidar-pack-list > button[data-selected="true"] {
border-color: rgb(185 255 74 / 0.42);
background: rgb(185 255 74 / 0.055);
}
.lidar-pack-list small {
color: var(--nodedc-text-muted);
font-size: 0.6rem;
}
.lidar-pack-catalog footer {
display: grid;
gap: 0.22rem;
margin-top: 0.8rem;
}
.overview-grid {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(19rem, 0.75fr);

View File

@ -0,0 +1,269 @@
import { useEffect, useState } from "react";
import {
Button,
GlassSurface,
StatusBadge,
} from "@nodedc/ui-react";
import {
fetchLidarReplayCatalog,
fetchLidarReplayDetail,
type LidarReplayCatalog,
type LidarReplayDetail,
type LidarStageReadiness,
} from "../core/lidar/replayQuality";
import { MetricCard } from "../components/MetricCard";
import type { WorkspaceDefinition } from "../productModel";
function formatNumber(value: number | null, digits = 1): string {
if (value === null) return "—";
return value.toLocaleString("ru-RU", { maximumFractionDigits: digits });
}
function formatFraction(value: number | null): string {
return value === null ? "—" : `${(value * 100).toLocaleString("ru-RU", {
maximumFractionDigits: 1,
})}%`;
}
function readinessTone(
readiness: LidarStageReadiness["readiness"],
): "success" | "warning" | "danger" {
if (readiness === "ready") return "success";
if (readiness === "degraded") return "warning";
return "danger";
}
function readinessLabel(readiness: LidarStageReadiness["readiness"]): string {
if (readiness === "ready") return "Готов";
if (readiness === "degraded") return "С ограничениями";
return "Заблокирован";
}
function errorMessage(error: unknown): string {
return error instanceof Error && error.message.trim()
? error.message
: "Не удалось прочитать LiDAR evidence.";
}
export function LidarQualityWorkspace({
definition,
}: {
definition: WorkspaceDefinition;
}) {
const [catalog, setCatalog] = useState<LidarReplayCatalog | null>(null);
const [detail, setDetail] = useState<LidarReplayDetail | null>(null);
const [selectedPackId, setSelectedPackId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reloadGeneration, setReloadGeneration] = useState(0);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
void (async () => {
try {
const nextCatalog = await fetchLidarReplayCatalog({
signal: controller.signal,
});
if (controller.signal.aborted) return;
setCatalog(nextCatalog);
const target = selectedPackId ?? nextCatalog.items[0]?.packId ?? null;
if (!target) {
setDetail(null);
return;
}
const nextDetail = await fetchLidarReplayDetail(target, {
signal: controller.signal,
});
if (controller.signal.aborted) return;
setSelectedPackId(target);
setDetail(nextDetail);
} catch (loadError) {
if (controller.signal.aborted) return;
setDetail(null);
setError(errorMessage(loadError));
} finally {
if (!controller.signal.aborted) setLoading(false);
}
})();
return () => controller.abort();
}, [reloadGeneration, selectedPackId]);
return (
<div className="standard-workspace lidar-quality-workspace">
<section className="workspace-lead workspace-lead--compact">
<div>
<span className="section-eyebrow">{definition.eyebrow}</span>
<h2>{definition.title}</h2>
<p>{definition.description}</p>
</div>
<span className="workspace-lead__note">Только проверенные replay-артефакты</span>
</section>
{loading && !detail ? (
<GlassSurface className="lidar-quality-message" padding="lg">
<StatusBadge tone="accent">Проверка evidence</StatusBadge>
<h2>Читаем LiDAR replay и quality report</h2>
<p>Интерфейс покажет pack только после проверки manifest, hashes и equivalence gate.</p>
</GlassSurface>
) : error ? (
<GlassSurface className="lidar-quality-message" padding="lg">
<StatusBadge tone="danger">Данные недоступны</StatusBadge>
<h2>LiDAR quality report не открыт</h2>
<p>{error}</p>
<Button
size="compact"
variant="secondary"
onClick={() => setReloadGeneration((value) => value + 1)}
>
Повторить
</Button>
</GlassSurface>
) : !detail ? (
<GlassSurface className="lidar-quality-message" padding="lg">
<StatusBadge tone={catalog?.configured ? "neutral" : "warning"}>
{catalog?.configured ? "Каталог пуст" : "Storage не настроен"}
</StatusBadge>
<h2>Lossless LiDAR replay пока не опубликован</h2>
<p>
После подготовки первого v2 pack здесь появятся field retention, cadence,
intensity, pose coverage и readiness следующих стадий.
</p>
</GlassSurface>
) : (
<>
<section className="metrics-grid" aria-label="Качество LiDAR replay">
<MetricCard
featured
eyebrow="ТОЧЕЧНЫХ КАДРОВ"
value={detail.pack.pointFrames.toLocaleString("ru-RU")}
detail={`${detail.pack.points.toLocaleString("ru-RU")} точек сохранено`}
/>
<MetricCard
eyebrow="СРЕДНЕЕ ТОЧЕК"
value={formatNumber(detail.pack.meanPointsPerFrame, 0)}
detail="На один lio_pcl кадр"
/>
<MetricCard
eyebrow="P95 ИНТЕРВАЛА"
value={formatNumber(detail.pack.p95FrameIntervalMs)}
unit="мс"
detail="По exact host monotonic time"
/>
<MetricCard
eyebrow="POSE COVERAGE"
value={formatFraction(detail.pack.poseCoverageFraction)}
detail={`Порог ${formatNumber(detail.quality.poseBinding.thresholdMs)} мс`}
/>
</section>
<div className="lidar-quality-grid">
<GlassSurface className="lidar-quality-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">ЦЕЛОСТНОСТЬ</span>
<h2>Lossless replay v2</h2>
</div>
<StatusBadge tone="success">Equivalence passed</StatusBadge>
</header>
<div className="lidar-quality-facts">
<div>
<span>Сессия</span>
<strong>{detail.pack.sessionId}</strong>
</div>
<div>
<span>Сверено массивов</span>
<strong>{detail.equivalence.arraysCompared}</strong>
</div>
<div>
<span>Несовпадений</span>
<strong>{detail.equivalence.arrayMismatches}</strong>
</div>
<div>
<span>Intensity p50 / p95</span>
<strong>
{formatNumber(detail.quality.intensity.p50)} /{" "}
{formatNumber(detail.quality.intensity.p95)}
</strong>
</div>
</div>
<div className="lidar-retention-list">
{Object.entries(detail.quality.fieldRetention).map(([field, retained]) => (
<span key={field} data-retained={retained ? "true" : "false"}>
<i aria-hidden="true" />
{field.replaceAll("_", " ")}
</span>
))}
</div>
</GlassSurface>
<GlassSurface className="lidar-quality-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">ДОПУСК СТАДИЙ</span>
<h2>Что можно запускать</h2>
</div>
</header>
<div className="lidar-readiness-list">
{detail.stages.map((stage) => (
<div key={stage.stage}>
<div>
<strong>{stage.stage}</strong>
<small>
{stage.reasons.length
? stage.reasons.join(" · ")
: "Входной контракт выполнен"}
</small>
</div>
<StatusBadge tone={readinessTone(stage.readiness)}>
{readinessLabel(stage.readiness)}
</StatusBadge>
</div>
))}
</div>
</GlassSurface>
</div>
<GlassSurface className="lidar-pack-catalog" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">REPLAY PACKS</span>
<h2>Проверенные записи</h2>
</div>
<small>
{catalog?.validTotal ?? 0} valid · {catalog?.duplicateTotal ?? 0} superseded ·{" "}
{catalog?.invalidTotal ?? 0} rejected
</small>
</header>
<div className="lidar-pack-list">
{catalog?.items.map((pack) => (
<button
type="button"
key={pack.packId}
data-selected={pack.packId === detail.pack.packId ? "true" : undefined}
onClick={() => setSelectedPackId(pack.packId)}
>
<div>
<strong>{pack.sessionId}</strong>
<small>
{pack.pointFrames.toLocaleString("ru-RU")} кадров ·{" "}
{pack.points.toLocaleString("ru-RU")} точек
</small>
</div>
<StatusBadge tone="success">Passed</StatusBadge>
</button>
))}
</div>
<footer>
{detail.quality.limitations.map((limitation) => (
<span key={limitation}>{limitation}</span>
))}
</footer>
</GlassSurface>
</>
)}
</div>
);
}

View File

@ -57,6 +57,7 @@ import {
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
import type { SceneSettings } from "../sceneSettings";
import { PolygonRunWorkspace } from "./PolygonRunWorkspace";
import { LidarQualityWorkspace } from "./LidarQualityWorkspace";
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
if (status === "active") return "success";
@ -1306,6 +1307,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
return <MissionWorkspace {...props} />;
case "catalog":
return <CatalogWorkspace {...props} />;
case "lidar-quality":
return <LidarQualityWorkspace definition={props.definition} />;
case "polygon-run":
return <PolygonRunWorkspace route={props.polygonRunRoute} />;
case "device":

View File

@ -0,0 +1,179 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let parseLidarReplayCatalog;
let parseLidarReplayDetail;
let fetchLidarReplayCatalog;
let fetchLidarReplayDetail;
let LidarReplayContractError;
let workspaceById;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
parseLidarReplayCatalog,
parseLidarReplayDetail,
fetchLidarReplayCatalog,
fetchLidarReplayDetail,
LidarReplayContractError,
} = await server.ssrLoadModule("/src/core/lidar/replayQuality.ts"));
({ workspaceById } = await server.ssrLoadModule("/src/productModel.ts"));
});
after(async () => {
await server?.close();
});
const packId = `lidar-replay-pack-${"a".repeat(64)}`;
function summary(overrides = {}) {
return {
pack_id: packId,
session_id: "20260719T220917Z_viewer_live",
profile_id: "xgrids-k1-lidar-replay-pack/v2",
point_frames: 10,
pose_frames: 12,
points: 1234,
mean_points_per_frame: 123.4,
p95_frame_interval_ms: 98.7,
pose_coverage_fraction: 0.9,
equivalence_status: "passed",
logical_content_sha256: "b".repeat(64),
created_at_utc: "2026-07-25T00:00:00Z",
authority: {
commands_enabled: false,
navigation_or_safety_accepted: false,
},
...overrides,
};
}
function distribution() {
return {
sample_count: 10,
minimum: 1,
mean: 2,
p50: 2,
p95: 3,
maximum: 4,
};
}
function catalog(overrides = {}) {
return {
schema_version: "missioncore.lidar-replay-pack-catalog/v1",
configured: true,
items: [summary()],
valid_total: 1,
invalid_total: 0,
duplicate_total: 0,
access: "read-only",
...overrides,
};
}
function detail() {
return {
schema_version: "missioncore.lidar-replay-pack-detail/v1",
access: "read-only",
pack: summary({ created_at_utc: undefined }),
quality: {
schema_version: "missioncore.lidar-quality-report/v1",
field_retention: {
xyz_map: true,
raw_xyz: true,
raw_rgbi: true,
intensity_low_byte: true,
source_sequence: true,
header_seq_stamp_scaler: true,
host_epoch_ns: true,
host_monotonic_ns: true,
},
point_count_per_frame: distribution(),
point_frame_interval_ms: distribution(),
intensity_0_255: distribution(),
pose_binding: {
threshold_ms: 100,
covered_point_frames: 9,
coverage_fraction: 0.9,
nearest_delta_ms: distribution(),
},
limitations: ["vendor-mapped increment"],
},
equivalence: {
schema_version: "missioncore.lidar-live-replay-equivalence/v1",
status: "passed",
arrays_compared: 24,
array_mismatches: 0,
},
readiness: {
stages: [
{
stage: "lidar-3d-detection",
readiness: "degraded",
reasons: ["pretrained-domain-expects-sensor-scan"],
},
{
stage: "lidar-inertial-slam",
readiness: "blocked",
reasons: ["lio-requires-unregistered-sensor-scans"],
},
],
},
};
}
function jsonResponse(payload, status = 200) {
return new Response(JSON.stringify(payload), {
status,
headers: { "content-type": "application/json" },
});
}
test("LiDAR catalog and detail decode only passed, path-free evidence", () => {
const parsedCatalog = parseLidarReplayCatalog(catalog());
const parsedDetail = parseLidarReplayDetail(detail());
assert.equal(parsedCatalog.items[0].packId, packId);
assert.equal(parsedDetail.quality.fieldRetention.raw_rgbi, true);
assert.equal(parsedDetail.equivalence.arrayMismatches, 0);
assert.equal(parsedDetail.stages[0].readiness, "degraded");
assert.equal("path" in parsedCatalog.items[0], false);
});
test("LiDAR contract refuses replay that did not pass equivalence", () => {
assert.throws(
() => parseLidarReplayCatalog(catalog({
items: [summary({ equivalence_status: "failed" })],
})),
LidarReplayContractError,
);
});
test("LiDAR fetchers use read-only endpoints and workspace is registered", async () => {
const calls = [];
const fetcher = async (input, init) => {
calls.push({ input: String(input), method: init?.method });
return String(input).includes(packId)
? jsonResponse(detail())
: jsonResponse(catalog());
};
const parsedCatalog = await fetchLidarReplayCatalog({ fetcher });
const parsedDetail = await fetchLidarReplayDetail(packId, { fetcher });
assert.equal(parsedCatalog.validTotal, 1);
assert.equal(parsedDetail.pack.packId, packId);
assert.deepEqual(calls, [
{ input: "/api/v1/lidar/replay-packs?limit=50", method: "GET" },
{ input: `/api/v1/lidar/replay-packs/${packId}`, method: "GET" },
]);
assert.equal(workspaceById("lidar-quality").root, "data");
assert.equal(workspaceById("lidar-quality").kind, "lidar-quality");
});

View File

@ -7,6 +7,13 @@ successfully is not evidence that its LiDAR assumptions are satisfied. In
particular, the accepted E10 replay pack v1 has no intensity, and the current K1
stream is a vendor map increment rather than an unregistered sensor sweep.
`missioncore.lidar-replay-pack/v2` closes the field-retention gap without
rewriting v1. Its manifest binds exact raw/metadata evidence, logical point/pose
content, an immutable scanner-quality report and a source-vs-persisted
equivalence report. The React surface reads those reports through the read-only
`/api/v1/lidar/replay-packs` boundary; CUDA/TensorRT and ROS do not move into the
browser.
## Boundary
```text

View File

@ -1,7 +1,7 @@
# LiDAR worker: product value, evidence boundary and implementation roadmap
Date: 2026-07-25
Status: accepted architecture plan; L0 contract implemented
Status: accepted architecture plan; L0 and L1 implemented
Scope: real scanner records, replay and future live shadow processing
Explicitly out of scope: Unreal U0/U1, Gaussian assets and simulator rendering
@ -63,6 +63,12 @@ The executable truth is
`src/k1link/compute/lidar_contract.py`. It assesses each stage as `ready`,
`degraded` or `blocked` and refuses to infer absent sensor fields.
`missioncore.lidar-replay-pack/v2` is now the admitted field-retaining replay
input. It preserves raw integer XYZ, derived map XYZ, raw uint32 `rgbi`, its
verified intensity low byte, source/payload identity, header seq/stamp/scaler,
exact host epoch/monotonic receive times and the independent pose stream. Pack
v1 remains immutable for E10E26.
## 3. Current system result
The existing camera-heavy E10E26 line is valuable and remains in place:
@ -156,21 +162,36 @@ memory, zero-copy or batching optimizations are accepted.
existing external worker report.
- [x] Keep authority diagnostic-only.
### L1 — lossless replay v2 and scanner quality
### L1 — lossless replay v2 and scanner quality — complete
- [ ] Create a new replay schema; do not rewrite pack v1.
- [ ] Preserve XYZ, intensity, source sequence/header stamp/scaler and exact
- [x] Create a new replay schema; do not rewrite pack v1.
- [x] Preserve XYZ, intensity, source sequence/header stamp/scaler and exact
host capture/receive times.
- [ ] Record explicitly absent ring, per-point time and IMU fields.
- [ ] Add bounded reports for frame gaps, arrival jitter, points/frame, range
distribution, intensity distribution and pose coverage.
- [ ] Compare live-vs-replay decoding byte-for-byte on a frozen slice.
- [ ] Publish the report to the React observation view without bundling a
- [x] Record explicitly absent ring, per-point time and IMU fields.
- [x] Add immutable reports for sequence gaps, arrival jitter, points/frame,
intensity distribution and pose coverage. Sensor-frame range remains
explicitly unavailable because the source is a vendor map increment.
- [x] Compare source native capture vs persisted replay fields byte-for-byte on
a frozen real slice.
- [x] Publish the report to the React observation view without bundling a
desktop renderer.
Exit: a recording cannot be called detector-ready when required fields were
dropped, and an operator can distinguish scanner dropout from model failure.
Accepted real slice:
- session `20260719T220917Z_viewer_live`;
- 66 point frames, 226,963 points and 70 pose frames;
- 24 arrays compared, zero mismatches, identical logical content SHA-256;
- pose coverage 100% at a 100 ms host-arrival gate;
- 3,439 mean points/frame;
- point-frame interval p50 80.4 ms, p95 186.6 ms and maximum 1,153.4 ms;
- intensity p50 248 and p95 255 on the verified `[0, 255]` low byte.
The long interval tail is evidence to investigate in the scanner/transport
quality line. It is not repaired or hidden by replay.
### L2 — geometric baseline
- [ ] Run Patchwork++ over the frozen XYZI slice.

View File

@ -45,7 +45,8 @@ be mistaken for geometric validity.
- Nvblox is blocked until the LiDAR scan geometry and timing/pose contract are
admitted.
- LiDAR odometry and LiDAR-inertial SLAM are blocked for current K1 evidence.
- L1 lossless replay and scanner-quality telemetry precede new model installs.
- L1 lossless replay and scanner-quality telemetry are implemented and precede
new model installs.
- Future scanners, simulation providers and datasets can use the same
readiness contract without being forced into K1-specific code.

View File

@ -0,0 +1,62 @@
# ADR 0019: preserve LiDAR fields before detector evaluation
Date: 2026-07-25
Status: accepted and implemented
## Context
The accepted E10 LiDAR replay pack v1 is bound to the E10E26 result line. It
stores camera-selected map XYZ and pose but drops the verified intensity byte,
raw `rgbi`, raw integer coordinates, source header fields and exact host
timestamps. Mutating that schema would invalidate historical evidence while
still leaving detector and scanner-quality assumptions implicit.
Mission Core also needs one operator surface that can distinguish capture
quality from later model quality. A successful worker process or a visually
plausible point cloud is not that evidence.
## Decision
1. Add the separate immutable `missioncore.lidar-replay-pack/v2`; never rewrite
E10 replay pack v1.
2. Bind each v2 pack to the source raw capture and aligned metadata hashes.
3. Preserve point capture sequence, payload size, exact host epoch and
monotonic receive times, header seq/stamp/scaler, raw integer XYZ, derived
map XYZ, raw uint32 `rgbi` and its verified uint8 intensity low byte.
4. Preserve pose messages independently with their source/header/timing,
position, quaternion, distance and accuracy fields.
5. Hash the canonical logical arrays, not only the compressed NPZ bytes.
6. Re-decode the immutable source and compare all persisted arrays before a
pack is accepted. Any mismatch fails the pack.
7. Publish a separate hashed scanner-quality report: cadence, density,
intensity, sequence anomalies and nearest-pose coverage.
8. Expose only path-free, read-only catalog/detail responses to React.
9. Deduplicate superseded producers by logical content identity in the UI.
10. Keep command, navigation and safety authority false.
## Consequences
- PointPillars now has retained XYZI evidence for replay, but remains degraded
because K1 supplies vendor-mapped increments rather than admitted raw sweeps.
- Scanner/transport timing tails become visible before model tuning.
- Reprocessing can compare new worker/model generations against exactly the
same point and pose evidence.
- The browser displays qualification facts but never loads raw NPZ payloads or
runs GPU perception.
- Ring, per-point time, scan geometry and IMU remain explicitly absent.
- Nvblox, LiDAR odometry and LIO/SLAM remain blocked.
## Accepted real evidence
The first accepted real pack was built from
`20260719T220917Z_viewer_live`: 66 point frames, 226,963 points, 70 pose frames,
24 exact array comparisons and zero mismatches. Pose coverage was 100% at the
100 ms host-arrival threshold. Point-frame interval was p50 80.4 ms, p95
186.6 ms and maximum 1,153.4 ms.
## References
- `src/k1link/compute/lidar_replay.py`
- `src/k1link/web/lidar_api.py`
- `apps/control-station/src/workspaces/LidarQualityWorkspace.tsx`
- `docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md`

View File

@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Build a field-retaining LiDAR replay pack from immutable K1 capture evidence."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from k1link.compute import LidarReplayPackV2, build_lidar_replay_pack_v2
def arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--capture", type=Path, required=True)
parser.add_argument("--output-root", type=Path, required=True)
parser.add_argument("--session-id")
parser.add_argument("--pose-coverage-threshold-ms", type=float, default=100.0)
return parser.parse_args()
def main() -> int:
args = arguments()
output = build_lidar_replay_pack_v2(
args.capture,
args.output_root,
session_id=args.session_id,
pose_coverage_threshold_ms=args.pose_coverage_threshold_ms,
)
pack = LidarReplayPackV2(output)
try:
print(
json.dumps(
{
"pack_id": pack.pack_id,
"output": str(output),
"session_id": pack.identity["session_id"],
"point_frames": pack.point_frame_count,
"pose_frames": pack.pose_frame_count,
"points": pack.point_count,
"equivalence": pack.equivalence["status"],
"pose_coverage_fraction": pack.quality["pose_binding"][
"coverage_fraction"
],
},
ensure_ascii=False,
sort_keys=True,
)
)
finally:
pack.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -50,6 +50,7 @@ from .lab_instances import (
)
from .lidar_contract import (
K1_LAB_LIDAR_PACK_V1_PROFILE,
K1_LIDAR_PACK_V2_PROFILE,
K1_LIVE_LIDAR_PROFILE,
LIDAR_EVIDENCE_PROFILE_SCHEMA,
LIDAR_READINESS_SCHEMA,
@ -68,6 +69,19 @@ from .lidar_contract import (
lidar_readiness_document,
sensor_frame_xyzi,
)
from .lidar_replay import (
LIDAR_EQUIVALENCE_REPORT_SCHEMA,
LIDAR_QUALITY_REPORT_SCHEMA,
LIDAR_REPLAY_PACK_SCHEMA,
LidarReplayError,
LidarReplayPackV2,
LidarReplayPointFrame,
LidarReplayPoseFrame,
build_lidar_replay_pack_v2,
lidar_pack_catalog_item,
lidar_pack_detail,
verify_lidar_replay_equivalence,
)
from .live_perception import (
LIVE_INGRESS_SCHEMA,
LIVE_INGRESS_WIRE_SCHEMA,
@ -139,7 +153,10 @@ __all__ = [
"EvaluationPackFrame",
"LatestWinsQueue",
"LIDAR_EVIDENCE_PROFILE_SCHEMA",
"LIDAR_EQUIVALENCE_REPORT_SCHEMA",
"LIDAR_QUALITY_REPORT_SCHEMA",
"LIDAR_READINESS_SCHEMA",
"LIDAR_REPLAY_PACK_SCHEMA",
"LIVE_INGRESS_SCHEMA",
"LIVE_INGRESS_WIRE_SCHEMA",
"LiveIngressEvent",
@ -152,6 +169,10 @@ __all__ = [
"LidarPoseStatus",
"LidarQualityMonitor",
"LidarReadiness",
"LidarReplayError",
"LidarReplayPackV2",
"LidarReplayPointFrame",
"LidarReplayPoseFrame",
"LidarRepresentation",
"LidarStageAssessment",
"LidarTimeBasis",
@ -167,6 +188,7 @@ __all__ = [
"MultiratePerceptionQualificationResult",
"QUALIFICATION_POLICY",
"K1_LAB_LIDAR_PACK_V1_PROFILE",
"K1_LIDAR_PACK_V2_PROFILE",
"K1_LIVE_LIDAR_PROFILE",
"QueueSnapshot",
"RecordedCalibratedFusion",
@ -201,6 +223,7 @@ __all__ = [
"validate_multirate_perception_qualification_result",
"prepare_recorded_qualification_slice",
"assess_lidar_profile",
"build_lidar_replay_pack_v2",
"DetectionFrame",
"ObjectDetection",
"RecordedPerceptionOverlayError",
@ -221,5 +244,8 @@ __all__ = [
"validate_tracked_fusion_qualification_result",
"validate_annotation_workspace",
"lidar_readiness_document",
"lidar_pack_catalog_item",
"lidar_pack_detail",
"sensor_frame_xyzi",
"verify_lidar_replay_equivalence",
]

View File

@ -621,3 +621,17 @@ K1_LAB_LIDAR_PACK_V1_PROFILE: Final = LidarEvidenceProfile(
lidar_imu_extrinsic_available=False,
camera_extrinsic_available=True,
)
K1_LIDAR_PACK_V2_PROFILE: Final = LidarEvidenceProfile(
profile_id="xgrids-k1-lidar-replay-pack/v2",
representation=LidarRepresentation.VENDOR_MAP_INCREMENT,
coordinate_space=LidarCoordinateSpace.MAP,
coordinate_frame="map",
frame_time_basis=LidarTimeBasis.HOST_ARRIVAL,
point_fields=(LidarPointField.XYZ, LidarPointField.INTENSITY),
pose_status=LidarPoseStatus.BEST_EFFORT,
scan_geometry_known=False,
imu_samples_available=False,
lidar_imu_extrinsic_available=False,
camera_extrinsic_available=True,
)

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,7 @@ from k1link.sessions import (
SessionStore,
)
from k1link.web.device_plugin_composition import load_installed_device_plugins
from k1link.web.lidar_api import build_lidar_router
from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
from k1link.web.plugin_runtime import (
STATE_READ_ACTION_ID,
@ -398,6 +399,17 @@ app.include_router(
)
)
app.include_router(build_polygon_router())
app.include_router(
build_lidar_router(
root_provider=lambda: (
REPOSITORY_ROOT
/ ".runtime"
/ "compute-experiments"
/ "lidar-replay-v2"
/ "packs"
)
)
)
frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"

110
src/k1link/web/lidar_api.py Normal file
View File

@ -0,0 +1,110 @@
from __future__ import annotations
import os
import re
from collections.abc import Callable
from pathlib import Path
from typing import Any, Final
from fastapi import APIRouter, HTTPException, Query
from k1link.compute import (
LidarReplayError,
LidarReplayPackV2,
lidar_pack_catalog_item,
lidar_pack_detail,
)
LIDAR_CATALOG_SCHEMA: Final = "missioncore.lidar-replay-pack-catalog/v1"
_PACK_ID = re.compile(r"^lidar-replay-pack-[a-f0-9]{64}$")
RootProvider = Callable[[], Path | None]
def configured_lidar_replay_root() -> Path | None:
value = os.environ.get("MISSIONCORE_LIDAR_REPLAY_ROOT", "").strip()
return Path(value).expanduser().absolute() if value else None
def build_lidar_router(
*,
root_provider: RootProvider = configured_lidar_replay_root,
) -> APIRouter:
router = APIRouter(prefix="/api/v1/lidar", tags=["lidar"])
@router.get("/replay-packs")
def list_lidar_replay_packs(
limit: int = Query(default=20, ge=1, le=100),
) -> dict[str, Any]:
root = root_provider()
if root is None or not root.is_dir():
return {
"schema_version": LIDAR_CATALOG_SCHEMA,
"configured": root is not None,
"items": [],
"valid_total": 0,
"invalid_total": 0,
"duplicate_total": 0,
"access": "read-only",
}
items: list[dict[str, object]] = []
invalid_total = 0
duplicate_total = 0
seen_logical_content: set[str] = set()
candidates = sorted(
(
candidate
for candidate in root.iterdir()
if candidate.is_dir() and _PACK_ID.fullmatch(candidate.name) is not None
),
key=lambda candidate: candidate.stat().st_mtime_ns,
reverse=True,
)
for candidate in candidates:
try:
pack = LidarReplayPackV2(candidate)
try:
item = lidar_pack_catalog_item(pack)
logical_content = str(item["logical_content_sha256"])
if logical_content in seen_logical_content:
duplicate_total += 1
continue
seen_logical_content.add(logical_content)
item["created_at_utc"] = pack.manifest.get("created_at_utc")
items.append(item)
finally:
pack.close()
except (LidarReplayError, OSError):
invalid_total += 1
return {
"schema_version": LIDAR_CATALOG_SCHEMA,
"configured": True,
"items": items[:limit],
"valid_total": len(items),
"invalid_total": invalid_total,
"duplicate_total": duplicate_total,
"access": "read-only",
}
@router.get("/replay-packs/{pack_id}")
def get_lidar_replay_pack(pack_id: str) -> dict[str, object]:
if _PACK_ID.fullmatch(pack_id) is None:
raise HTTPException(status_code=404, detail="LiDAR replay pack не найден")
root = root_provider()
if root is None or not root.is_dir():
raise HTTPException(status_code=503, detail="LiDAR replay storage не настроен")
candidate = root / pack_id
if not candidate.is_dir():
raise HTTPException(status_code=404, detail="LiDAR replay pack не найден")
try:
pack = LidarReplayPackV2(candidate)
try:
return lidar_pack_detail(pack)
finally:
pack.close()
except (LidarReplayError, OSError) as exc:
raise HTTPException(
status_code=409,
detail="LiDAR replay pack не прошёл проверку целостности",
) from exc
return router

264
tests/test_lidar_replay.py Normal file
View File

@ -0,0 +1,264 @@
from __future__ import annotations
import json
import struct
from pathlib import Path
import lz4.block
import pytest
from fastapi import APIRouter
from fastapi.routing import APIRoute
from k1link.compute import (
K1_LIDAR_PACK_V2_PROFILE,
LidarPipelineStage,
LidarReadiness,
LidarReplayError,
LidarReplayPackV2,
assess_lidar_profile,
build_lidar_replay_pack_v2,
verify_lidar_replay_equivalence,
)
from k1link.device_plugins.xgrids_k1.mqtt.capture import FRAME_HEADER, RAW_MAGIC
from k1link.web.lidar_api import build_lidar_router
def _varint(value: int) -> bytes:
encoded = bytearray()
while value > 0x7F:
encoded.append((value & 0x7F) | 0x80)
value >>= 7
encoded.append(value)
return bytes(encoded)
def _key(number: int, wire_type: int) -> bytes:
return _varint((number << 3) | wire_type)
def _uint(number: int, value: int) -> bytes:
return _key(number, 0) + _varint(value)
def _sint(number: int, value: int) -> bytes:
zigzag = (value << 1) ^ (value >> 63)
return _uint(number, zigzag & 0xFFFFFFFFFFFFFFFF)
def _bytes(number: int, value: bytes) -> bytes:
return _key(number, 2) + _varint(len(value)) + value
def _fixed32(number: int, value: float) -> bytes:
return _key(number, 5) + struct.pack("<f", value)
def _fixed64(number: int, value: float) -> bytes:
return _key(number, 1) + struct.pack("<d", value)
def _header(sequence: int, stamp: int, scaler: int = 1000) -> bytes:
return b"".join(
(
_uint(1, sequence),
_sint(2, stamp),
_sint(3, scaler),
_bytes(4, b"device-redacted"),
_bytes(5, b"session-redacted"),
)
)
def _pcl_payload(sequence: int, stamp: int, first_rgbi: int) -> bytes:
point_1 = (
_sint(1, 1000 * sequence)
+ _sint(2, -2000)
+ _sint(3, 500)
+ _uint(4, first_rgbi)
)
point_2 = (
_sint(1, -250)
+ _sint(2, sequence)
+ _sint(3, 4000)
+ _uint(4, 0xAABBCC09)
)
report = (
_bytes(1, _header(sequence, stamp))
+ _bytes(2, point_1)
+ _bytes(2, point_2)
)
compressed = lz4.block.compress(report, store_size=False)
return _uint(3, len(report)) + _bytes(4, compressed)
def _pose_payload(sequence: int, stamp: int) -> bytes:
position = (
_fixed64(1, float(sequence))
+ _fixed64(2, -2.5)
+ _fixed64(3, 3.75)
)
orientation = (
_fixed64(1, 0.0)
+ _fixed64(2, 0.0)
+ _fixed64(3, 0.0)
+ _fixed64(4, 1.0)
)
pose = _bytes(1, position) + _bytes(2, orientation)
stamped = _sint(1, stamp + 1) + _bytes(2, pose)
return (
_bytes(1, _header(sequence, stamp))
+ _bytes(2, stamped)
+ _fixed32(3, 12.5)
+ _fixed32(4, 0.001)
)
def _capture(tmp_path: Path) -> Path:
session = tmp_path / "session-001"
root = session / "captures" / "mqtt_live"
root.mkdir(parents=True)
messages = [
("lixel/application/report/lio_pcl", _pcl_payload(10, 1_000, 0x11223344)),
("lixel/application/report/lio_pose", _pose_payload(20, 1_010)),
("lixel/application/report/lio_pcl", _pcl_payload(11, 2_000, 0x55667788)),
("lixel/application/report/lio_pose", _pose_payload(21, 2_010)),
]
raw = bytearray(RAW_MAGIC)
metadata: list[str] = []
for sequence, (topic, payload) in enumerate(messages, start=1):
topic_bytes = topic.encode()
raw.extend(FRAME_HEADER.pack(len(topic_bytes), len(payload)))
raw.extend(topic_bytes)
raw.extend(payload)
metadata.append(
json.dumps(
{
"schema_version": 1,
"record_type": "message",
"sequence": sequence,
"received_at_epoch_ns": 1_000_000_000 + sequence * 10_000_000,
"received_monotonic_ns": 5_000_000_000 + sequence * 10_000_000,
},
separators=(",", ":"),
)
)
capture = root / "mqtt.raw.k1mqtt"
capture.write_bytes(raw)
(root / "mqtt.metadata.jsonl").write_text(
"\n".join(metadata) + "\n",
encoding="utf-8",
)
(root / "mqtt.timeline.origin.json").write_text(
json.dumps(
{
"schema_version": 1,
"started_at_epoch_ns": 1_000_000_000,
"started_monotonic_ns": 5_000_000_000,
}
),
encoding="utf-8",
)
return capture
def _endpoint(router: APIRouter, path: str) -> object:
for route in router.routes:
if isinstance(route, APIRoute) and route.path == path and "GET" in route.methods:
return route.endpoint
raise AssertionError(f"GET {path} route is missing")
def test_lidar_replay_v2_retains_fields_and_passes_equivalence(tmp_path: Path) -> None:
capture = _capture(tmp_path)
output = build_lidar_replay_pack_v2(capture, tmp_path / "packs")
pack = LidarReplayPackV2(output)
try:
assert pack.identity["session_id"] == "session-001"
assert pack.identity["lidar_evidence_profile"] == K1_LIDAR_PACK_V2_PROFILE.to_dict()
assert pack.point_frame_count == 2
assert pack.pose_frame_count == 2
assert pack.point_count == 4
point = pack.point_frame(0)
assert point.capture_sequence == 1
assert point.received_at_epoch_ns == 1_010_000_000
assert point.received_monotonic_ns == 5_010_000_000
assert point.header_seq == 10
assert point.header_stamp == 1_000
assert point.scaler == 1_000
assert point.raw_xyz.tolist() == [[10_000, -2_000, 500], [-250, 10, 4_000]]
assert point.xyz_map.tolist() == [[10.0, -2.0, 0.5], [-0.25, 0.01, 4.0]]
assert point.rgbi.tolist() == [0x11223344, 0xAABBCC09]
assert point.intensity.tolist() == [0x44, 0x09]
assert point.decoded_view().intensities == bytes((0x44, 0x09))
pose = pack.pose_frame(0)
assert pose.capture_sequence == 2
assert pose.header_seq == 20
assert pose.pose_stamp == 1_011
assert pose.position_map == (20.0, -2.5, 3.75)
assert pose.decoded_view().frame_id == "map"
assert pack.quality["field_retention"]["raw_rgbi"] is True
assert pack.quality["field_retention"]["host_monotonic_ns"] is True
assert pack.quality["sensor_range_m"]["sample_count"] == 0
assert pack.quality["sensor_range_status"].startswith("unavailable-")
assert pack.quality["pose_binding"]["coverage_fraction"] == pytest.approx(1.0)
assert pack.equivalence["status"] == "passed"
assert pack.equivalence["array_mismatches"] == 0
rerun = verify_lidar_replay_equivalence(capture, pack)
assert rerun["status"] == "passed"
finally:
pack.close()
assert build_lidar_replay_pack_v2(capture, tmp_path / "packs") == output
def test_lidar_replay_v2_unblocks_detector_input_but_not_mapping() -> None:
assessments = {
item.stage: item for item in assess_lidar_profile(K1_LIDAR_PACK_V2_PROFILE)
}
assert (
assessments[LidarPipelineStage.LIDAR_3D_DETECTION].readiness
is LidarReadiness.DEGRADED
)
assert (
assessments[LidarPipelineStage.LIDAR_INERTIAL_SLAM].readiness
is LidarReadiness.BLOCKED
)
assert (
assessments[LidarPipelineStage.NVIDIA_NVBLOX].readiness
is LidarReadiness.BLOCKED
)
def test_lidar_replay_v2_fails_closed_without_exact_host_timing(
tmp_path: Path,
) -> None:
capture = _capture(tmp_path)
capture.with_name("mqtt.metadata.jsonl").unlink()
with pytest.raises(LidarReplayError, match="exact host timing"):
build_lidar_replay_pack_v2(capture, tmp_path / "packs")
def test_lidar_api_exposes_path_free_quality_and_equivalence(tmp_path: Path) -> None:
capture = _capture(tmp_path)
root = tmp_path / "packs"
output = build_lidar_replay_pack_v2(capture, root)
router = build_lidar_router(root_provider=lambda: root)
catalog_route = _endpoint(router, "/api/v1/lidar/replay-packs")
detail_route = _endpoint(router, "/api/v1/lidar/replay-packs/{pack_id}")
catalog = catalog_route(limit=20) # type: ignore[operator]
detail = detail_route(pack_id=output.name) # type: ignore[operator]
assert catalog["schema_version"] == "missioncore.lidar-replay-pack-catalog/v1"
assert catalog["valid_total"] == 1
assert catalog["invalid_total"] == 0
assert catalog["items"][0]["equivalence_status"] == "passed"
assert detail["pack"]["pack_id"] == output.name
assert detail["quality"]["field_retention"]["raw_rgbi"] is True
assert detail["equivalence"]["status"] == "passed"
assert detail["access"] == "read-only"
serialized = repr({"catalog": catalog, "detail": detail})
assert str(tmp_path) not in serialized