feat(perception): add lossless lidar replay v2
This commit is contained in:
parent
3f549f91f1
commit
e4fdd9fa20
|
|
@ -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."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ export type WorkspaceKind =
|
||||||
| "timeline"
|
| "timeline"
|
||||||
| "missions"
|
| "missions"
|
||||||
| "catalog"
|
| "catalog"
|
||||||
|
| "lidar-quality"
|
||||||
| "polygon-run";
|
| "polygon-run";
|
||||||
|
|
||||||
export type CapabilityStatus = "active" | "ready" | "contract" | "later";
|
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",
|
id: "entities",
|
||||||
root: "data",
|
root: "data",
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@
|
||||||
.mission-layout,
|
.mission-layout,
|
||||||
.polygon-live-layout,
|
.polygon-live-layout,
|
||||||
.polygon-run-layout,
|
.polygon-run-layout,
|
||||||
.polygon-run-evidence-grid {
|
.polygon-run-evidence-grid,
|
||||||
|
.lidar-quality-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,6 +158,10 @@
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lidar-quality-facts {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.polygon-run-identity dl {
|
.polygon-run-identity dl {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -803,6 +803,157 @@
|
||||||
font-weight: 650;
|
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 {
|
.overview-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.55fr) minmax(19rem, 0.75fr);
|
grid-template-columns: minmax(0, 1.55fr) minmax(19rem, 0.75fr);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -57,6 +57,7 @@ import {
|
||||||
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
|
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
|
||||||
import type { SceneSettings } from "../sceneSettings";
|
import type { SceneSettings } from "../sceneSettings";
|
||||||
import { PolygonRunWorkspace } from "./PolygonRunWorkspace";
|
import { PolygonRunWorkspace } from "./PolygonRunWorkspace";
|
||||||
|
import { LidarQualityWorkspace } from "./LidarQualityWorkspace";
|
||||||
|
|
||||||
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
|
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
|
||||||
if (status === "active") return "success";
|
if (status === "active") return "success";
|
||||||
|
|
@ -1306,6 +1307,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||||
return <MissionWorkspace {...props} />;
|
return <MissionWorkspace {...props} />;
|
||||||
case "catalog":
|
case "catalog":
|
||||||
return <CatalogWorkspace {...props} />;
|
return <CatalogWorkspace {...props} />;
|
||||||
|
case "lidar-quality":
|
||||||
|
return <LidarQualityWorkspace definition={props.definition} />;
|
||||||
case "polygon-run":
|
case "polygon-run":
|
||||||
return <PolygonRunWorkspace route={props.polygonRunRoute} />;
|
return <PolygonRunWorkspace route={props.polygonRunRoute} />;
|
||||||
case "device":
|
case "device":
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
|
@ -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
|
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.
|
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
|
## Boundary
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# LiDAR worker: product value, evidence boundary and implementation roadmap
|
# LiDAR worker: product value, evidence boundary and implementation roadmap
|
||||||
|
|
||||||
Date: 2026-07-25
|
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
|
Scope: real scanner records, replay and future live shadow processing
|
||||||
Explicitly out of scope: Unreal U0/U1, Gaussian assets and simulator rendering
|
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`,
|
`src/k1link/compute/lidar_contract.py`. It assesses each stage as `ready`,
|
||||||
`degraded` or `blocked` and refuses to infer absent sensor fields.
|
`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 E10–E26.
|
||||||
|
|
||||||
## 3. Current system result
|
## 3. Current system result
|
||||||
|
|
||||||
The existing camera-heavy E10–E26 line is valuable and remains in place:
|
The existing camera-heavy E10–E26 line is valuable and remains in place:
|
||||||
|
|
@ -156,21 +162,36 @@ memory, zero-copy or batching optimizations are accepted.
|
||||||
existing external worker report.
|
existing external worker report.
|
||||||
- [x] Keep authority diagnostic-only.
|
- [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.
|
- [x] Create a new replay schema; do not rewrite pack v1.
|
||||||
- [ ] Preserve XYZ, intensity, source sequence/header stamp/scaler and exact
|
- [x] Preserve XYZ, intensity, source sequence/header stamp/scaler and exact
|
||||||
host capture/receive times.
|
host capture/receive times.
|
||||||
- [ ] Record explicitly absent ring, per-point time and IMU fields.
|
- [x] Record explicitly absent ring, per-point time and IMU fields.
|
||||||
- [ ] Add bounded reports for frame gaps, arrival jitter, points/frame, range
|
- [x] Add immutable reports for sequence gaps, arrival jitter, points/frame,
|
||||||
distribution, intensity distribution and pose coverage.
|
intensity distribution and pose coverage. Sensor-frame range remains
|
||||||
- [ ] Compare live-vs-replay decoding byte-for-byte on a frozen slice.
|
explicitly unavailable because the source is a vendor map increment.
|
||||||
- [ ] Publish the report to the React observation view without bundling a
|
- [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.
|
desktop renderer.
|
||||||
|
|
||||||
Exit: a recording cannot be called detector-ready when required fields were
|
Exit: a recording cannot be called detector-ready when required fields were
|
||||||
dropped, and an operator can distinguish scanner dropout from model failure.
|
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
|
### L2 — geometric baseline
|
||||||
|
|
||||||
- [ ] Run Patchwork++ over the frozen XYZI slice.
|
- [ ] Run Patchwork++ over the frozen XYZI slice.
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,8 @@ be mistaken for geometric validity.
|
||||||
- Nvblox is blocked until the LiDAR scan geometry and timing/pose contract are
|
- Nvblox is blocked until the LiDAR scan geometry and timing/pose contract are
|
||||||
admitted.
|
admitted.
|
||||||
- LiDAR odometry and LiDAR-inertial SLAM are blocked for current K1 evidence.
|
- 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
|
- Future scanners, simulation providers and datasets can use the same
|
||||||
readiness contract without being forced into K1-specific code.
|
readiness contract without being forced into K1-specific code.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 E10–E26 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`
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -50,6 +50,7 @@ from .lab_instances import (
|
||||||
)
|
)
|
||||||
from .lidar_contract import (
|
from .lidar_contract import (
|
||||||
K1_LAB_LIDAR_PACK_V1_PROFILE,
|
K1_LAB_LIDAR_PACK_V1_PROFILE,
|
||||||
|
K1_LIDAR_PACK_V2_PROFILE,
|
||||||
K1_LIVE_LIDAR_PROFILE,
|
K1_LIVE_LIDAR_PROFILE,
|
||||||
LIDAR_EVIDENCE_PROFILE_SCHEMA,
|
LIDAR_EVIDENCE_PROFILE_SCHEMA,
|
||||||
LIDAR_READINESS_SCHEMA,
|
LIDAR_READINESS_SCHEMA,
|
||||||
|
|
@ -68,6 +69,19 @@ from .lidar_contract import (
|
||||||
lidar_readiness_document,
|
lidar_readiness_document,
|
||||||
sensor_frame_xyzi,
|
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 (
|
from .live_perception import (
|
||||||
LIVE_INGRESS_SCHEMA,
|
LIVE_INGRESS_SCHEMA,
|
||||||
LIVE_INGRESS_WIRE_SCHEMA,
|
LIVE_INGRESS_WIRE_SCHEMA,
|
||||||
|
|
@ -139,7 +153,10 @@ __all__ = [
|
||||||
"EvaluationPackFrame",
|
"EvaluationPackFrame",
|
||||||
"LatestWinsQueue",
|
"LatestWinsQueue",
|
||||||
"LIDAR_EVIDENCE_PROFILE_SCHEMA",
|
"LIDAR_EVIDENCE_PROFILE_SCHEMA",
|
||||||
|
"LIDAR_EQUIVALENCE_REPORT_SCHEMA",
|
||||||
|
"LIDAR_QUALITY_REPORT_SCHEMA",
|
||||||
"LIDAR_READINESS_SCHEMA",
|
"LIDAR_READINESS_SCHEMA",
|
||||||
|
"LIDAR_REPLAY_PACK_SCHEMA",
|
||||||
"LIVE_INGRESS_SCHEMA",
|
"LIVE_INGRESS_SCHEMA",
|
||||||
"LIVE_INGRESS_WIRE_SCHEMA",
|
"LIVE_INGRESS_WIRE_SCHEMA",
|
||||||
"LiveIngressEvent",
|
"LiveIngressEvent",
|
||||||
|
|
@ -152,6 +169,10 @@ __all__ = [
|
||||||
"LidarPoseStatus",
|
"LidarPoseStatus",
|
||||||
"LidarQualityMonitor",
|
"LidarQualityMonitor",
|
||||||
"LidarReadiness",
|
"LidarReadiness",
|
||||||
|
"LidarReplayError",
|
||||||
|
"LidarReplayPackV2",
|
||||||
|
"LidarReplayPointFrame",
|
||||||
|
"LidarReplayPoseFrame",
|
||||||
"LidarRepresentation",
|
"LidarRepresentation",
|
||||||
"LidarStageAssessment",
|
"LidarStageAssessment",
|
||||||
"LidarTimeBasis",
|
"LidarTimeBasis",
|
||||||
|
|
@ -167,6 +188,7 @@ __all__ = [
|
||||||
"MultiratePerceptionQualificationResult",
|
"MultiratePerceptionQualificationResult",
|
||||||
"QUALIFICATION_POLICY",
|
"QUALIFICATION_POLICY",
|
||||||
"K1_LAB_LIDAR_PACK_V1_PROFILE",
|
"K1_LAB_LIDAR_PACK_V1_PROFILE",
|
||||||
|
"K1_LIDAR_PACK_V2_PROFILE",
|
||||||
"K1_LIVE_LIDAR_PROFILE",
|
"K1_LIVE_LIDAR_PROFILE",
|
||||||
"QueueSnapshot",
|
"QueueSnapshot",
|
||||||
"RecordedCalibratedFusion",
|
"RecordedCalibratedFusion",
|
||||||
|
|
@ -201,6 +223,7 @@ __all__ = [
|
||||||
"validate_multirate_perception_qualification_result",
|
"validate_multirate_perception_qualification_result",
|
||||||
"prepare_recorded_qualification_slice",
|
"prepare_recorded_qualification_slice",
|
||||||
"assess_lidar_profile",
|
"assess_lidar_profile",
|
||||||
|
"build_lidar_replay_pack_v2",
|
||||||
"DetectionFrame",
|
"DetectionFrame",
|
||||||
"ObjectDetection",
|
"ObjectDetection",
|
||||||
"RecordedPerceptionOverlayError",
|
"RecordedPerceptionOverlayError",
|
||||||
|
|
@ -221,5 +244,8 @@ __all__ = [
|
||||||
"validate_tracked_fusion_qualification_result",
|
"validate_tracked_fusion_qualification_result",
|
||||||
"validate_annotation_workspace",
|
"validate_annotation_workspace",
|
||||||
"lidar_readiness_document",
|
"lidar_readiness_document",
|
||||||
|
"lidar_pack_catalog_item",
|
||||||
|
"lidar_pack_detail",
|
||||||
"sensor_frame_xyzi",
|
"sensor_frame_xyzi",
|
||||||
|
"verify_lidar_replay_equivalence",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -621,3 +621,17 @@ K1_LAB_LIDAR_PACK_V1_PROFILE: Final = LidarEvidenceProfile(
|
||||||
lidar_imu_extrinsic_available=False,
|
lidar_imu_extrinsic_available=False,
|
||||||
camera_extrinsic_available=True,
|
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
|
|
@ -33,6 +33,7 @@ from k1link.sessions import (
|
||||||
SessionStore,
|
SessionStore,
|
||||||
)
|
)
|
||||||
from k1link.web.device_plugin_composition import load_installed_device_plugins
|
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_catalog import DevicePluginCatalog, PluginCatalogError
|
||||||
from k1link.web.plugin_runtime import (
|
from k1link.web.plugin_runtime import (
|
||||||
STATE_READ_ACTION_ID,
|
STATE_READ_ACTION_ID,
|
||||||
|
|
@ -398,6 +399,17 @@ app.include_router(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
app.include_router(build_polygon_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"
|
frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue