diff --git a/apps/control-station/src/App.tsx b/apps/control-station/src/App.tsx index df7f3e2..41e3d80 100644 --- a/apps/control-station/src/App.tsx +++ b/apps/control-station/src/App.tsx @@ -421,6 +421,7 @@ export default function App() { setActiveRoot(rootId); if (rootId === "polygon") { workspace.openView("polygon-run"); + workspace.openNavigation(); return; } workspace.closeView(); @@ -652,7 +653,7 @@ export default function App() { className="control-station" data-observation-fullscreen={observationFullscreenActive ? "true" : undefined} navigationOpen={ - workspace.navigationOpen && activeRoot !== null && activeRoot !== "polygon" + workspace.navigationOpen && activeRoot !== null } contentOpen={workspace.contentOpen && activeDefinition !== null} contentExpanded={workspace.contentExpanded} @@ -667,7 +668,7 @@ export default function App() { onOpenDevice={() => openView("local-device")} /> } - navigation={currentRoot && activeRoot !== "polygon" ? ( + navigation={currentRoot ? ( ) : activeDefinition.kind === "polygon-run" ? ( Virtual only + ) : activeDefinition.kind === "polygon-datasets" ? ( + Offline evaluation ) : ( Интерфейс готов ) @@ -775,6 +778,7 @@ export default function App() { onLivePerceptionLayersChange={changeLivePerceptionLayers} observationLayout={observationLayout} polygonRunRoute={polygonRunRoute} + deviceLabel={selection?.model.displayName ?? null} spatialControls={selection?.SpatialControlsView ? { View: selection.SpatialControlsView, diff --git a/apps/control-station/src/productModel.ts b/apps/control-station/src/productModel.ts index 2539690..8e40748 100644 --- a/apps/control-station/src/productModel.ts +++ b/apps/control-station/src/productModel.ts @@ -19,6 +19,7 @@ export type WorkspaceKind = | "missions" | "catalog" | "lidar-quality" + | "polygon-datasets" | "polygon-run"; export type CapabilityStatus = "active" | "ready" | "contract" | "later"; @@ -133,9 +134,9 @@ export const roots: RootDefinition[] = [ label: "Полигон", title: "Полигон", eyebrow: "СИМУЛЯЦИЯ И КВАЛИФИКАЦИЯ", - description: "Живые PX4/Gazebo прогоны, сценарии, траектории и доказательства.", - statement: "Проверять поведение машины в воспроизводимой виртуальной среде.", - accent: "СИМУЛЯЦИЯ И ДОКАЗАТЕЛЬСТВА", + description: "Симуляционные, replay и shadow-прогоны с воспроизводимыми доказательствами.", + statement: "Проверять алгоритмы и поведение машины на версионированных входах.", + accent: "КВАЛИФИКАЦИЯ И ДОКАЗАТЕЛЬСТВА", }, { id: "system", @@ -152,14 +153,25 @@ export const workspaces: WorkspaceDefinition[] = [ { id: "polygon-run", root: "polygon", - label: "Полигон", - title: "Полигон", - eyebrow: "ПОЛИГОН / LIVE", - description: "Live Simulation Worker и доказательства прогонов PX4/Gazebo.", + label: "Прогоны", + title: "Прогоны", + eyebrow: "ПОЛИГОН / ПРОГОНЫ", + description: "Live Simulation Worker, история и доказательства квалификационных прогонов.", icon: "activity", kind: "polygon-run", groups: [], }, + { + id: "polygon-datasets", + root: "polygon", + label: "Датасеты", + title: "Датасеты", + eyebrow: "ПОЛИГОН / ДАТАСЕТЫ", + description: "Версионированные входы для replay-регрессии и независимой оценки алгоритмов.", + icon: "grid", + kind: "polygon-datasets", + groups: [], + }, { id: "command-overview", root: "center", @@ -589,12 +601,12 @@ export const workspaces: WorkspaceDefinition[] = [ }, { id: "lidar-quality", - root: "data", - label: "Качество LiDAR", - title: "Качество LiDAR", - eyebrow: "ДАННЫЕ / LIDAR EVIDENCE", + root: "fleet", + label: "Диагностика LiDAR", + title: "Диагностика LiDAR", + eyebrow: "ПАРК / СЕНСОРЫ / LIDAR", description: - "Проверенные поля сканера, частота, плотность, intensity, pose coverage и допуск следующих алгоритмов.", + "Состояние данных и качество записей выбранного LiDAR.", icon: "activity", kind: "lidar-quality", groups: [], diff --git a/apps/control-station/src/styles/responsive.css b/apps/control-station/src/styles/responsive.css index 45ecd11..21b17b0 100644 --- a/apps/control-station/src/styles/responsive.css +++ b/apps/control-station/src/styles/responsive.css @@ -30,6 +30,16 @@ grid-template-columns: 1fr; } + .lidar-field-cloud { + grid-column: 1; + grid-row: 1; + } + + .lidar-field-camera { + grid-column: 1; + grid-row: 2; + } + .lidar-field-camera img { min-height: 0; aspect-ratio: 4 / 3; @@ -106,6 +116,24 @@ gap: 0.7rem; } + .lidar-device-context, + .dataset-purpose, + .dataset-entry { + grid-template-columns: 1fr; + } + + .lidar-device-context__verdict { + border-top: 1px solid var(--station-hairline); + border-left: 0; + padding-top: 0.75rem; + padding-left: 0; + } + + .dataset-purpose { + align-items: start; + gap: 1rem; + } + .polygon-live-heading { align-items: stretch; flex-direction: column; @@ -137,15 +165,45 @@ } @media (max-width: 760px) { - .dataset-gateway__representations, - .dataset-gateway__grid, - .dataset-gateway__footer { + .dataset-purpose ol { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; + } + + .dataset-entry dl { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .dataset-entry dl div + div { + border-left: 0; + } + + .dataset-entry dl div:nth-child(even) { + border-left: 1px solid var(--station-hairline); + } + + .dataset-entry dl div:nth-child(n + 3) { + border-top: 1px solid var(--station-hairline); + } + + .dataset-entry footer { + align-items: stretch; + flex-direction: column; + } + + .dataset-contract__content { grid-template-columns: 1fr; } - .dataset-gateway__representations > div + div { - border-top: 1px solid rgb(255 255 255 / 0.07); - border-left: 0; + .dataset-contract summary small, + .lidar-technical-details > summary small { + display: none; + } + + .lidar-device-context__verdict { + align-items: flex-start; + flex-direction: column; } .control-station .nodedc-header__profile-button { @@ -200,7 +258,8 @@ } .lidar-ground-review > header, - .lidar-ground-review__controls { + .lidar-ground-review__controls, + .lidar-fallback-review > header { align-items: stretch; flex-direction: column; } diff --git a/apps/control-station/src/styles/workspaces.css b/apps/control-station/src/styles/workspaces.css index 6784dba..aa49599 100644 --- a/apps/control-station/src/styles/workspaces.css +++ b/apps/control-station/src/styles/workspaces.css @@ -807,144 +807,378 @@ gap: 1rem; } -.dataset-gateway { +.lidar-device-context { 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); + grid-template-columns: minmax(14rem, 0.8fr) minmax(20rem, 1.2fr) auto; + align-items: center; + gap: 1.1rem; + border-radius: 0.9rem; + background: var(--station-panel); + padding: 0.85rem 1rem; } -.dataset-gateway__heading p, -.dataset-gateway__grid p, -.dataset-gateway__pending { - margin: 0.4rem 0 0; +.lidar-device-context__identity { + display: grid; + gap: 0.22rem; +} + +.lidar-device-context__identity strong { + color: var(--nodedc-text-primary); + font-size: 0.82rem; +} + +.lidar-device-context__identity small { + overflow: hidden; + color: var(--nodedc-text-muted); + font-size: 0.6rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lidar-device-context__verdict { + display: flex; + align-items: center; + gap: 0.7rem; + min-width: 0; + border-left: 1px solid var(--station-hairline); + padding-left: 1rem; +} + +.lidar-device-context__verdict p { + margin: 0; + color: var(--nodedc-text-muted); + font-size: 0.62rem; + line-height: 1.45; +} + +.lidar-technical-details { + overflow: hidden; + border-radius: 0.9rem; + background: var(--station-panel); +} + +.lidar-technical-details > summary { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 1rem; + padding: 0.95rem 1rem; + color: var(--nodedc-text-primary); + cursor: pointer; + list-style: none; +} + +.lidar-technical-details > summary::-webkit-details-marker { + display: none; +} + +.lidar-technical-details > summary::after { + content: "+"; + color: var(--nodedc-text-muted); +} + +.lidar-technical-details[open] > summary::after { + content: "−"; +} + +.lidar-technical-details > summary span { + font-size: 0.72rem; + font-weight: 600; +} + +.lidar-technical-details > summary small { + color: var(--nodedc-text-muted); + font-size: 0.58rem; +} + +.lidar-technical-details__content { + display: grid; + gap: 0.85rem; + border-top: 1px solid var(--station-hairline); + padding: 0.85rem; +} + +.dataset-workspace { + gap: 1rem; +} + +.dataset-purpose { + display: grid; + grid-template-columns: minmax(20rem, 1fr) auto; + align-items: end; + gap: 2rem; + border-bottom: 1px solid var(--station-hairline); + padding: 0.2rem 0.1rem 1.1rem; +} + +.dataset-purpose h2, +.dataset-purpose p, +.dataset-library h2, +.dataset-entry h3, +.dataset-entry p, +.dataset-entry dl, +.dataset-contract h3, +.dataset-contract p, +.dataset-contract ul { + margin: 0; +} + +.dataset-purpose h2 { + margin-top: 0.28rem; + color: var(--nodedc-text-primary); + font-size: 1.15rem; +} + +.dataset-purpose p { + max-width: 54rem; + margin-top: 0.45rem; + color: var(--nodedc-text-muted); + font-size: 0.72rem; + line-height: 1.6; +} + +.dataset-purpose ol { + display: flex; + gap: 0.9rem; + margin: 0; + padding: 0; + list-style: none; +} + +.dataset-purpose li { + display: grid; + gap: 0.22rem; + min-width: 6.8rem; + color: var(--nodedc-text-secondary); + font-size: 0.62rem; + white-space: nowrap; +} + +.dataset-purpose li span { + color: var(--nodedc-accent); + font-size: 0.56rem; + letter-spacing: 0.12em; +} + +.dataset-state { + display: grid; + min-height: 12rem; + align-content: center; + justify-items: start; + gap: 0.65rem; +} + +.dataset-state h2, +.dataset-state p { + margin: 0; +} + +.dataset-state p { + color: var(--nodedc-text-muted); + font-size: 0.72rem; +} + +.dataset-library { + display: grid; + gap: 1rem; + border: 0; + background: var(--station-panel); +} + +.dataset-library__heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.dataset-library h2 { + margin-top: 0.25rem; + color: var(--nodedc-text-primary); + font-size: 1rem; +} + +.dataset-entry { + display: grid; + grid-template-columns: minmax(20rem, 1.2fr) minmax(22rem, 0.8fr); + gap: 1.25rem 2rem; + border-top: 1px solid var(--station-hairline); + padding-top: 1.15rem; +} + +.dataset-entry__identity { + display: flex; + gap: 0.9rem; + min-width: 0; +} + +.dataset-entry__mark { + display: grid; + flex: 0 0 auto; + width: 2.8rem; + height: 2.8rem; + place-items: center; + border-radius: 0.8rem; + background: color-mix(in srgb, var(--nodedc-accent) 13%, transparent); + color: var(--nodedc-accent); + font-size: 1rem; + font-weight: 700; +} + +.dataset-entry__identity > div > span { + color: var(--nodedc-text-muted); + font-size: 0.55rem; + letter-spacing: 0.08em; +} + +.dataset-entry h3 { + margin-top: 0.25rem; + color: var(--nodedc-text-primary); + font-size: 1.05rem; +} + +.dataset-entry__identity p { + max-width: 48rem; + margin-top: 0.45rem; color: var(--nodedc-text-muted); font-size: 0.68rem; line-height: 1.55; } -.dataset-gateway__representations { +.dataset-entry dl { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + align-self: start; + background: rgb(255 255 255 / 0.025); + border-radius: 0.75rem; +} + +.dataset-entry dl div { + display: grid; + gap: 0.25rem; + padding: 0.78rem; +} + +.dataset-entry dl div + div { + border-left: 1px solid var(--station-hairline); +} + +.dataset-entry dt { + color: var(--nodedc-text-muted); + font-size: 0.55rem; +} + +.dataset-entry dd { + margin: 0; + overflow: hidden; + color: var(--nodedc-text-primary); + font-size: 0.64rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dataset-entry footer { + display: flex; + grid-column: 1 / -1; + align-items: center; + justify-content: space-between; + gap: 1rem; + border-top: 1px solid var(--station-hairline); + padding-top: 0.9rem; +} + +.dataset-entry footer p { + color: var(--nodedc-text-muted); + font-size: 0.64rem; + line-height: 1.45; +} + +.dataset-entry footer > div { + display: flex; + gap: 0.5rem; +} + +.dataset-contract { + overflow: hidden; + background: var(--station-panel); + border-radius: 0.9rem; +} + +.dataset-contract summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.9rem 1rem; + color: var(--nodedc-text-primary); + cursor: pointer; + list-style: none; +} + +.dataset-contract summary::-webkit-details-marker { + display: none; +} + +.dataset-contract summary::after { + content: "+"; + color: var(--nodedc-text-muted); +} + +.dataset-contract[open] summary::after { + content: "−"; +} + +.dataset-contract summary span { + font-size: 0.72rem; + font-weight: 600; +} + +.dataset-contract summary small { + color: var(--nodedc-text-muted); + font-size: 0.58rem; +} + +.dataset-contract__content { 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; + gap: 1.5rem; + border-top: 1px solid var(--station-hairline); 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; +.dataset-contract h3 { color: var(--nodedc-text-primary); font-size: 0.68rem; } +.dataset-contract p, +.dataset-contract li { + color: var(--nodedc-text-muted); + font-size: 0.6rem; + line-height: 1.5; +} + +.dataset-contract section > p:first-of-type, +.dataset-contract ul { + margin-top: 0.5rem; +} + +.dataset-contract ul { + display: grid; + gap: 0.45rem; + padding: 0; + list-style: none; +} + +.dataset-contract li { + display: grid; + gap: 0.12rem; +} + +.dataset-contract li strong { + color: var(--nodedc-text-secondary); +} + .lidar-quality-message { display: grid; min-height: 12rem; @@ -1093,11 +1327,11 @@ } .lidar-field-review { + display: grid; min-width: 0; - border-color: rgb(74 215 255 / 0.22); - background: - radial-gradient(circle at 8% 0%, rgb(56 124 255 / 0.13), transparent 30rem), - var(--station-panel); + gap: 0.22rem; + border: 0; + background: var(--station-panel); } .lidar-field-review__heading p, @@ -1121,8 +1355,6 @@ display: grid; min-width: 0; gap: 0.28rem; - border: 1px solid var(--station-hairline); - border-radius: 0.72rem; background: rgb(255 255 255 / 0.025); padding: 0.64rem 0.7rem; } @@ -1169,8 +1401,8 @@ .lidar-field-window-list > button:hover, .lidar-field-window-list > button[data-selected="true"] { - border-color: rgb(74 215 255 / 0.48); - background: rgb(74 215 255 / 0.08); + border-color: color-mix(in srgb, var(--nodedc-accent) 45%, transparent); + background: color-mix(in srgb, var(--nodedc-accent) 7%, transparent); } .lidar-field-window-list > button > span { @@ -1180,8 +1412,8 @@ height: 1.35rem; place-items: center; border-radius: 50%; - background: rgb(74 215 255 / 0.12); - color: #78e3ff; + background: color-mix(in srgb, var(--nodedc-accent) 12%, transparent); + color: var(--nodedc-accent); font-size: 0.59rem; font-weight: 700; } @@ -1200,7 +1432,7 @@ .lidar-field-stage { display: grid; - grid-template-columns: minmax(18rem, 0.68fr) minmax(0, 1.32fr); + grid-template-columns: minmax(0, 1.45fr) minmax(18rem, 0.55fr); gap: 0.65rem; margin-top: 0.7rem; } @@ -1216,9 +1448,16 @@ .lidar-field-camera { display: grid; + grid-column: 2; + grid-row: 1; grid-template-rows: auto minmax(0, 1fr) auto; } +.lidar-field-cloud { + grid-column: 1; + grid-row: 1; +} + .lidar-field-camera > .lidar-field-stage__label, .lidar-field-cloud > header { min-height: 3.45rem; @@ -1304,6 +1543,39 @@ margin: 0; } +.lidar-fallback-review { + display: grid; + overflow: hidden; + margin-top: 0.8rem; + border-radius: 0.9rem; + background: #071018; +} + +.lidar-fallback-review > header { + display: flex; + min-height: 3.6rem; + align-items: center; + justify-content: space-between; + gap: 0.8rem; + border-bottom: 1px solid rgb(255 255 255 / 0.08); + padding: 0.65rem 0.75rem; +} + +.lidar-fallback-review .lidar-ground-scene, +.lidar-fallback-review .lidar-ground-scene-placeholder { + min-height: 34rem; + border: 0; + border-radius: 0; +} + +.lidar-fallback-review > footer { + border-top: 1px solid rgb(255 255 255 / 0.08); + padding: 0.58rem 0.75rem; + color: var(--nodedc-text-muted); + font-size: 0.58rem; + line-height: 1.45; +} + .lidar-ground-benchmark { min-width: 0; } diff --git a/apps/control-station/src/workspaces/DatasetGatewayPanel.tsx b/apps/control-station/src/workspaces/DatasetGatewayPanel.tsx deleted file mode 100644 index 7809f49..0000000 --- a/apps/control-station/src/workspaces/DatasetGatewayPanel.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { useEffect, useState } from "react"; -import { GlassSurface, StatusBadge } from "@nodedc/ui-react"; - -import { - fetchDatasetGatewayCatalog, - type DatasetGatewayCatalog, -} from "../core/lidar/datasetGateway"; - -const representationLabels: Record = { - "native-scan": "Один оборот / скан", - "normalized-scan": "Deskew + bounded cleanup", - "rolling-local-map": "Pose + TTL + voxel map", -}; - -export function DatasetGatewayPanel() { - const [catalog, setCatalog] = useState(null); - const [error, setError] = useState(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 ( - -
-
- DATASET GATEWAY · S0 -

Три разных LiDAR-продукта

-

- Кольцевой одиночный скан, очищенный sensor-frame и накопленная карта - больше не считаются одним облаком. -

-
- - {error - ? "Gateway недоступен" - : catalog?.storage.admitted - ? "D: допущен" - : "Ожидает D:"} - -
- - {catalog ? ( - <> -
- {catalog.representations.map((representation, index) => ( -
- 0{index + 1} - - {representationLabels[representation.id] ?? representation.title} - - {representation.title} - - {representation.accumulation - ? "накопление включено явно" - : "без накопления"} - -
- ))} -
- -
-
- ПЕРВЫЙ BASELINE -

{catalog.source.displayName}

-

- Один оборот VLS-128 в SemanticKITTI XYZI + point-wise semantic и - instance labels. Это то самое разреженное кольцевое облако, - которое корректно сравнивать с алгоритмами. -

-
- {catalog.source.platforms.join(" · ")} - {catalog.source.superclasses.length} superclasses - val {catalog.source.validationArchiveGb} ГБ - {catalog.source.license} -
-
-
- ТЕКУЩИЙ DEVICE INPUT -

Mapped feed ≠ raw scan

-

- Внешний MQTT содержит vendor-mapped increment после LIO. В нём - нет per-point time и line/ring, поэтому из него нельзя честно - восстановить один исходный скан или выполнить deskew. -

-
- Patchwork++: diagnostic only - rolling map: возможно - raw reconstruction: невозможно -
-
-
- -
-
- Storage gate - {catalog.storage.requiredWindowsRoot} - {catalog.storage.requiredWslRoot} -
-
- Следующий исполнимый шаг - - {catalog.storage.admitted - ? "Скачать GOOSE validation и импортировать первый кадр" - : "Подключить Dataset Root на D: worker"} - - Автозагрузка 3.3 ГБ намеренно запрещена -
-
- - ) : ( -

- {error ?? "Читаем входные контракты Dataset Gateway…"} -

- )} -
- ); -} diff --git a/apps/control-station/src/workspaces/DatasetGatewayWorkspace.tsx b/apps/control-station/src/workspaces/DatasetGatewayWorkspace.tsx new file mode 100644 index 0000000..a9f9c61 --- /dev/null +++ b/apps/control-station/src/workspaces/DatasetGatewayWorkspace.tsx @@ -0,0 +1,199 @@ +import { useEffect, useState } from "react"; +import { + Button, + GlassSurface, + StatusBadge, +} from "@nodedc/ui-react"; + +import { + fetchDatasetGatewayCatalog, + type DatasetGatewayCatalog, +} from "../core/lidar/datasetGateway"; + +const representationLabels: Record = { + "native-scan": "Исходный скан", + "normalized-scan": "Нормализованный скан", + "rolling-local-map": "Локальная карта", +}; + +function errorMessage(error: unknown): string { + return error instanceof Error && error.message.trim() + ? error.message + : "Каталог датасетов недоступен."; +} + +export function DatasetGatewayWorkspace() { + const [catalog, setCatalog] = 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 fetchDatasetGatewayCatalog({ signal: controller.signal }) + .then((value) => { + if (!controller.signal.aborted) setCatalog(value); + }) + .catch((loadError: unknown) => { + if (controller.signal.aborted) return; + setCatalog(null); + setError(errorMessage(loadError)); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [reloadGeneration]); + + return ( +
+
+
+ ЗАЧЕМ ЭТО НУЖНО +

Независимая проверка алгоритмов

+

+ Датасет — это вход квалификационного прогона. Мы подаём известные + размеченные данные в выбранный pipeline, измеряем ошибки и сохраняем + воспроизводимое решение. Он не подмешивается в диагностику + реального сенсора и не + заменяет closed-loop симуляцию. +

+
+
    +
  1. 01Выбрать данные
  2. +
  3. 02Запустить pipeline
  4. +
  5. 03Сравнить с разметкой
  6. +
  7. 04Принять решение
  8. +
+
+ + {loading && !catalog ? ( + + Проверяем каталог +

Читаем допущенные источники

+

Ни один большой архив не загружается автоматически.

+
+ ) : error ? ( + + Каталог недоступен +

Датасеты не открыты

+

{error}

+ +
+ ) : catalog ? ( + <> + +
+
+ КАТАЛОГ +

Доступные источники

+
+ + {catalog.storage.admitted ? "Worker готов" : "Worker не настроен"} + +
+ +
+
+ G +
+ PUBLIC · OFF-ROAD · LABELED +

{catalog.source.displayName}

+

+ Один оборот VLS-128 с point-wise semantic и instance labels. + Первый baseline для ground segmentation и последующих + LiDAR-native моделей. +

+
+
+
+
+
Состояние
+
Не загружен
+
+
+
Validation
+
{catalog.source.validationArchiveGb} ГБ
+
+
+
Разметка
+
{catalog.source.superclasses.length} классов
+
+
+
Лицензия
+
{catalog.source.license}
+
+
+
+

+ {catalog.storage.admitted + ? "Следующий шаг: загрузить validation archive на worker и проверить hash/license." + : "Сначала нужно допустить Dataset Root на диске D worker. Сейчас открывать нечего."} +

+
+ + +
+
+
+
+ +
+ + Технический контракт + Представления, storage gate и граница реального сенсора + +
+
+

Представления

+
    + {catalog.representations.map((representation) => ( +
  • + + {representationLabels[representation.id] ?? representation.title} + + {representation.title} +
  • + ))} +
+
+
+

Storage gate

+

{catalog.storage.requiredWindowsRoot}

+

{catalog.storage.requiredWslRoot}

+
+
+

Граница реального сенсора

+

+ Текущий MQTT-вход — vendor-mapped increment, а не native scan. + Отсутствующие per-point time и ring/line не восстанавливаются. +

+
+
+
+ + ) : null} +
+ ); +} diff --git a/apps/control-station/src/workspaces/LidarGroundPointCloud.tsx b/apps/control-station/src/workspaces/LidarGroundPointCloud.tsx index 090eb6d..8faee87 100644 --- a/apps/control-station/src/workspaces/LidarGroundPointCloud.tsx +++ b/apps/control-station/src/workspaces/LidarGroundPointCloud.tsx @@ -26,6 +26,16 @@ interface LidarGroundPointCloudProps { mode: LidarGroundViewMode; } +function percentile(values: number[], fraction: number): number { + if (!values.length) return 0; + const sorted = [...values].sort((left, right) => left - right); + const index = Math.min( + sorted.length - 1, + Math.max(0, Math.floor((sorted.length - 1) * fraction)), + ); + return sorted[index]; +} + function setRgb( target: Float32Array, offset: number, @@ -101,6 +111,7 @@ export function LidarGroundPointCloud({ const controlsRef = useRef(null); const fogRef = useRef(null); const gridRef = useRef(null); + const viewRadiusRef = useRef(1); const [renderError, setRenderError] = useState(null); useEffect(() => { @@ -123,7 +134,7 @@ export function LidarGroundPointCloud({ renderer.setClearColor(0x071018, 0.96); renderer.domElement.setAttribute( "aria-label", - "Интерактивное облако ground segmentation", + "Интерактивное облако LiDAR", ); host.prepend(renderer.domElement); @@ -161,7 +172,7 @@ export function LidarGroundPointCloud({ materialRef.current = material; scene.add(new THREE.Points(geometry, material)); - const grid = new THREE.GridHelper(24, 48, 0x3c7cff, 0x233747); + const grid = new THREE.GridHelper(10, 40, 0x71963d, 0x26312a); gridRef.current = grid; const gridMaterials = Array.isArray(grid.material) ? grid.material @@ -216,6 +227,7 @@ export function LidarGroundPointCloud({ controlsRef.current = null; fogRef.current = null; gridRef.current = null; + viewRadiusRef.current = 1; }; }, []); @@ -229,20 +241,22 @@ export function LidarGroundPointCloud({ if (!geometry || !material || !camera || !controls || !fog || !grid) return; const positions = new Float32Array(frame.pointCount * 3); - let minimumX = Number.POSITIVE_INFINITY; - let maximumX = Number.NEGATIVE_INFINITY; - let minimumY = Number.POSITIVE_INFINITY; - let maximumY = Number.NEGATIVE_INFINITY; - let minimumZ = Number.POSITIVE_INFINITY; - let maximumZ = Number.NEGATIVE_INFINITY; - frame.pointsXyzM.forEach(([x, y, z]) => { - minimumX = Math.min(minimumX, x); - maximumX = Math.max(maximumX, x); - minimumY = Math.min(minimumY, y); - maximumY = Math.max(maximumY, y); - minimumZ = Math.min(minimumZ, z); - maximumZ = Math.max(maximumZ, z); + const sampleStep = Math.max(1, Math.floor(frame.pointCount / 5_000)); + const sampleX: number[] = []; + const sampleY: number[] = []; + const sampleZ: number[] = []; + frame.pointsXyzM.forEach(([x, y, z], index) => { + if (index % sampleStep !== 0) return; + sampleX.push(x); + sampleY.push(y); + sampleZ.push(z); }); + const minimumX = percentile(sampleX, 0.01); + const maximumX = percentile(sampleX, 0.99); + const minimumY = percentile(sampleY, 0.01); + const maximumY = percentile(sampleY, 0.99); + const minimumZ = percentile(sampleZ, 0.01); + const maximumZ = percentile(sampleZ, 0.99); const centerX = (minimumX + maximumX) / 2; const centerY = (minimumY + maximumY) / 2; frame.pointsXyzM.forEach(([x, y, z], index) => { @@ -253,13 +267,21 @@ export function LidarGroundPointCloud({ }); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); geometry.computeBoundingSphere(); - const radius = Math.max(geometry.boundingSphere?.radius ?? 1, 0.2); + const radius = Math.max( + Math.hypot( + (maximumX - minimumX) / 2, + (maximumY - minimumY) / 2, + maximumZ - minimumZ, + ), + 0.2, + ); + viewRadiusRef.current = radius; material.size = THREE.MathUtils.clamp(radius / 155, 0.014, 0.075); fog.density = THREE.MathUtils.clamp(0.18 / radius, 0.0008, 0.035); - grid.scale.setScalar(Math.max(radius / 12, 1)); + grid.scale.setScalar(Math.max(radius / 5, 0.25)); - const targetHeight = Math.max((maximumZ - minimumZ) * 0.35, 0.15); - const distance = Math.max(radius * 1.8, 1.2); + const targetHeight = Math.max((maximumZ - minimumZ) * 0.42, 0.15); + const distance = Math.max(radius * 1.15, 0.9); controls.target.set(0, targetHeight, 0); camera.position.set(distance, distance * 0.72, distance); camera.near = Math.max(distance / 1_000, 0.005); @@ -283,10 +305,10 @@ export function LidarGroundPointCloud({ const camera = cameraRef.current; const controls = controlsRef.current; if (!geometry || !camera || !controls) return; - const radius = Math.max(geometry.boundingSphere?.radius ?? 1, 0.2); - const distance = Math.max(radius * 1.8, 1.2); + const radius = Math.max(viewRadiusRef.current, 0.2); + const distance = Math.max(radius * 1.15, 0.9); camera.position.set(distance, distance * 0.72, distance); - controls.target.set(0, radius * 0.18, 0); + controls.target.set(0, radius * 0.25, 0); controls.update(); }; diff --git a/apps/control-station/src/workspaces/LidarQualityWorkspace.tsx b/apps/control-station/src/workspaces/LidarQualityWorkspace.tsx index 70b5406..ed68405 100644 --- a/apps/control-station/src/workspaces/LidarQualityWorkspace.tsx +++ b/apps/control-station/src/workspaces/LidarQualityWorkspace.tsx @@ -21,12 +21,10 @@ import { type LidarStageReadiness, } from "../core/lidar/replayQuality"; import { MetricCard } from "../components/MetricCard"; -import type { WorkspaceDefinition } from "../productModel"; import { LidarGroundPointCloud, type LidarGroundViewMode, } from "./LidarGroundPointCloud"; -import { DatasetGatewayPanel } from "./DatasetGatewayPanel"; function formatNumber(value: number | null, digits = 1): string { if (value === null) return "—"; @@ -60,9 +58,11 @@ function errorMessage(error: unknown): string { } export function LidarQualityWorkspace({ - definition, + deviceLabel, + onOpenObservation, }: { - definition: WorkspaceDefinition; + deviceLabel: string | null; + onOpenObservation: () => void; }) { const [catalog, setCatalog] = useState(null); const [detail, setDetail] = useState(null); @@ -73,7 +73,7 @@ export function LidarQualityWorkspace({ const [groundFrameLoading, setGroundFrameLoading] = useState(false); const [groundFrameError, setGroundFrameError] = useState(null); const [groundViewMode, setGroundViewMode] = - useState("disagreement"); + useState("intensity"); const [fieldReview, setFieldReview] = useState(null); const [fieldWindow, setFieldWindow] = useState(null); @@ -81,7 +81,7 @@ export function LidarQualityWorkspace({ const [fieldLoading, setFieldLoading] = useState(true); const [fieldError, setFieldError] = useState(null); const [fieldViewMode, setFieldViewMode] = - useState("disagreement"); + useState("intensity"); const [selectedPackId, setSelectedPackId] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -116,8 +116,11 @@ export function LidarQualityWorkspace({ if (controller.signal.aborted) return; setSelectedPackId(target); setDetail(nextDetail); - setGroundBenchmark(groundCatalog.items[0] ?? null); - setGroundFrameIndex(0); + const nextGroundBenchmark = groundCatalog.items[0] ?? null; + setGroundBenchmark(nextGroundBenchmark); + setGroundFrameIndex( + nextGroundBenchmark ? Math.floor(nextGroundBenchmark.frames / 2) : 0, + ); } catch (loadError) { if (controller.signal.aborted) return; setDetail(null); @@ -212,17 +215,6 @@ export function LidarQualityWorkspace({ return (
-
-
- {definition.eyebrow} -

{definition.title}

-

{definition.description}

-
- Только проверенные replay-артефакты -
- - - {loading && !detail ? ( Проверка evidence @@ -255,22 +247,49 @@ export function LidarQualityWorkspace({ ) : ( <> +
+
+ ВЫБРАННЫЙ СЕНСОР + {deviceLabel ?? "Архивный источник"} · LiDAR + + Запись {fieldReview?.sessionId ?? detail.pack.sessionId} + +
+
+ Диагностическая запись +

+ Данные воспроизводятся и доступны для визуального разбора. + Физическая точность сенсора этой записью не аттестована. +

+
+ +
+
- ПОЛЕВОЙ REVIEW · RAVNOVES00 + + {fieldReview ? "ОБЛАКО LIDAR · RAVNOVES00" : "ОБЛАКО LIDAR · ЗАПИСЬ"} +

- {fieldReview?.displayName - ?? "Центральный городской интервал"} + {fieldReview + ? `Городская сцена: ${selectedFieldWindow?.label ?? fieldReview.displayName}` + : `Кадр ${groundFrameIndex + 1} из ${groundBenchmark?.frames ?? 0}`}

- Дорога, дома, автомобили и растительность. Облако накоплено - по исходным LiDAR-сэмплам выбранного окна, а кадр камеры - фиксирует контекст сцены. + {fieldReview + ? "Выберите один из пяти интервалов, вращайте облако мышью и при необходимости включите экспериментальные маски обработки." + : "Полевая запись на этом хосте не подключена. Показываем доступную запись выбранного LiDAR без подмешивания публичных датасетов."}

- {fieldError ? "Review недоступен" : "Visual review only"} + {fieldError ? "Облако недоступно" : "Только собственные данные"}
@@ -389,6 +408,7 @@ export function LidarQualityWorkspace({ aria-label="Режим окраски полевого LiDAR" > {([ + ["intensity", "Облако"], ["current", "Current"], ["candidate", "Patchwork++"], ["disagreement", "Расхождения"], @@ -441,6 +461,87 @@ export function LidarQualityWorkspace({
+ ) : groundBenchmark ? ( +
+
+
+ {([ + ["intensity", "Облако"], + ["current", "Current"], + ["candidate", "Patchwork++"], + ["disagreement", "Расхождения"], + ] as const).map(([mode, label]) => ( + + ))} +
+
+ + + setGroundFrameIndex(Number(event.currentTarget.value)) + } + /> + +
+
+ {groundFrame ? ( + + ) : ( +
+ + {groundFrameError ? "Кадр недоступен" : "Загрузка"} + +

{groundFrameError ?? "Читаем облако LiDAR…"}

+
+ )} +
+ Это техническая запись из 66 кадров. Она позволяет проверить + геометрию и управление viewer, но не заменяет полевую сцену. +
+
) : (
@@ -481,6 +582,14 @@ export function LidarQualityWorkspace({ /> +
+ + Технические детали и эксперименты + + Целостность записи, допуски стадий, Patchwork++ и contract slice + + +
@@ -813,6 +922,8 @@ export function LidarQualityWorkspace({ ))} +
+
)}
diff --git a/apps/control-station/src/workspaces/Workspaces.tsx b/apps/control-station/src/workspaces/Workspaces.tsx index 65b68cd..6b938e2 100644 --- a/apps/control-station/src/workspaces/Workspaces.tsx +++ b/apps/control-station/src/workspaces/Workspaces.tsx @@ -58,6 +58,7 @@ import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from ".. import type { SceneSettings } from "../sceneSettings"; import { PolygonRunWorkspace } from "./PolygonRunWorkspace"; import { LidarQualityWorkspace } from "./LidarQualityWorkspace"; +import { DatasetGatewayWorkspace } from "./DatasetGatewayWorkspace"; function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" { if (status === "active") return "success"; @@ -140,6 +141,7 @@ export interface WorkspaceRendererProps { }) => void; observationLayout: ObservationLayoutController; polygonRunRoute: PolygonRunRoute; + deviceLabel: string | null; navigation: WorkspaceNavigation; spatialControls: { View: ComponentType; @@ -1308,7 +1310,14 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) { case "catalog": return ; case "lidar-quality": - return ; + return ( + props.navigation.openView("spatial-scene")} + /> + ); + case "polygon-datasets": + return ; case "polygon-run": return ; case "device": diff --git a/apps/control-station/test/lidarReplayQuality.test.mjs b/apps/control-station/test/lidarReplayQuality.test.mjs index 01a8ded..343155e 100644 --- a/apps/control-station/test/lidarReplayQuality.test.mjs +++ b/apps/control-station/test/lidarReplayQuality.test.mjs @@ -610,6 +610,6 @@ test("LiDAR fetchers use read-only endpoints and workspace is registered", async method: "GET", }, ]); - assert.equal(workspaceById("lidar-quality").root, "data"); + assert.equal(workspaceById("lidar-quality").root, "fleet"); assert.equal(workspaceById("lidar-quality").kind, "lidar-quality"); }); diff --git a/apps/control-station/test/polygonRunArchive.test.mjs b/apps/control-station/test/polygonRunArchive.test.mjs index 6102384..2f7072e 100644 --- a/apps/control-station/test/polygonRunArchive.test.mjs +++ b/apps/control-station/test/polygonRunArchive.test.mjs @@ -314,6 +314,8 @@ test("Polygon is a worker-gated product root and keeps a compatible direct route workspacesForRoot("polygon").some(({ id }) => id === "polygon-run"), true, ); + assert.equal(workspaceById("polygon-datasets").root, "polygon"); + assert.equal(workspaceById("polygon-datasets").kind, "polygon-datasets"); assert.equal( workspacesForRoot("system").some(({ id }) => id === "polygon-run"), false, diff --git a/docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md b/docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md index 6d64ccb..f7fbf08 100644 --- a/docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md +++ b/docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md @@ -77,9 +77,11 @@ Polygon is now a parallel product branch. As of this document: Simulation Worker agent in a fresh loopback-only namespace, a bounded Unix-socket Mission Core gateway and an explicit `internal-virtual-only` lifecycle gate; -- UI-2 exact generation `60e7916` exposes `Полигон` as a dedicated header root - only while Mission Core confirms an available Simulation Worker. The header - opens one direct workspace without a secondary launcher panel; +- UI-2 exact generation `60e7916` established `Полигон` as a dedicated header + root only while Mission Core confirms an available Simulation Worker. The + current product surface keeps that direct entry and exposes a bounded + two-item navigation: `Прогоны` and `Датасеты`; it has no launcher or link + directory; - the browser-native Three.js scene renders a recognisable procedural Ackermann Rover, ground grid and ENU trajectory. Orbit supports 360° azimuth, bounded vertical inspection from overhead to horizon, zoom, pan, follow and camera @@ -842,6 +844,18 @@ Center | Fleet | Observation | Missions | Data | Polygon* | System Two UI gates are intentionally distinct. +The Polygon navigation is task-oriented: + +- `Прогоны` owns live simulation lifecycle, history and qualification evidence; +- `Датасеты` owns admitted replay inputs and the entry point for a future + qualification run. + +Dataset bytes and source artifacts remain in Data storage, but algorithm +evaluation does not live under `Данные`. Device-specific scanner diagnostics +remain under Fleet. A dataset preview is not a qualification result; the +result is a versioned run with pipeline, sensor profile, metrics, provenance +and decision. + ### UI-0 — early read-only run view Target S1B proves persisted start/health/stop history. UI-0 is implemented as a diff --git a/docs/14_LIDAR_DATASET_GATEWAY.md b/docs/14_LIDAR_DATASET_GATEWAY.md index 59a5cbb..dbbcf25 100644 --- a/docs/14_LIDAR_DATASET_GATEWAY.md +++ b/docs/14_LIDAR_DATASET_GATEWAY.md @@ -30,7 +30,9 @@ Implemented now: - a fail-closed K1 `lio_pcl` boundary; - worker storage admission for `D:\NDC_MISSIONCORE\datasets` and `/mnt/d/NDC_MISSIONCORE/datasets`; -- a visible Dataset Gateway panel in **Данные → Качество LiDAR**. +- a dedicated, honest dataset catalog in **Полигон → Датасеты**; +- a separate **Парк → Диагностика LiDAR** surface containing only real sensor + recordings and their operational evidence. Not implemented in S0: @@ -40,6 +42,23 @@ Not implemented in S0: - no model training or production promotion; - no rolling-map implementation yet. +## Product surface boundary + +The gateway is not part of device quality diagnostics. + +- **Парк → Диагностика LiDAR** answers whether a selected real sensor recording + is present, reproducible and operationally usable. It never mixes public + dataset frames into the selected device evidence. +- **Наблюдение** opens a concrete live or recorded spatial scene. +- **Данные** owns source-of-record, retention, replay preparation and export. +- **Полигон → Датасеты** lists admitted evaluation inputs. +- **Полигон → Прогоны** owns the resulting algorithm comparison, metrics, + provenance and decision. + +Before real dataset bytes exist, the catalog shows `Не загружен`, explains the +blocked storage gate and keeps `Открыть` / `Создать прогон` disabled. An +architecture contract must not look like an executable dataset tool. + ## Why public recordings look different GOOSE stores one VLS-128 revolution per annotated `.bin` file. A rotating @@ -130,7 +149,9 @@ time; TTL/dynamic filtering prevents stale ghosts. - [x] GOOSE XYZI and labels remain point aligned. - [x] Invalid length and non-finite frames fail closed. - [x] K1 mapped increments cannot claim raw-scan fields. -- [x] React exposes the architectural truth before dataset bytes exist. +- [x] React exposes an honest catalog/empty state before dataset bytes exist. +- [x] Dataset Gateway is isolated from real-sensor diagnostics and placed under + Polygon qualification. - [ ] Worker D root configured. - [ ] GOOSE validation archive hash recorded. - [ ] First real labeled frame visible in React. diff --git a/docs/adr/0021-dataset-gateway-representation-boundary.md b/docs/adr/0021-dataset-gateway-representation-boundary.md index 31556b1..5e90fd6 100644 --- a/docs/adr/0021-dataset-gateway-representation-boundary.md +++ b/docs/adr/0021-dataset-gateway-representation-boundary.md @@ -69,6 +69,12 @@ unavailable rather than inventing timestamps. ## Consequences - The UI must label accumulated K1 evidence as a map product, not a scan. +- Real-sensor diagnostics live under Fleet and never mix public dataset frames + into device evidence. +- Dataset catalog, preview and qualification-run entry live under Polygon; + Data remains the source-of-record and artifact store. +- Until bytes are installed, the UI exposes an explicit empty state rather + than presenting this architecture contract as an interactive tool. - Dataset and device inputs can share downstream algorithms only after their normalized contracts match. - Sensor adaptation may change range, FOV, point density, noise and dropout for