diff --git a/apps/control-station/src/core/lidar/replayQuality.ts b/apps/control-station/src/core/lidar/replayQuality.ts new file mode 100644 index 0000000..3604ab7 --- /dev/null +++ b/apps/control-station/src/core/lidar/replayQuality.ts @@ -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; + 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; + +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 { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new LidarReplayContractError(`${label}: ожидался объект`); + } + return value as Record; +} + +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 = {}; + 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 { + 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 { + 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 { + 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."), + ); +} diff --git a/apps/control-station/src/productModel.ts b/apps/control-station/src/productModel.ts index 86f18bc..2539690 100644 --- a/apps/control-station/src/productModel.ts +++ b/apps/control-station/src/productModel.ts @@ -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", diff --git a/apps/control-station/src/styles/responsive.css b/apps/control-station/src/styles/responsive.css index cf1ca12..8ebd791 100644 --- a/apps/control-station/src/styles/responsive.css +++ b/apps/control-station/src/styles/responsive.css @@ -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; } diff --git a/apps/control-station/src/styles/workspaces.css b/apps/control-station/src/styles/workspaces.css index 4ed9b94..b5e1baf 100644 --- a/apps/control-station/src/styles/workspaces.css +++ b/apps/control-station/src/styles/workspaces.css @@ -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); diff --git a/apps/control-station/src/workspaces/LidarQualityWorkspace.tsx b/apps/control-station/src/workspaces/LidarQualityWorkspace.tsx new file mode 100644 index 0000000..3e73a15 --- /dev/null +++ b/apps/control-station/src/workspaces/LidarQualityWorkspace.tsx @@ -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(null); + const [detail, setDetail] = useState(null); + const [selectedPackId, setSelectedPackId] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+ {definition.eyebrow} +

{definition.title}

+

{definition.description}

+
+ Только проверенные replay-артефакты +
+ + {loading && !detail ? ( + + Проверка evidence +

Читаем LiDAR replay и quality report

+

Интерфейс покажет pack только после проверки manifest, hashes и equivalence gate.

+
+ ) : error ? ( + + Данные недоступны +

LiDAR quality report не открыт

+

{error}

+ +
+ ) : !detail ? ( + + + {catalog?.configured ? "Каталог пуст" : "Storage не настроен"} + +

Lossless LiDAR replay пока не опубликован

+

+ После подготовки первого v2 pack здесь появятся field retention, cadence, + intensity, pose coverage и readiness следующих стадий. +

+
+ ) : ( + <> +
+ + + + +
+ +
+ +
+
+ ЦЕЛОСТНОСТЬ +

Lossless replay v2

+
+ Equivalence passed +
+
+
+ Сессия + {detail.pack.sessionId} +
+
+ Сверено массивов + {detail.equivalence.arraysCompared} +
+
+ Несовпадений + {detail.equivalence.arrayMismatches} +
+
+ Intensity p50 / p95 + + {formatNumber(detail.quality.intensity.p50)} /{" "} + {formatNumber(detail.quality.intensity.p95)} + +
+
+
+ {Object.entries(detail.quality.fieldRetention).map(([field, retained]) => ( + +