feat(lidar): add dataset gateway boundary

This commit is contained in:
DCCONSTRUCTIONS
2026-07-25 11:21:22 +03:00
parent 3333e9ac0f
commit f57d64bf52
14 changed files with 1314 additions and 17 deletions
@@ -0,0 +1,243 @@
export type DatasetRepresentationId =
| "native-scan"
| "normalized-scan"
| "rolling-local-map";
export interface DatasetGatewayCatalog {
storage: {
configured: boolean;
admitted: boolean;
status: "ready" | "blocked-storage-policy";
requiredWindowsRoot: string;
requiredWslRoot: string;
};
source: {
sourceId: string;
displayName: string;
role: string;
license: string;
format: string;
frameSemantics: "one-lidar-revolution";
platforms: string[];
superclasses: string[];
validationArchiveGb: number;
admissionStatus: "ready-for-download" | "blocked-storage-policy";
};
representations: Array<{
id: DatasetRepresentationId;
title: string;
purpose: string;
accumulation: boolean;
}>;
pipeline: Array<{
stage: string;
requires: string[];
produces: string;
}>;
currentInput: {
representation: "vendor-mapped-increment";
nativeScan: false;
perPointTime: false;
ringOrLine: false;
admittedForPatchworkpp: false;
reason: string;
};
nextAction: string;
}
export class DatasetGatewayContractError extends Error {}
type DatasetFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
const SAFE_ID = /^[a-z0-9][a-z0-9._:/-]{0,159}$/;
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new DatasetGatewayContractError(`${label}: ожидался объект`);
}
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) {
throw new DatasetGatewayContractError(`${label}: ожидался массив`);
}
return value;
}
function string(value: unknown, label: string, safe = false): string {
if (
typeof value !== "string"
|| !value
|| (safe && !SAFE_ID.test(value))
) {
throw new DatasetGatewayContractError(`${label}: некорректная строка`);
}
return value;
}
function strings(value: unknown, label: string): string[] {
return array(value, label).map((item, index) =>
string(item, `${label}[${index}]`, true)
);
}
function displayStrings(value: unknown, label: string): string[] {
return array(value, label).map((item, index) =>
string(item, `${label}[${index}]`)
);
}
function boolean(value: unknown, label: string): boolean {
if (typeof value !== "boolean") {
throw new DatasetGatewayContractError(`${label}: ожидался boolean`);
}
return value;
}
function number(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new DatasetGatewayContractError(`${label}: некорректное число`);
}
return value;
}
export function parseDatasetGatewayCatalog(
value: unknown,
): DatasetGatewayCatalog {
const source = record(value, "Dataset Gateway");
if (
source.schema_version !== "missioncore.dataset-gateway-catalog/v1"
|| source.access !== "read-only"
) {
throw new DatasetGatewayContractError("Dataset Gateway contract несовместим");
}
const storage = record(source.storage, "storage");
const storageStatus = storage.status;
if (storageStatus !== "ready" && storageStatus !== "blocked-storage-policy") {
throw new DatasetGatewayContractError("storage.status: неизвестное значение");
}
if (storage.path_exposed !== false) {
throw new DatasetGatewayContractError("Dataset Gateway раскрыл локальный путь");
}
const sources = array(source.sources, "sources");
if (sources.length !== 1) {
throw new DatasetGatewayContractError("Ожидался один первичный dataset source");
}
const dataset = record(sources[0], "sources[0]");
const download = record(dataset.download, "source.download");
const admission = record(dataset.admission, "source.admission");
if (download.automatic !== false) {
throw new DatasetGatewayContractError("Большой dataset нельзя загружать автоматически");
}
const admissionStatus = admission.status;
if (
admissionStatus !== "ready-for-download"
&& admissionStatus !== "blocked-storage-policy"
) {
throw new DatasetGatewayContractError("source admission status неизвестен");
}
const representations = array(
source.representations,
"representations",
).map((value, index) => {
const item = record(value, `representations[${index}]`);
const id = item.id;
if (
id !== "native-scan"
&& id !== "normalized-scan"
&& id !== "rolling-local-map"
) {
throw new DatasetGatewayContractError("Неизвестная LiDAR representation");
}
const normalizedId: DatasetRepresentationId = id;
return {
id: normalizedId,
title: string(item.title, "representation.title"),
purpose: string(item.purpose, "representation.purpose", true),
accumulation: boolean(item.accumulation, "representation.accumulation"),
};
});
const pipeline = array(source.pipeline, "pipeline").map((value, index) => {
const item = record(value, `pipeline[${index}]`);
return {
stage: string(item.stage, "pipeline.stage", true),
requires: strings(item.requires, "pipeline.requires"),
produces: string(item.produces, "pipeline.produces", true),
};
});
const inputs = array(source.known_inputs, "known_inputs");
const currentInput = record(inputs[0], "known_inputs[0]");
if (
currentInput.representation !== "vendor-mapped-increment"
|| currentInput.native_scan !== false
|| currentInput.per_point_time !== false
|| currentInput.ring_or_line !== false
|| currentInput.admitted_for_patchworkpp !== false
) {
throw new DatasetGatewayContractError("Vendor-map boundary завышен");
}
if (dataset.frame_semantics !== "one-lidar-revolution") {
throw new DatasetGatewayContractError("GOOSE frame semantics несовместима");
}
return {
storage: {
configured: boolean(storage.configured, "storage.configured"),
admitted: boolean(storage.admitted, "storage.admitted"),
status: storageStatus,
requiredWindowsRoot: string(
storage.required_windows_root,
"storage.required_windows_root",
),
requiredWslRoot: string(storage.required_wsl_root, "storage.required_wsl_root"),
},
source: {
sourceId: string(dataset.source_id, "source_id", true),
displayName: string(dataset.display_name, "display_name"),
role: string(dataset.role, "role", true),
license: string(dataset.license, "license"),
format: string(dataset.format, "format", true),
frameSemantics: "one-lidar-revolution",
platforms: displayStrings(dataset.platforms, "platforms"),
superclasses: strings(dataset.superclasses, "superclasses"),
validationArchiveGb: number(
download.validation_archive_gb,
"validation_archive_gb",
),
admissionStatus,
},
representations,
pipeline,
currentInput: {
representation: "vendor-mapped-increment",
nativeScan: false,
perPointTime: false,
ringOrLine: false,
admittedForPatchworkpp: false,
reason: string(currentInput.reason, "known_inputs.reason", true),
},
nextAction: string(source.next_action, "next_action", true),
};
}
async function responseJson(response: Response): Promise<unknown> {
if (!response.ok) {
throw new Error(`Dataset Gateway HTTP ${response.status}`);
}
return response.json();
}
export async function fetchDatasetGatewayCatalog(
options: { signal?: AbortSignal; fetcher?: DatasetFetch } = {},
): Promise<DatasetGatewayCatalog> {
const fetcher = options.fetcher ?? fetch;
const response = await fetcher("/api/v1/lidar/dataset-gateway", {
method: "GET",
headers: { Accept: "application/json" },
signal: options.signal,
});
return parseDatasetGatewayCatalog(await responseJson(response));
}
@@ -137,6 +137,17 @@
}
@media (max-width: 760px) {
.dataset-gateway__representations,
.dataset-gateway__grid,
.dataset-gateway__footer {
grid-template-columns: 1fr;
}
.dataset-gateway__representations > div + div {
border-top: 1px solid rgb(255 255 255 / 0.07);
border-left: 0;
}
.control-station .nodedc-header__profile-button {
display: none;
}
@@ -807,6 +807,144 @@
gap: 1rem;
}
.dataset-gateway {
display: grid;
gap: 1rem;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--nodedc-accent) 24%, transparent);
background:
radial-gradient(circle at 10% 0%, color-mix(in srgb, var(--nodedc-accent) 10%, transparent), transparent 32%),
rgb(255 255 255 / 0.025);
}
.dataset-gateway__heading p,
.dataset-gateway__grid p,
.dataset-gateway__pending {
margin: 0.4rem 0 0;
color: var(--nodedc-text-muted);
font-size: 0.68rem;
line-height: 1.55;
}
.dataset-gateway__representations {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
overflow: hidden;
border: 1px solid rgb(255 255 255 / 0.07);
border-radius: 0.9rem;
}
.dataset-gateway__representations > div {
position: relative;
display: grid;
min-height: 7.4rem;
align-content: center;
gap: 0.3rem;
padding: 1rem 1.1rem 1rem 3.6rem;
background: rgb(255 255 255 / 0.025);
}
.dataset-gateway__representations > div + div {
border-left: 1px solid rgb(255 255 255 / 0.07);
}
.dataset-gateway__representations > div > span {
position: absolute;
top: 1rem;
left: 1rem;
color: var(--nodedc-accent);
font-size: 0.62rem;
letter-spacing: 0.14em;
}
.dataset-gateway__representations strong,
.dataset-gateway__representations small,
.dataset-gateway__representations em {
display: block;
}
.dataset-gateway__representations strong {
color: var(--nodedc-text-primary);
font-size: 0.76rem;
}
.dataset-gateway__representations small,
.dataset-gateway__representations em {
color: var(--nodedc-text-muted);
font-size: 0.58rem;
line-height: 1.35;
}
.dataset-gateway__representations em {
color: color-mix(in srgb, var(--nodedc-accent) 72%, white);
font-style: normal;
}
.dataset-gateway__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
}
.dataset-gateway__grid > section {
min-width: 0;
padding: 1rem;
border: 1px solid rgb(255 255 255 / 0.07);
border-radius: 0.9rem;
background: rgb(255 255 255 / 0.02);
}
.dataset-gateway__grid > section[data-warning="true"] {
border-color: rgb(255 181 71 / 0.2);
}
.dataset-gateway__grid h3 {
margin: 0.28rem 0 0;
color: var(--nodedc-text-primary);
font-size: 1rem;
}
.dataset-gateway__facts {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.8rem;
}
.dataset-gateway__facts span {
padding: 0.32rem 0.5rem;
border-radius: 999px;
background: rgb(255 255 255 / 0.045);
color: var(--nodedc-text-secondary);
font-size: 0.56rem;
}
.dataset-gateway__footer {
display: grid;
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
gap: 1rem;
padding-top: 0.85rem;
border-top: 1px solid rgb(255 255 255 / 0.07);
}
.dataset-gateway__footer > div {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.dataset-gateway__footer span,
.dataset-gateway__footer small {
color: var(--nodedc-text-muted);
font-size: 0.58rem;
}
.dataset-gateway__footer strong {
overflow-wrap: anywhere;
color: var(--nodedc-text-primary);
font-size: 0.68rem;
}
.lidar-quality-message {
display: grid;
min-height: 12rem;
@@ -0,0 +1,133 @@
import { useEffect, useState } from "react";
import { GlassSurface, StatusBadge } from "@nodedc/ui-react";
import {
fetchDatasetGatewayCatalog,
type DatasetGatewayCatalog,
} from "../core/lidar/datasetGateway";
const representationLabels: Record<string, string> = {
"native-scan": "Один оборот / скан",
"normalized-scan": "Deskew + bounded cleanup",
"rolling-local-map": "Pose + TTL + voxel map",
};
export function DatasetGatewayPanel() {
const [catalog, setCatalog] = useState<DatasetGatewayCatalog | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
void fetchDatasetGatewayCatalog({ signal: controller.signal })
.then((value) => {
if (!controller.signal.aborted) setCatalog(value);
})
.catch((loadError: unknown) => {
if (controller.signal.aborted) return;
setError(
loadError instanceof Error
? loadError.message
: "Dataset Gateway недоступен",
);
});
return () => controller.abort();
}, []);
return (
<GlassSurface className="dataset-gateway" padding="lg">
<header className="panel-heading dataset-gateway__heading">
<div>
<span className="section-eyebrow">DATASET GATEWAY · S0</span>
<h2>Три разных LiDAR-продукта</h2>
<p>
Кольцевой одиночный скан, очищенный sensor-frame и накопленная карта
больше не считаются одним облаком.
</p>
</div>
<StatusBadge
tone={error ? "danger" : catalog?.storage.admitted ? "success" : "warning"}
>
{error
? "Gateway недоступен"
: catalog?.storage.admitted
? "D: допущен"
: "Ожидает D:"}
</StatusBadge>
</header>
{catalog ? (
<>
<div className="dataset-gateway__representations">
{catalog.representations.map((representation, index) => (
<div key={representation.id}>
<span>0{index + 1}</span>
<strong>
{representationLabels[representation.id] ?? representation.title}
</strong>
<small>{representation.title}</small>
<em>
{representation.accumulation
? "накопление включено явно"
: "без накопления"}
</em>
</div>
))}
</div>
<div className="dataset-gateway__grid">
<section>
<span className="section-eyebrow">ПЕРВЫЙ BASELINE</span>
<h3>{catalog.source.displayName}</h3>
<p>
Один оборот VLS-128 в SemanticKITTI XYZI + point-wise semantic и
instance labels. Это то самое разреженное кольцевое облако,
которое корректно сравнивать с алгоритмами.
</p>
<div className="dataset-gateway__facts">
<span>{catalog.source.platforms.join(" · ")}</span>
<span>{catalog.source.superclasses.length} superclasses</span>
<span>val {catalog.source.validationArchiveGb} ГБ</span>
<span>{catalog.source.license}</span>
</div>
</section>
<section data-warning="true">
<span className="section-eyebrow">ТЕКУЩИЙ DEVICE INPUT</span>
<h3>Mapped feed raw scan</h3>
<p>
Внешний MQTT содержит vendor-mapped increment после LIO. В нём
нет per-point time и line/ring, поэтому из него нельзя честно
восстановить один исходный скан или выполнить deskew.
</p>
<div className="dataset-gateway__facts">
<span>Patchwork++: diagnostic only</span>
<span>rolling map: возможно</span>
<span>raw reconstruction: невозможно</span>
</div>
</section>
</div>
<footer className="dataset-gateway__footer">
<div>
<span>Storage gate</span>
<strong>{catalog.storage.requiredWindowsRoot}</strong>
<small>{catalog.storage.requiredWslRoot}</small>
</div>
<div>
<span>Следующий исполнимый шаг</span>
<strong>
{catalog.storage.admitted
? "Скачать GOOSE validation и импортировать первый кадр"
: "Подключить Dataset Root на D: worker"}
</strong>
<small>Автозагрузка 3.3 ГБ намеренно запрещена</small>
</div>
</footer>
</>
) : (
<p className="dataset-gateway__pending">
{error ?? "Читаем входные контракты Dataset Gateway…"}
</p>
)}
</GlassSurface>
);
}
@@ -26,6 +26,7 @@ import {
LidarGroundPointCloud,
type LidarGroundViewMode,
} from "./LidarGroundPointCloud";
import { DatasetGatewayPanel } from "./DatasetGatewayPanel";
function formatNumber(value: number | null, digits = 1): string {
if (value === null) return "—";
@@ -220,6 +221,8 @@ export function LidarQualityWorkspace({
<span className="workspace-lead__note">Только проверенные replay-артефакты</span>
</section>
<DatasetGatewayPanel />
{loading && !detail ? (
<GlassSurface className="lidar-quality-message" padding="lg">
<StatusBadge tone="accent">Проверка evidence</StatusBadge>