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"; } from "@nodedc/ui-react";
import { LandingStage } from "./components/LandingStage"; import { LandingStage } from "./components/LandingStage";
import { ObservationSessionSelect } from "./components/ObservationSessionSelect";
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost"; import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext"; import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
import type { ViewerSettings } from "./core/runtime/contracts"; import type { ViewerSettings } from "./core/runtime/contracts";
@@ -752,7 +753,14 @@ export default function App() {
) : null} ) : null}
</div> </div>
) : activeDefinition.kind === "recordings" ? ( ) : 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" ? ( ) : activeDefinition.kind === "datasets" ? (
<StatusBadge tone="neutral">Offline evaluation</StatusBadge> <StatusBadge tone="neutral">Offline evaluation</StatusBadge>
) : activeDefinition.kind === "lab-archive" ? ( ) : activeDefinition.kind === "lab-archive" ? (
+3 -3
View File
@@ -153,10 +153,10 @@ export const workspaces: WorkspaceDefinition[] = [
{ {
id: "lab-archive", id: "lab-archive",
root: "polygon", root: "polygon",
label: "Лабораторные работы", label: "Лабораторные контуры",
title: "Архив лабораторных работ", title: "Лабораторные контуры",
eyebrow: "ТЕСТОВЫЙ КОНТУР / ЛАБОРАТОРИЯ", eyebrow: "ТЕСТОВЫЙ КОНТУР / ЛАБОРАТОРИЯ",
description: "Конфигурации, ход эксперимента и визуальные результаты принятых лабораторных работ.", description: "Профили конфигураций, лабораторные работы и их воспроизводимые результаты.",
icon: "clipboard", icon: "clipboard",
kind: "lab-archive", kind: "lab-archive",
groups: [], groups: [],
+312 -69
View File
@@ -2385,18 +2385,17 @@
.recordings-workspace, .recordings-workspace,
.lab-archive-workspace, .lab-archive-workspace,
.recordings-workspace__viewer,
.lab-result-surface { .lab-result-surface {
display: grid; display: grid;
gap: 0.85rem; gap: 0.85rem;
} }
.recordings-workspace__viewer, .recordings-workspace {
.lab-result-surface { width: 100%;
min-height: 30rem; height: 100%;
min-height: 34rem;
} }
.recordings-workspace__viewer > header,
.lab-result-surface > header { .lab-result-surface > header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2404,102 +2403,346 @@
padding: 0 0.2rem; padding: 0 0.2rem;
} }
.recordings-workspace__viewer > header strong,
.lab-result-surface > header strong { .lab-result-surface > header strong {
color: var(--nodedc-text-secondary); color: var(--nodedc-text-secondary);
font-size: 0.7rem; font-size: 0.7rem;
} }
.recordings-workspace__empty { .lab-archive-workspace {
padding-bottom: 1rem;
}
.laboratory-selector {
display: grid; display: grid;
min-height: 27rem; grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.38fr);
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;
align-items: center; align-items: center;
justify-content: space-between; gap: 1.4rem;
gap: 1rem;
border-radius: 1rem; border-radius: 1rem;
background: rgb(255 255 255 / 0.025); background: rgb(255 255 255 / 0.025);
padding: 1rem; padding: 1rem;
} }
.lab-configuration h2, .laboratory-selector h2,
.lab-configuration p { .laboratory-selector p {
margin: 0; margin: 0;
} }
.lab-configuration h2 { .laboratory-selector h2 {
margin-top: 0.25rem; margin-top: 0.25rem;
color: var(--nodedc-text-primary); color: var(--nodedc-text-primary);
font-size: 1rem; font-size: 1rem;
} }
.lab-configuration p { .laboratory-selector p {
max-width: 55rem; max-width: 60rem;
margin-top: 0.35rem; margin-top: 0.35rem;
color: var(--nodedc-text-muted); color: var(--nodedc-text-muted);
font-size: 0.66rem; font-size: 0.64rem;
line-height: 1.5; line-height: 1.5;
} }
.lab-primary-result { .laboratory-selector label {
display: grid; display: grid;
gap: 0.35rem;
} }
.lab-primary-result button { .laboratory-selector label > span {
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 {
color: var(--nodedc-text-muted); 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; 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 { .dataset-entry {
display: grid; display: grid;
grid-template-columns: minmax(20rem, 1.2fr) minmax(22rem, 0.8fr); grid-template-columns: minmax(20rem, 1.2fr) minmax(22rem, 0.8fr);
@@ -61,9 +61,11 @@ function errorMessage(error: unknown): string {
export function LidarQualityWorkspace({ export function LidarQualityWorkspace({
deviceLabel, deviceLabel,
onOpenObservation, onOpenObservation,
embedded = false,
}: { }: {
deviceLabel: string | null; deviceLabel: string | null;
onOpenObservation: () => void; onOpenObservation: () => void;
embedded?: boolean;
}) { }) {
const [catalog, setCatalog] = useState<LidarReplayCatalog | null>(null); const [catalog, setCatalog] = useState<LidarReplayCatalog | null>(null);
const [detail, setDetail] = useState<LidarReplayDetail | null>(null); const [detail, setDetail] = useState<LidarReplayDetail | null>(null);
@@ -248,29 +250,31 @@ export function LidarQualityWorkspace({
</GlassSurface> </GlassSurface>
) : ( ) : (
<> <>
<section className="lidar-device-context" aria-label="Выбранный LiDAR и запись"> {!embedded ? (
<div className="lidar-device-context__identity"> <section className="lidar-device-context" aria-label="Выбранный LiDAR и запись">
<span className="section-eyebrow">ВЫБРАННЫЙ СЕНСОР</span> <div className="lidar-device-context__identity">
<strong>{deviceLabel ?? "Архивный источник"} · LiDAR</strong> <span className="section-eyebrow">ВЫБРАННЫЙ СЕНСОР</span>
<small> <strong>{deviceLabel ?? "Архивный источник"} · LiDAR</strong>
Запись {fieldReview?.sessionId ?? detail.pack.sessionId} <small>
</small> Запись {fieldReview?.sessionId ?? detail.pack.sessionId}
</div> </small>
<div className="lidar-device-context__verdict"> </div>
<StatusBadge tone="warning">Диагностическая запись</StatusBadge> <div className="lidar-device-context__verdict">
<p> <StatusBadge tone="warning">Диагностическая запись</StatusBadge>
Данные воспроизводятся и доступны для визуального разбора. <p>
Физическая точность сенсора этой записью не аттестована. Данные воспроизводятся и доступны для визуального разбора.
</p> Физическая точность сенсора этой записью не аттестована.
</div> </p>
<Button </div>
size="compact" <Button
variant="secondary" size="compact"
onClick={onOpenObservation} variant="secondary"
> onClick={onOpenObservation}
Открыть наблюдение >
</Button> Открыть наблюдение
</section> </Button>
</section>
) : null}
<GlassSurface className="lidar-field-review" padding="lg"> <GlassSurface className="lidar-field-review" padding="lg">
<header className="panel-heading lidar-field-review__heading"> <header className="panel-heading lidar-field-review__heading">
@@ -14,7 +14,6 @@ import {
} from "@nodedc/ui-react"; } from "@nodedc/ui-react";
import { import {
ObservationSessionArchive,
type ObservationSessionReplayCallbacks, type ObservationSessionReplayCallbacks,
} from "../components/ObservationSessionSelect"; } from "../components/ObservationSessionSelect";
import { import {
@@ -24,7 +23,11 @@ import {
} from "../components/ObservationSources"; } from "../components/ObservationSources";
import { ObservationTimeline } from "../components/ObservationTimeline"; import { ObservationTimeline } from "../components/ObservationTimeline";
import { FloatingObservationWindow } from "../components/FloatingObservationWindow"; 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 { RecordedSessionAdmissionController } from "../core/observation/useRecordedSessionAdmission";
import type { import type {
RecordedAdmissionPhase, RecordedAdmissionPhase,
@@ -1176,84 +1179,447 @@ function CatalogWorkspace({ definition }: WorkspaceRendererProps) {
function RecordingsWorkspace(props: WorkspaceRendererProps) { function RecordingsWorkspace(props: WorkspaceRendererProps) {
return ( return (
<div className="recordings-workspace"> <div className="recordings-workspace">
<ObservationSessionArchive {...props.sessionArchive} /> <SpatialWorkspace {...props} />
{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>
)}
</div> </div>
); );
} }
function LabArchiveWorkspace(props: WorkspaceRendererProps) { type LaboratoryProfileId = "sensor-fusion" | "published-perception";
const [surface, setSurface] = useState<"lidar-e29" | "replay">( type LaboratoryWorkId = "e28-local-surface" | "e29-camera-geometry" | `session:${string}`;
props.recordedReplay ? "replay" : "lidar-e29",
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) => { function LaboratoryTask({
await props.sessionArchive.onReplayAccepted?.(session, launch); title,
setSurface("replay"); 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 ( return (
<div className="lab-archive-workspace"> <>
<section className="lab-configuration"> <LaboratoryTask
<div> title="LAB E29 · camera-first semantics + независимая геометрия"
<span className="section-eyebrow">ОБЪЕКТ И КОНФИГУРАЦИЯ ИССЛЕДОВАНИЯ</span> description="Проверяется рабочая продуктовая граница: камера сохраняет семантический класс и идентичность объекта, LiDAR подтверждает метрическую дальность и занятую геометрию по локальной поверхности L2.6. Отсутствие точек не объявляется свободным пространством."
<h2>Текущий профиль · camera + LiDAR</h2> status="Диагностический replay"
<p> facts={[
Объект и точная конфигурация берутся из LAB-provenance выбранной записи. { label: "Конфигурация", value: `${rigLabel} · камера + LiDAR · worker D` },
Исходный сенсор остаётся read-only, вычисление выполняется на worker D. { label: "Источник", value: "RAVNOVES00 · 4 489 кадров" },
</p> { 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> </div>
<StatusBadge tone="accent">Активная серия</StatusBadge>
</section> </section>
<section className="lab-primary-result"> <section className="laboratory-result-summary">
<button <header>
type="button" <div>
data-active={surface === "lidar-e29" ? "true" : undefined} <span className="section-eyebrow">РЕЗУЛЬТАТ И ВЫВОД</span>
onClick={() => setSurface("lidar-e29")} <h2>Архитектурная схема подтверждена, safety-gate не пройден</h2>
> </div>
<span>LAB E29</span> <StatusBadge tone="success">Replay завершён</StatusBadge>
<strong>Локальная модель поверхности L2.6</strong> </header>
<small>Диагностический replay всей записи и кадры для разбора</small> <div className="laboratory-result-metrics">
</button> <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> </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"> <section className="lab-result-surface">
<header> <header>
<span className="section-eyebrow">ВИЗУАЛЬНОЕ ДОКАЗАТЕЛЬСТВО</span> <span className="section-eyebrow">ВИЗУАЛЬНОЕ ДОКАЗАТЕЛЬСТВО</span>
<strong> <strong>Исходная запись выбранной лабораторной работы</strong>
{surface === "replay" && props.recordedReplay
? "Запись выбранной лабораторной работы"
: "LAB E29 · результат L2.6"}
</strong>
</header> </header>
{surface === "replay" && props.recordedReplay ? ( {replayReady ? (
<SpatialWorkspace {...props} /> <SpatialWorkspace {...props} />
) : ( ) : (
<LidarQualityWorkspace <div className="laboratory-result-pending" role="status">
deviceLabel={props.deviceLabel} {loading ? <span className="busy-indicator" aria-hidden="true" /> : <Icon name="database" size={20} />}
onOpenObservation={() => props.navigation.openView("spatial-scene")} <strong>{loading ? "Подготавливаем лабораторную запись" : "Запись не открыта"}</strong>
/> <p>
{error ?? (loading
? "Связанное визуальное доказательство откроется после проверки записи."
: "Выберите работу ещё раз, чтобы открыть связанное визуальное доказательство.")}
</p>
</div>
)} )}
</section> </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> </div>
); );
} }
@@ -172,6 +172,31 @@ test("opened archive is named in the scene header and trash hover has no pill",
assert.doesNotMatch(acquisitionPipeline, /Индикатор постоянно зелёный/); 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", () => { test("session catalog exposes authoritative background preparation state", () => {
const catalog = decodeObservationSessionCatalog({ const catalog = decodeObservationSessionCatalog({
items: [session({ 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 3. Stop acquisition normally, or allow the local service to recover an
unexpected interruption on its next start. Session recording is automatic; unexpected interruption on its next start. Session recording is automatic;
the disk action is not required. the disk action is not required.
4. Open **Данные → Сессии и записи**. The inline archive shows up to 100 4. Open **Данные → Сессии и записи**. The compact **Сохранённые сессии**
indexed runs, their date, duration, state, modalities and background dropdown shows up to 100 indexed runs, their date, duration, state,
preparation state. Opening an archive never replaces the live source owned modalities and background preparation state. The page itself remains one
by **Наблюдение**. 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 5. Choose a replayable run. Opening a replay never performs conversion in the
request. A ready recording opens immediately; otherwise the client receives request. A ready recording opens immediately; otherwise the client receives
HTTP 202, keeps the current scene mounted and polls the preparation status. 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` 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` and the derived temporal comparison `LAB E22` can coexist over `RAVNOVES00`
without “latest accepted result” replacing an earlier experiment. The operator without “latest accepted result” replacing an earlier experiment. The operator
can open any row in **Данные → Сессии и записи** or the corresponding LAB row can open any row in **Данные → Сессии и записи** or choose the corresponding
in **Тестировочный контур** and use the same 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 **Объекты 2D / Сегментация / Кубы 3D** controls. First publication must also
warm the result-specific Rerun overlay; later opens reuse that verified cache warm the result-specific Rerun overlay; later opens reuse that verified cache
and do not rerun AI inference. 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 The implemented `missioncore.k1-local-surface/v1` derivative is reproducible
through `experiments/perception/run_k1_local_surface.py` and is exposed through `experiments/perception/run_k1_local_surface.py` and is exposed
read-only through `GET /api/v1/lidar/local-surfaces` plus the bound frame, 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 reuses the five
RAVNOVES00 scene selectors and also exposes a clickable timeline over the RAVNOVES00 scene selectors and also exposes a clickable timeline over the
complete recording. The selected source frame shows observed surface, observed 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 lower-cell coordinates, signed residual and derived inlier mask are
content-bound and visible as a read-only 3D overlay; content-bound and visible as a read-only 3D overlay;
- a provider-neutral read-only local-surface laboratory result in - 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; RAVNOVES00 scene selectors and a clickable complete-recording timeline;
- a complete recorded-host-paced 1× worker replay through authenticated - a complete recorded-host-paced 1× worker replay through authenticated
ingress, K1 normalization, point/pose binding and a bounded latest-wins 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 inputs. Opening one dataset exposes its source fragments, sparse annotated
scans, visual overlays and useful analysis inside the expanded source card. scans, visual overlays and useful analysis inside the expanded source card.
Opening another source closes the previous one. Opening another source closes the previous one.
- **Тестировочный контур → Лабораторные работы** owns immutable experiment - **Тестировочный контур → Лабораторные контуры** owns immutable experiment
configurations, accepted LAB instances and visual conclusions such as E29. 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 - A future **Парк** diagnostics surface must require a connected device and a
dedicated live diagnostic contract. It cannot reuse an offline replay report. dedicated live diagnostic contract. It cannot reuse an offline replay report.
- Internal replay-run identity, provenance and immutable artifacts remain a - 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. page.
Saved session replay is mounted only in **Данные → Сессии и записи** or from a 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 сцена** to the runtime-published live source. A selected archive cannot leak
into the live observation surface. 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 viewer and analysis inside that source card, pushes later sources down, and
closes the previously expanded source. closes the previously expanded source.
The laboratory archive reads immutable LAB instances from the existing The laboratory surface is a two-level catalog:
observation-session catalog. The E29 local-surface result remains a read-only
laboratory surface and is no longer presented as live LiDAR diagnostics. A 1. a **laboratory contour profile** fixes the tested rig, modules and worker;
future device-diagnostics workspace must require an attached device and its own 2. a **laboratory work** selects one immutable result inside that profile.
diagnostic contract.
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: Contour health reads only live contracts: