fix(control-station): separate lab contours from recordings

This commit is contained in:
DCCONSTRUCTIONS
2026-07-26 13:34:12 +03:00
parent 05413e3f5c
commit 42041b37cd
10 changed files with 840 additions and 171 deletions
+9 -1
View File
@@ -26,6 +26,7 @@ import {
} from "@nodedc/ui-react";
import { LandingStage } from "./components/LandingStage";
import { ObservationSessionSelect } from "./components/ObservationSessionSelect";
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
import type { ViewerSettings } from "./core/runtime/contracts";
@@ -752,7 +753,14 @@ export default function App() {
) : null}
</div>
) : activeDefinition.kind === "recordings" ? (
<StatusBadge tone="accent">Архив записей</StatusBadge>
<ObservationSessionSelect
disabled={runtime.pendingAction !== null || sourceSwitchBlocked}
blockedReason={sourceSwitchBlockedReason}
onReplayBegin={beginRecordedReplaySwitch}
onReplayAccepted={acceptRecordedReplay}
onReplaySettled={(_session, outcome) =>
settleRecordedReplaySwitch(outcome)}
/>
) : activeDefinition.kind === "datasets" ? (
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
) : activeDefinition.kind === "lab-archive" ? (
+3 -3
View File
@@ -153,10 +153,10 @@ export const workspaces: WorkspaceDefinition[] = [
{
id: "lab-archive",
root: "polygon",
label: "Лабораторные работы",
title: "Архив лабораторных работ",
label: "Лабораторные контуры",
title: "Лабораторные контуры",
eyebrow: "ТЕСТОВЫЙ КОНТУР / ЛАБОРАТОРИЯ",
description: "Конфигурации, ход эксперимента и визуальные результаты принятых лабораторных работ.",
description: "Профили конфигураций, лабораторные работы и их воспроизводимые результаты.",
icon: "clipboard",
kind: "lab-archive",
groups: [],
+312 -69
View File
@@ -2385,18 +2385,17 @@
.recordings-workspace,
.lab-archive-workspace,
.recordings-workspace__viewer,
.lab-result-surface {
display: grid;
gap: 0.85rem;
}
.recordings-workspace__viewer,
.lab-result-surface {
min-height: 30rem;
.recordings-workspace {
width: 100%;
height: 100%;
min-height: 34rem;
}
.recordings-workspace__viewer > header,
.lab-result-surface > header {
display: flex;
align-items: center;
@@ -2404,102 +2403,346 @@
padding: 0 0.2rem;
}
.recordings-workspace__viewer > header strong,
.lab-result-surface > header strong {
color: var(--nodedc-text-secondary);
font-size: 0.7rem;
}
.recordings-workspace__empty {
.lab-archive-workspace {
padding-bottom: 1rem;
}
.laboratory-selector {
display: grid;
min-height: 27rem;
place-items: center;
align-content: center;
gap: 0.5rem;
border-radius: 1rem;
background: rgb(0 0 0 / 0.24);
color: var(--nodedc-text-muted);
text-align: center;
}
.recordings-workspace__empty strong {
color: var(--nodedc-text-secondary);
font-size: 0.75rem;
}
.recordings-workspace__empty p {
margin: 0;
font-size: 0.63rem;
}
.lab-configuration {
display: flex;
grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.38fr);
align-items: center;
justify-content: space-between;
gap: 1rem;
gap: 1.4rem;
border-radius: 1rem;
background: rgb(255 255 255 / 0.025);
padding: 1rem;
}
.lab-configuration h2,
.lab-configuration p {
.laboratory-selector h2,
.laboratory-selector p {
margin: 0;
}
.lab-configuration h2 {
.laboratory-selector h2 {
margin-top: 0.25rem;
color: var(--nodedc-text-primary);
font-size: 1rem;
}
.lab-configuration p {
max-width: 55rem;
.laboratory-selector p {
max-width: 60rem;
margin-top: 0.35rem;
color: var(--nodedc-text-muted);
font-size: 0.66rem;
font-size: 0.64rem;
line-height: 1.5;
}
.lab-primary-result {
.laboratory-selector label {
display: grid;
gap: 0.35rem;
}
.lab-primary-result button {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.3rem 0.75rem;
border: 0;
border-radius: 0.85rem;
background: rgb(255 255 255 / 0.025);
color: var(--nodedc-text-secondary);
padding: 0.8rem 1rem;
text-align: left;
cursor: pointer;
}
.lab-primary-result button[data-active="true"] {
background: rgb(255 255 255 / 0.065);
}
.lab-primary-result button > span {
grid-row: 1 / 3;
align-self: center;
color: var(--nodedc-accent);
font-size: 0.65rem;
font-weight: 700;
}
.lab-primary-result button > strong {
color: var(--nodedc-text-primary);
font-size: 0.72rem;
}
.lab-primary-result button > small {
.laboratory-selector label > span {
color: var(--nodedc-text-muted);
font-size: 0.55rem;
font-weight: 650;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.laboratory-selector select {
width: 100%;
min-width: 0;
border: 0;
border-radius: 999px;
outline: 0;
background: rgb(255 255 255 / 0.075);
color: var(--nodedc-text-primary);
padding: 0.58rem 0.85rem;
font: inherit;
font-size: 0.65rem;
font-weight: 620;
}
.laboratory-selector select:focus-visible {
background: rgb(255 255 255 / 0.12);
}
.laboratory-selector select:disabled {
opacity: 0.45;
}
.laboratory-work-output {
display: grid;
gap: 0.85rem;
}
.laboratory-task,
.laboratory-result-summary,
.laboratory-visual-result {
border-radius: 1rem;
background: rgb(255 255 255 / 0.025);
padding: 1rem;
}
.laboratory-task > header,
.laboratory-result-summary > header,
.laboratory-visual-result > header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
}
.laboratory-task h2,
.laboratory-task p,
.laboratory-task dl,
.laboratory-result-summary h2,
.laboratory-result-summary p,
.laboratory-visual-result h2,
.laboratory-visual-result p {
margin: 0;
}
.laboratory-task h2,
.laboratory-result-summary h2,
.laboratory-visual-result h2 {
margin-top: 0.3rem;
color: var(--nodedc-text-primary);
font-size: 1rem;
letter-spacing: -0.025em;
}
.laboratory-task p,
.laboratory-result-summary > p,
.laboratory-visual-result header p {
max-width: 66rem;
margin-top: 0.38rem;
color: var(--nodedc-text-muted);
font-size: 0.63rem;
line-height: 1.55;
}
.laboratory-task dl {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.4rem;
margin-top: 0.9rem;
}
.laboratory-task dl > div,
.laboratory-result-metrics > div {
display: grid;
gap: 0.25rem;
border-radius: 0.75rem;
background: rgb(255 255 255 / 0.035);
padding: 0.7rem;
}
.laboratory-task dt,
.laboratory-task dd {
margin: 0;
}
.laboratory-task dt,
.laboratory-result-metrics span,
.laboratory-result-metrics small {
color: var(--nodedc-text-muted);
font-size: 0.54rem;
}
.laboratory-task dd,
.laboratory-result-metrics strong {
overflow: hidden;
color: var(--nodedc-text-primary);
font-size: 0.7rem;
font-weight: 660;
text-overflow: ellipsis;
white-space: nowrap;
}
.lab-result-surface {
min-height: 36rem;
}
.lab-result-surface .spatial-workspace {
min-height: 34rem;
}
.laboratory-result-pending {
display: grid;
min-height: 34rem;
place-items: center;
align-content: center;
gap: 0.5rem;
border-radius: 1rem;
background: #06070a;
color: var(--nodedc-text-muted);
text-align: center;
}
.laboratory-result-pending strong {
color: var(--nodedc-text-secondary);
font-size: 0.75rem;
}
.laboratory-result-pending p {
max-width: 34rem;
margin: 0;
font-size: 0.62rem;
}
.laboratory-visual-result {
display: grid;
min-height: 34rem;
align-content: start;
gap: 1rem;
background: #08090c;
}
.laboratory-e29-chart {
display: grid;
gap: 0.85rem;
border-radius: 0.9rem;
background: rgb(255 255 255 / 0.025);
padding: 1rem;
}
.laboratory-e29-chart__bar {
display: flex;
height: 1.1rem;
overflow: hidden;
border-radius: 999px;
background: rgb(255 255 255 / 0.04);
}
.laboratory-e29-chart__bar i[data-status="agree"],
.laboratory-e29-chart__legend i[data-status="agree"] {
background: rgb(var(--nodedc-success-rgb));
}
.laboratory-e29-chart__bar i[data-status="camera"],
.laboratory-e29-chart__legend i[data-status="camera"] {
background: rgb(var(--nodedc-accent-rgb));
}
.laboratory-e29-chart__bar i[data-status="conflict"],
.laboratory-e29-chart__legend i[data-status="conflict"] {
min-width: 0.25rem;
background: rgb(var(--nodedc-warning-rgb));
}
.laboratory-e29-chart__legend {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
}
.laboratory-e29-chart__legend > div {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.25rem 0.45rem;
border-radius: 0.75rem;
background: rgb(255 255 255 / 0.025);
padding: 0.7rem;
}
.laboratory-e29-chart__legend i {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
}
.laboratory-e29-chart__legend span,
.laboratory-e29-chart__legend small {
color: var(--nodedc-text-muted);
font-size: 0.56rem;
}
.laboratory-e29-chart__legend strong {
color: var(--nodedc-text-primary);
font-size: 0.85rem;
}
.laboratory-e29-chart__legend small {
grid-column: 2 / 4;
}
.laboratory-e29-evidence {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.6rem;
}
.laboratory-e29-evidence article {
display: grid;
align-content: start;
gap: 0.45rem;
border-radius: 0.9rem;
background: rgb(255 255 255 / 0.025);
padding: 1rem;
}
.laboratory-e29-evidence strong {
color: var(--nodedc-text-primary);
font-size: 1.4rem;
}
.laboratory-e29-evidence p {
margin: 0;
color: var(--nodedc-text-muted);
font-size: 0.62rem;
line-height: 1.5;
}
.laboratory-e29-evidence article > div {
display: grid;
gap: 0.3rem;
}
.laboratory-e29-evidence article > div span {
border-radius: 0.6rem;
background: rgb(255 255 255 / 0.03);
color: var(--nodedc-text-secondary);
padding: 0.45rem 0.55rem;
font-size: 0.58rem;
}
.laboratory-result-summary {
display: grid;
gap: 0.85rem;
}
.laboratory-result-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.4rem;
}
.laboratory-result-metrics > div {
min-width: 0;
}
.laboratory-result-metrics strong {
font-size: 0.9rem;
}
@media (max-width: 1100px) {
.laboratory-selector {
grid-template-columns: minmax(0, 1fr);
}
.laboratory-task dl,
.laboratory-result-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.dataset-entry {
display: grid;
grid-template-columns: minmax(20rem, 1.2fr) minmax(22rem, 0.8fr);
@@ -61,9 +61,11 @@ function errorMessage(error: unknown): string {
export function LidarQualityWorkspace({
deviceLabel,
onOpenObservation,
embedded = false,
}: {
deviceLabel: string | null;
onOpenObservation: () => void;
embedded?: boolean;
}) {
const [catalog, setCatalog] = useState<LidarReplayCatalog | null>(null);
const [detail, setDetail] = useState<LidarReplayDetail | null>(null);
@@ -248,29 +250,31 @@ export function LidarQualityWorkspace({
</GlassSurface>
) : (
<>
<section className="lidar-device-context" aria-label="Выбранный LiDAR и запись">
<div className="lidar-device-context__identity">
<span className="section-eyebrow">ВЫБРАННЫЙ СЕНСОР</span>
<strong>{deviceLabel ?? "Архивный источник"} · LiDAR</strong>
<small>
Запись {fieldReview?.sessionId ?? detail.pack.sessionId}
</small>
</div>
<div className="lidar-device-context__verdict">
<StatusBadge tone="warning">Диагностическая запись</StatusBadge>
<p>
Данные воспроизводятся и доступны для визуального разбора.
Физическая точность сенсора этой записью не аттестована.
</p>
</div>
<Button
size="compact"
variant="secondary"
onClick={onOpenObservation}
>
Открыть наблюдение
</Button>
</section>
{!embedded ? (
<section className="lidar-device-context" aria-label="Выбранный LiDAR и запись">
<div className="lidar-device-context__identity">
<span className="section-eyebrow">ВЫБРАННЫЙ СЕНСОР</span>
<strong>{deviceLabel ?? "Архивный источник"} · LiDAR</strong>
<small>
Запись {fieldReview?.sessionId ?? detail.pack.sessionId}
</small>
</div>
<div className="lidar-device-context__verdict">
<StatusBadge tone="warning">Диагностическая запись</StatusBadge>
<p>
Данные воспроизводятся и доступны для визуального разбора.
Физическая точность сенсора этой записью не аттестована.
</p>
</div>
<Button
size="compact"
variant="secondary"
onClick={onOpenObservation}
>
Открыть наблюдение
</Button>
</section>
) : null}
<GlassSurface className="lidar-field-review" padding="lg">
<header className="panel-heading lidar-field-review__heading">
@@ -14,7 +14,6 @@ import {
} from "@nodedc/ui-react";
import {
ObservationSessionArchive,
type ObservationSessionReplayCallbacks,
} from "../components/ObservationSessionSelect";
import {
@@ -24,7 +23,11 @@ import {
} from "../components/ObservationSources";
import { ObservationTimeline } from "../components/ObservationTimeline";
import { FloatingObservationWindow } from "../components/FloatingObservationWindow";
import type { ObservationSessionReplayLaunch } from "../core/observation/sessionArchive";
import type {
ObservationSessionReplayLaunch,
ObservationSessionSummary,
} from "../core/observation/sessionArchive";
import { useObservationSessions } from "../core/observation/useObservationSessions";
import type { RecordedSessionAdmissionController } from "../core/observation/useRecordedSessionAdmission";
import type {
RecordedAdmissionPhase,
@@ -1176,84 +1179,447 @@ function CatalogWorkspace({ definition }: WorkspaceRendererProps) {
function RecordingsWorkspace(props: WorkspaceRendererProps) {
return (
<div className="recordings-workspace">
<ObservationSessionArchive {...props.sessionArchive} />
{props.recordedReplay ? (
<section className="recordings-workspace__viewer">
<header>
<span className="section-eyebrow">ВОСПРОИЗВЕДЕНИЕ</span>
<strong>Выбранная пространственная запись</strong>
</header>
<SpatialWorkspace {...props} />
</section>
) : (
<section className="recordings-workspace__empty">
<Icon name="database" size={20} />
<strong>Выберите сохранённую сессию</strong>
<p>Viewer появится здесь; эфир в разделе «Наблюдение» останется неизменным.</p>
</section>
)}
<SpatialWorkspace {...props} />
</div>
);
}
function LabArchiveWorkspace(props: WorkspaceRendererProps) {
const [surface, setSurface] = useState<"lidar-e29" | "replay">(
props.recordedReplay ? "replay" : "lidar-e29",
type LaboratoryProfileId = "sensor-fusion" | "published-perception";
type LaboratoryWorkId = "e28-local-surface" | "e29-camera-geometry" | `session:${string}`;
interface LaboratoryOption<T extends string> {
id: T;
label: string;
}
function LaboratorySelector<T extends string>({
eyebrow,
title,
description,
label,
value,
options,
disabled = false,
onChange,
}: {
eyebrow: string;
title: string;
description: string;
label: string;
value: T;
options: readonly LaboratoryOption<T>[];
disabled?: boolean;
onChange: (value: T) => void;
}) {
return (
<section className="laboratory-selector">
<div>
<span className="section-eyebrow">{eyebrow}</span>
<h2>{title}</h2>
<p>{description}</p>
</div>
<label>
<span>{label}</span>
<select
value={value}
disabled={disabled}
onChange={(event) => onChange(event.currentTarget.value as T)}
>
{options.map((option) => (
<option key={option.id} value={option.id}>{option.label}</option>
))}
</select>
</label>
</section>
);
const replayActions: WorkspaceRendererProps["sessionArchive"] = {
...props.sessionArchive,
onReplayAccepted: async (session, launch) => {
await props.sessionArchive.onReplayAccepted?.(session, launch);
setSurface("replay");
},
};
}
function LaboratoryTask({
title,
description,
status,
facts,
}: {
title: string;
description: string;
status: string;
facts: readonly { label: string; value: string }[];
}) {
return (
<section className="laboratory-task">
<header>
<div>
<span className="section-eyebrow">ОПИСАНИЕ ЛАБОРАТОРНОЙ РАБОТЫ</span>
<h2>{title}</h2>
<p>{description}</p>
</div>
<StatusBadge tone="warning">{status}</StatusBadge>
</header>
<dl>
{facts.map((fact) => (
<div key={fact.label}>
<dt>{fact.label}</dt>
<dd>{fact.value}</dd>
</div>
))}
</dl>
</section>
);
}
function E29LaboratoryResult({ rigLabel }: { rigLabel: string }) {
const semanticTotal = 19_625;
const statuses = [
{ id: "agree", label: "Камера + геометрия", value: 6_341 },
{ id: "camera", label: "Только камера", value: 13_246 },
{ id: "conflict", label: "Конфликт", value: 38 },
] as const;
const conflictEpisodes = [
"track 115 · 69,79971,398 с",
"track 470 · 215,324218,708 с",
"track 679 · 282,569283,281 с",
"track 1011 · 354,937355,242 с",
"track 1276 · 402,606402,995 с",
];
return (
<div className="lab-archive-workspace">
<section className="lab-configuration">
<div>
<span className="section-eyebrow">ОБЪЕКТ И КОНФИГУРАЦИЯ ИССЛЕДОВАНИЯ</span>
<h2>Текущий профиль · camera + LiDAR</h2>
<p>
Объект и точная конфигурация берутся из LAB-provenance выбранной записи.
Исходный сенсор остаётся read-only, вычисление выполняется на worker D.
</p>
<>
<LaboratoryTask
title="LAB E29 · camera-first semantics + независимая геометрия"
description="Проверяется рабочая продуктовая граница: камера сохраняет семантический класс и идентичность объекта, LiDAR подтверждает метрическую дальность и занятую геометрию по локальной поверхности L2.6. Отсутствие точек не объявляется свободным пространством."
status="Диагностический replay"
facts={[
{ label: "Конфигурация", value: `${rigLabel} · камера + LiDAR · worker D` },
{ label: "Источник", value: "RAVNOVES00 · 4 489 кадров" },
{ label: "Объектов", value: "20 513 наблюдений" },
{ label: "Полномочия", value: "Только shadow · команды запрещены" },
]}
/>
<section className="laboratory-visual-result" aria-label="Визуальный результат LAB E29">
<header>
<div>
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ РЕЗУЛЬТАТ</span>
<h2>Покрытие семантических наблюдений геометрией</h2>
<p>
Это покрытие одного воспроизводимого replay, а не accuracy и не допуск
планировщика. Конфликты сохранены для ручного покадрового разбора.
</p>
</div>
<StatusBadge tone="warning">16 конфликтных эпизодов</StatusBadge>
</header>
<div className="laboratory-e29-chart" role="img" aria-label="Распределение статусов геометрии">
<div className="laboratory-e29-chart__bar">
{statuses.map((status) => (
<i
key={status.id}
data-status={status.id}
style={{ width: `${(status.value / semanticTotal) * 100}%` }}
/>
))}
</div>
<div className="laboratory-e29-chart__legend">
{statuses.map((status) => (
<div key={status.id}>
<i data-status={status.id} />
<span>{status.label}</span>
<strong>{status.value.toLocaleString("ru-RU")}</strong>
<small>{((status.value / semanticTotal) * 100).toLocaleString("ru-RU", {
maximumFractionDigits: 2,
})}%</small>
</div>
))}
</div>
</div>
<div className="laboratory-e29-evidence">
<article>
<span className="section-eyebrow">НЕЗАВИСИМАЯ ГЕОМЕТРИЯ</span>
<strong>21 321 компонент</strong>
<p>
Незасемантизированные занятые компоненты сохранены отдельным слоем:
им не назначается выдуманный класс и они не считаются свободным местом.
</p>
</article>
<article>
<span className="section-eyebrow">КОНФЛИКТЫ ДЛЯ РАЗБОРА</span>
<div>
{conflictEpisodes.map((episode) => <span key={episode}>{episode}</span>)}
</div>
</article>
</div>
<StatusBadge tone="accent">Активная серия</StatusBadge>
</section>
<section className="lab-primary-result">
<button
type="button"
data-active={surface === "lidar-e29" ? "true" : undefined}
onClick={() => setSurface("lidar-e29")}
>
<span>LAB E29</span>
<strong>Локальная модель поверхности L2.6</strong>
<small>Диагностический replay всей записи и кадры для разбора</small>
</button>
<section className="laboratory-result-summary">
<header>
<div>
<span className="section-eyebrow">РЕЗУЛЬТАТ И ВЫВОД</span>
<h2>Архитектурная схема подтверждена, safety-gate не пройден</h2>
</div>
<StatusBadge tone="success">Replay завершён</StatusBadge>
</header>
<div className="laboratory-result-metrics">
<div><span>Поддержка геометрией</span><strong>6 341</strong><small>32,31% current observations</small></div>
<div><span>Camera-only</span><strong>13 246</strong><small>Семантика сохранена</small></div>
<div><span>Postprocess p95</span><strong>2,517 мс</strong><small>8,467 с полный build</small></div>
<div><span>Geometry-only</span><strong>21 321</strong><small>Занято / класс неизвестен</small></div>
</div>
<p>
Следующий gate покадровый разбор конфликтов, camera-only пробелов и
geometry-only компонентов, затем source-paced shadow с deadline и staleness.
</p>
</section>
</>
);
}
<ObservationSessionArchive {...replayActions} labsOnly />
function formatLaboratoryMetric(value: unknown): string {
if (typeof value === "boolean") return value ? "Да" : "Нет";
if (typeof value === "number") {
return value.toLocaleString("ru-RU", { maximumFractionDigits: 3 });
}
return String(value);
}
function laboratoryMetricLabel(key: string): string {
return key
.replace(/_p95_ms$/, " · p95 мс")
.replaceAll("_", " ")
.replace(/^./, (value) => value.toLocaleUpperCase("ru-RU"));
}
function laboratorySessionTitle(session: ObservationSessionSummary): string {
const labPrefix = session.lab ? `${session.lab.labId} · ` : "";
return labPrefix && session.label.startsWith(labPrefix)
? session.label.slice(labPrefix.length)
: session.label;
}
function PublishedLaboratoryResult({
props,
session,
loading,
error,
}: {
props: WorkspaceRendererProps;
session: ObservationSessionSummary;
loading: boolean;
error: string | null;
}) {
const lab = session.lab;
const metrics = Object.entries(lab?.provenance ?? {})
.filter(([key, value]) => (
(typeof value === "number" || typeof value === "boolean")
&& !key.includes("sha256")
&& !key.includes("authority")
))
.slice(0, 8);
const replayReady = props.recordedReplay?.sessionId === session.id;
return (
<>
<LaboratoryTask
title={`${lab?.labId ?? "LAB"} · ${laboratorySessionTitle(session)}`}
description="Опубликованная лабораторная работа открывается по своей неизменяемой записи. Viewer показывает исходные синхронные каналы; за ним приведены только метрики, зафиксированные в LAB-provenance этого результата."
status="Зафиксированный результат"
facts={[
{ label: "Тип результата", value: lab?.resultKind ?? "—" },
{ label: "Источник", value: lab?.sourceSessionId ?? session.id },
{ label: "Конфигурация", value: lab?.configSha256?.slice(0, 16) ?? "Не зафиксирована" },
{ label: "Полномочия", value: "Диагностика · без навигации и команд" },
]}
/>
<section className="lab-result-surface">
<header>
<span className="section-eyebrow">ВИЗУАЛЬНОЕ ДОКАЗАТЕЛЬСТВО</span>
<strong>
{surface === "replay" && props.recordedReplay
? "Запись выбранной лабораторной работы"
: "LAB E29 · результат L2.6"}
</strong>
<strong>Исходная запись выбранной лабораторной работы</strong>
</header>
{surface === "replay" && props.recordedReplay ? (
{replayReady ? (
<SpatialWorkspace {...props} />
) : (
<LidarQualityWorkspace
deviceLabel={props.deviceLabel}
onOpenObservation={() => props.navigation.openView("spatial-scene")}
/>
<div className="laboratory-result-pending" role="status">
{loading ? <span className="busy-indicator" aria-hidden="true" /> : <Icon name="database" size={20} />}
<strong>{loading ? "Подготавливаем лабораторную запись" : "Запись не открыта"}</strong>
<p>
{error ?? (loading
? "Связанное визуальное доказательство откроется после проверки записи."
: "Выберите работу ещё раз, чтобы открыть связанное визуальное доказательство.")}
</p>
</div>
)}
</section>
<section className="laboratory-result-summary">
<header>
<div>
<span className="section-eyebrow">ЗАФИКСИРОВАННЫЕ МЕТРИКИ</span>
<h2>Результат из LAB-provenance</h2>
</div>
<StatusBadge tone={
lab?.provenance.benchmark_passed === true ? "success" : "warning"
}>
{lab?.provenance.benchmark_passed === true ? "Benchmark passed" : "Требует разбора"}
</StatusBadge>
</header>
<div className="laboratory-result-metrics">
{metrics.length ? metrics.map(([key, value]) => (
<div key={key}>
<span>{laboratoryMetricLabel(key)}</span>
<strong>{formatLaboratoryMetric(value)}</strong>
<small>Immutable provenance</small>
</div>
)) : (
<div>
<span>Метрики</span>
<strong>Не опубликованы</strong>
<small>Доступна исходная запись</small>
</div>
)}
</div>
</section>
</>
);
}
function LabArchiveWorkspace(props: WorkspaceRendererProps) {
const [profileId, setProfileId] = useState<LaboratoryProfileId>("sensor-fusion");
const [workId, setWorkId] = useState<LaboratoryWorkId>("e28-local-surface");
const sessions = useObservationSessions({
limit: 100,
replayEnabled: props.sessionArchive.blockedReason === null,
onReplayBegin: props.sessionArchive.onReplayBegin,
onReplayAccepted: props.sessionArchive.onReplayAccepted,
onReplaySettled: props.sessionArchive.onReplaySettled,
});
const publishedWorks = useMemo(
() => sessions.items.filter((session) => session.lab !== null),
[sessions.items],
);
const rigLabel = useMemo(() => {
if (props.deviceLabel) return props.deviceLabel;
const coordinateFrame = publishedWorks
.map((session) => session.lab?.provenance.coordinate_frame)
.find((value) => typeof value === "string" && value.trim());
const sensorToken = typeof coordinateFrame === "string"
? coordinateFrame.split("-")[0]?.trim()
: "";
return sensorToken ? sensorToken.toLocaleUpperCase("ru-RU") : "Сенсорный риг";
}, [props.deviceLabel, publishedWorks]);
const profiles: readonly LaboratoryOption<LaboratoryProfileId>[] = [
{
id: "sensor-fusion",
label: `${rigLabel} · камера + LiDAR · worker D`,
},
{
id: "published-perception",
label: `${rigLabel} · опубликованный perception pipeline`,
},
];
const workOptions: readonly LaboratoryOption<LaboratoryWorkId>[] =
profileId === "sensor-fusion"
? [
{ id: "e28-local-surface", label: "LAB E28 · локальная поверхность L2.6" },
{ id: "e29-camera-geometry", label: "LAB E29 · camera-first + geometry" },
]
: publishedWorks.map((session) => ({
id: `session:${session.id}` as const,
label: `${session.lab?.labId ?? "LAB"} · ${laboratorySessionTitle(session)}`,
}));
const selectedSessionId = workId.startsWith("session:")
? workId.slice("session:".length)
: null;
const selectedSession = selectedSessionId
? publishedWorks.find((session) => session.id === selectedSessionId) ?? null
: null;
useEffect(() => {
if (profileId !== "published-perception" || workId.startsWith("session:")) return;
const first = publishedWorks[0];
if (!first) return;
setWorkId(`session:${first.id}`);
}, [profileId, publishedWorks, workId]);
const selectProfile = (next: LaboratoryProfileId) => {
setProfileId(next);
if (next === "sensor-fusion") {
setWorkId("e28-local-surface");
return;
}
const first = publishedWorks[0];
if (!first) return;
const nextWork = `session:${first.id}` as const;
setWorkId(nextWork);
void sessions.replay(first.id);
};
const selectWork = (next: LaboratoryWorkId) => {
setWorkId(next);
if (!next.startsWith("session:")) return;
void sessions.replay(next.slice("session:".length));
};
return (
<div className="lab-archive-workspace">
<LaboratorySelector
eyebrow="ПРОФИЛЬ ЛАБОРАТОРНОГО КОНТУРА"
title={profiles.find((profile) => profile.id === profileId)?.label ?? rigLabel}
description="Профиль фиксирует объект исследования, сенсорные модули и вычислительный контур. Исходные данные остаются read-only; профиль объединяет серию сопоставимых лабораторных работ."
label="Профиль"
value={profileId}
options={profiles}
onChange={selectProfile}
/>
<LaboratorySelector
eyebrow="ЛАБОРАТОРНАЯ РАБОТА"
title={workOptions.find((work) => work.id === workId)?.label ?? "Работа не выбрана"}
description="Выберите один зафиксированный эксперимент. Ниже откроются его задача, полноразмерное визуальное доказательство и структурированный результат."
label="Работа"
value={workId}
options={workOptions}
disabled={workOptions.length === 0}
onChange={selectWork}
/>
<div className="laboratory-work-output">
{workId === "e28-local-surface" ? (
<>
<LaboratoryTask
title="LAB E28 · локальная модель поверхности L2.6"
description="Полная запись RAVNOVES00 воспроизводится через bounded shadow-контур. Модель оценивает локальную поверхность, наблюдаемые препятствия, временные скачки и ошибки предсказания без изменения исходного сенсора и без командного канала."
status="Диагностический replay"
facts={[
{ label: "Конфигурация", value: `${rigLabel} · LiDAR + pose · worker D` },
{ label: "Покрытие", value: "3 928 / 3 928 кадров" },
{ label: "Режим", value: "Recorded-source-paced shadow" },
{ label: "Полномочия", value: "Навигация и команды запрещены" },
]}
/>
<LidarQualityWorkspace
embedded
deviceLabel={props.deviceLabel}
onOpenObservation={() => props.navigation.openView("spatial-scene")}
/>
</>
) : workId === "e29-camera-geometry" ? (
<E29LaboratoryResult rigLabel={rigLabel} />
) : selectedSession ? (
<PublishedLaboratoryResult
props={props}
session={selectedSession}
loading={sessions.replayingSessionId === selectedSession.id}
error={sessions.failedSessionId === selectedSession.id ? sessions.error : null}
/>
) : (
<div className="laboratory-result-pending">
<Icon name="database" size={20} />
<strong>В этом профиле пока нет опубликованных работ</strong>
<p>Работы появятся после публикации проверенного LAB-provenance.</p>
</div>
)}
</div>
</div>
);
}
@@ -172,6 +172,31 @@ test("opened archive is named in the scene header and trash hover has no pill",
assert.doesNotMatch(acquisitionPipeline, /Индикатор постоянно зелёный/);
});
test("data recordings keep the compact session dropdown and laboratory results stay separate", async () => {
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
const workspaceSource = await readFile(
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
"utf8",
);
const productSource = await readFile(
new URL("../src/productModel.ts", import.meta.url),
"utf8",
);
const recordingsSource = workspaceSource.slice(
workspaceSource.indexOf("function RecordingsWorkspace"),
workspaceSource.indexOf("type LaboratoryProfileId"),
);
assert.match(appSource, /activeDefinition\.kind === "recordings"[\s\S]*<ObservationSessionSelect/);
assert.match(recordingsSource, /<SpatialWorkspace \{\.\.\.props\} \/>/);
assert.doesNotMatch(recordingsSource, /ObservationSessionArchive/);
assert.match(productSource, /label: "Лабораторные контуры"/);
assert.match(workspaceSource, /ПРОФИЛЬ ЛАБОРАТОРНОГО КОНТУРА/);
assert.match(workspaceSource, /ЛАБОРАТОРНАЯ РАБОТА/);
assert.match(workspaceSource, /e28-local-surface/);
assert.match(workspaceSource, /e29-camera-geometry/);
});
test("session catalog exposes authoritative background preparation state", () => {
const catalog = decodeObservationSessionCatalog({
items: [session({
+10 -6
View File
@@ -24,10 +24,12 @@ fully usable with only their available modalities.
3. Stop acquisition normally, or allow the local service to recover an
unexpected interruption on its next start. Session recording is automatic;
the disk action is not required.
4. Open **Данные → Сессии и записи**. The inline archive shows up to 100
indexed runs, their date, duration, state, modalities and background
preparation state. Opening an archive never replaces the live source owned
by **Наблюдение**.
4. Open **Данные → Сессии и записи**. The compact **Сохранённые сессии**
dropdown shows up to 100 indexed runs, their date, duration, state,
modalities and background preparation state. The page itself remains one
full-size spatial viewer; the catalog is not expanded into an inline list.
Opening an archive never replaces the live source owned by
**Наблюдение**.
5. Choose a replayable run. Opening a replay never performs conversion in the
request. A ready recording opens immediately; otherwise the client receives
HTTP 202, keeps the current scene mounted and polls the preparation status.
@@ -130,8 +132,10 @@ on deletion.
The visual result is resolved by the LAB session id, so `LAB E19`, `LAB E21`
and the derived temporal comparison `LAB E22` can coexist over `RAVNOVES00`
without “latest accepted result” replacing an earlier experiment. The operator
can open any row in **Данные → Сессии и записи** or the corresponding LAB row
in **Тестировочный контур** and use the same
can open any row in **Данные → Сессии и записи** or choose the corresponding
published LAB work through **Тестировочный контур → Лабораторные контуры**.
The test surface presents LAB works through `profile → work`, never as a copy
of the saved-session list. A recorded LAB work then uses the same
**Объекты 2D / Сегментация / Кубы 3D** controls. First publication must also
warm the result-specific Rerun overlay; later opens reuse that verified cache
and do not rerun AI inference.
+2 -1
View File
@@ -432,7 +432,8 @@ Dataset expansion is no longer the next gate.
The implemented `missioncore.k1-local-surface/v1` derivative is reproducible
through `experiments/perception/run_k1_local_surface.py` and is exposed
read-only through `GET /api/v1/lidar/local-surfaces` plus the bound frame,
timeline and review endpoints. **Тестировочный контур → Лабораторные работы**
timeline and review endpoints. **Тестировочный контур → Лабораторные контуры
→ LAB E28**
reuses the five
RAVNOVES00 scene selectors and also exposes a clickable timeline over the
complete recording. The selected source frame shows observed surface, observed
+8 -3
View File
@@ -61,7 +61,7 @@ Implemented now:
lower-cell coordinates, signed residual and derived inlier mask are
content-bound and visible as a read-only 3D overlay;
- a provider-neutral read-only local-surface laboratory result in
**Тестировочный контур → Лабораторные работы**, synchronized to the five existing
**Тестировочный контур → Лабораторные контуры → LAB E28**, synchronized to the five existing
RAVNOVES00 scene selectors and a clickable complete-recording timeline;
- a complete recorded-host-paced 1× worker replay through authenticated
ingress, K1 normalization, point/pose binding and a bounded latest-wins
@@ -114,8 +114,13 @@ The gateway and current local-surface work are not live device diagnostics.
inputs. Opening one dataset exposes its source fragments, sparse annotated
scans, visual overlays and useful analysis inside the expanded source card.
Opening another source closes the previous one.
- **Тестировочный контур → Лабораторные работы** owns immutable experiment
configurations, accepted LAB instances and visual conclusions such as E29.
- **Тестировочный контур → Лабораторные контуры** owns immutable experiment
configurations and results. The first selector chooses a configuration
profile, the second chooses one LAB work inside it, and the output uses one
reusable `task → full visual evidence → structured result` template.
LAB E28 owns the L2.6 local-surface viewer and review queue; LAB E29 owns the
camera-first semantic/geometry result. Published E19E26 recordings remain a
separate profile and are not duplicated into the saved-session catalog UI.
- A future **Парк** diagnostics surface must require a connected device and a
dedicated live diagnostic contract. It cannot reuse an offline replay report.
- Internal replay-run identity, provenance and immutable artifacts remain a
+19 -6
View File
@@ -34,7 +34,10 @@ to **Данные → Датасеты**. They do not recreate the previous Poly
page.
Saved session replay is mounted only in **Данные → Сессии и записи** or from a
LAB entry. Leaving either archive returns **Наблюдение → Пространственная
selected recorded LAB work. **Данные → Сессии и записи** keeps the original
compact dropdown in the panel header and one full-size spatial viewer; it does
not expand the catalog into the content column. Leaving either archive returns
**Наблюдение → Пространственная
сцена** to the runtime-published live source. A selected archive cannot leak
into the live observation surface.
@@ -42,11 +45,21 @@ Dataset sources use a single-open accordion. Expanding one source mounts its
viewer and analysis inside that source card, pushes later sources down, and
closes the previously expanded source.
The laboratory archive reads immutable LAB instances from the existing
observation-session catalog. The E29 local-surface result remains a read-only
laboratory surface and is no longer presented as live LiDAR diagnostics. A
future device-diagnostics workspace must require an attached device and its own
diagnostic contract.
The laboratory surface is a two-level catalog:
1. a **laboratory contour profile** fixes the tested rig, modules and worker;
2. a **laboratory work** selects one immutable result inside that profile.
The selected work opens one reusable output structure: task and configuration,
full-size visual evidence, then structured metrics, errors, timing and
conclusion. LAB E28 owns the L2.6 local-surface viewer, complete-recording
timeline and review queue. LAB E29 owns the camera-first semantic/geometry
coverage result. Recorded E19E26 LAB instances are exposed through a separate
published-perception profile backed by the existing observation-session
catalog. They are not rendered as a second saved-session archive.
Neither E28 nor E29 is live device diagnostics. A future device-diagnostics
workspace must require an attached device and its own diagnostic contract.
Contour health reads only live contracts: