feat(lab): verify and expose real evidence
This commit is contained in:
@@ -0,0 +1,540 @@
|
||||
export interface E29ReviewFrame {
|
||||
frameIndex: number;
|
||||
sourceFrameIndex: number;
|
||||
sessionSeconds: number;
|
||||
conflictCount: number;
|
||||
semanticObservationCount: number;
|
||||
geometryOnlyClusterCount: number;
|
||||
}
|
||||
|
||||
export interface E29EvidenceResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
status: "diagnostic-replay-complete";
|
||||
identity: {
|
||||
frameCount: number;
|
||||
timelineStartSeconds: number;
|
||||
timelineEndSeconds: number;
|
||||
};
|
||||
metrics: {
|
||||
frames: {
|
||||
total: number;
|
||||
sourceAvailable: number;
|
||||
localSurfaceValid: number;
|
||||
};
|
||||
semanticObservations: {
|
||||
total: number;
|
||||
current: number;
|
||||
agreementFractionOfCurrent: number;
|
||||
geometryStatus: {
|
||||
agree: number;
|
||||
cameraOnly: number;
|
||||
conflict: number;
|
||||
unknown: number;
|
||||
};
|
||||
};
|
||||
geometryOnlyOccupied: {
|
||||
clusterCount: number;
|
||||
pointCount: number;
|
||||
};
|
||||
runtime: {
|
||||
buildElapsedMs: number;
|
||||
frameProcessingP95Ms: number;
|
||||
};
|
||||
};
|
||||
decision: {
|
||||
cameraFirstContractImplemented: boolean;
|
||||
parallelGeometryOnlyLayerImplemented: boolean;
|
||||
productionPromotion: boolean;
|
||||
nextGate: string;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
authority: {
|
||||
commandsEnabled: false;
|
||||
navigationOrSafetyAccepted: false;
|
||||
};
|
||||
reviewFrames: readonly E29ReviewFrame[];
|
||||
linkedEvidence: {
|
||||
sourceSessionId: string;
|
||||
localSurfaceModelId: string;
|
||||
sourcePackId: string;
|
||||
sourceResultId: string;
|
||||
publishedOnControlPlane: true;
|
||||
workerCopyRequired: false;
|
||||
};
|
||||
groundTruth: false;
|
||||
}
|
||||
|
||||
export interface E29EvidenceCatalog {
|
||||
configured: boolean;
|
||||
candidateTotal: number;
|
||||
invalidTotal: number;
|
||||
items: readonly E29EvidenceResult[];
|
||||
}
|
||||
|
||||
export interface E29SemanticObservation {
|
||||
trackId: number;
|
||||
label: string;
|
||||
geometryStatus: string;
|
||||
geometryReason: string;
|
||||
rangeM: number | null;
|
||||
score: number;
|
||||
support: {
|
||||
classifiedPoints: number;
|
||||
occupiedPoints: number;
|
||||
surfacePoints: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface E29GeometryOnlyCluster {
|
||||
nearestRangeM: number;
|
||||
pointCount: number;
|
||||
voxelCount: number;
|
||||
heightRangeM: readonly [number, number];
|
||||
}
|
||||
|
||||
export interface E29EvidenceFrame {
|
||||
resultId: string;
|
||||
frameIndex: number;
|
||||
sourceFrameIndex: number;
|
||||
sessionSeconds: number;
|
||||
semanticObservations: readonly E29SemanticObservation[];
|
||||
geometryOnlyOccupied: readonly E29GeometryOnlyCluster[];
|
||||
}
|
||||
|
||||
export class E29EvidenceContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "E29EvidenceContractError";
|
||||
}
|
||||
}
|
||||
|
||||
type E29Fetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E29EvidenceContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E29EvidenceContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new E29EvidenceContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
const parsed = numberValue(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new E29EvidenceContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new E29EvidenceContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function falseValue(value: unknown, label: string): false {
|
||||
if (booleanValue(value, label) !== false) {
|
||||
throw new E29EvidenceContractError(`${label}: ожидалось false.`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function trueValue(value: unknown, label: string): true {
|
||||
if (booleanValue(value, label) !== true) {
|
||||
throw new E29EvidenceContractError(`${label}: ожидалось true.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function nullableNumber(value: unknown, label: string): number | null {
|
||||
return value === null ? null : numberValue(value, label);
|
||||
}
|
||||
|
||||
function tuple2(value: unknown, label: string): readonly [number, number] {
|
||||
if (!Array.isArray(value) || value.length !== 2) {
|
||||
throw new E29EvidenceContractError(`${label}: ожидалась пара чисел.`);
|
||||
}
|
||||
return [
|
||||
numberValue(value[0], `${label}[0]`),
|
||||
numberValue(value[1], `${label}[1]`),
|
||||
];
|
||||
}
|
||||
|
||||
function parseReviewFrame(value: unknown): E29ReviewFrame {
|
||||
const source = record(value, "review_frame");
|
||||
return {
|
||||
frameIndex: integerValue(source.frame_index, "review_frame.frame_index"),
|
||||
sourceFrameIndex: integerValue(
|
||||
source.source_frame_index,
|
||||
"review_frame.source_frame_index",
|
||||
),
|
||||
sessionSeconds: numberValue(
|
||||
source.session_seconds,
|
||||
"review_frame.session_seconds",
|
||||
),
|
||||
conflictCount: integerValue(
|
||||
source.conflict_count,
|
||||
"review_frame.conflict_count",
|
||||
),
|
||||
semanticObservationCount: integerValue(
|
||||
source.semantic_observation_count,
|
||||
"review_frame.semantic_observation_count",
|
||||
),
|
||||
geometryOnlyClusterCount: integerValue(
|
||||
source.geometry_only_cluster_count,
|
||||
"review_frame.geometry_only_cluster_count",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseEvidenceResult(value: unknown): E29EvidenceResult {
|
||||
const source = record(value, "E29 result");
|
||||
const identity = record(source.identity, "E29 identity");
|
||||
const metrics = record(source.metrics, "E29 metrics");
|
||||
const frames = record(metrics.frames, "E29 metrics.frames");
|
||||
const semantic = record(
|
||||
metrics.semantic_observations,
|
||||
"E29 metrics.semantic_observations",
|
||||
);
|
||||
const geometryStatus = record(
|
||||
semantic.geometry_status,
|
||||
"E29 metrics.semantic_observations.geometry_status",
|
||||
);
|
||||
const geometryOnly = record(
|
||||
metrics.geometry_only_occupied,
|
||||
"E29 metrics.geometry_only_occupied",
|
||||
);
|
||||
const runtime = record(metrics.runtime, "E29 metrics.runtime");
|
||||
const frameProcessing = record(
|
||||
runtime.frame_processing_ms,
|
||||
"E29 metrics.runtime.frame_processing_ms",
|
||||
);
|
||||
const decision = record(source.decision, "E29 decision");
|
||||
const authority = record(source.authority, "E29 authority");
|
||||
const linked = record(source.linked_evidence, "E29 linked evidence");
|
||||
const status = stringValue(source.status, "E29 status");
|
||||
if (status !== "diagnostic-replay-complete") {
|
||||
throw new E29EvidenceContractError("E29 status не завершён.");
|
||||
}
|
||||
if (!Array.isArray(source.limitations) || !Array.isArray(source.review_frames)) {
|
||||
throw new E29EvidenceContractError("E29 collections имеют неверный формат.");
|
||||
}
|
||||
return {
|
||||
resultId: stringValue(source.result_id, "E29 result_id"),
|
||||
createdAtUtc: source.created_at_utc === null
|
||||
? null
|
||||
: stringValue(source.created_at_utc, "E29 created_at_utc"),
|
||||
status,
|
||||
identity: {
|
||||
frameCount: integerValue(identity.frame_count, "E29 identity.frame_count"),
|
||||
timelineStartSeconds: numberValue(
|
||||
identity.timeline_start_seconds,
|
||||
"E29 identity.timeline_start_seconds",
|
||||
),
|
||||
timelineEndSeconds: numberValue(
|
||||
identity.timeline_end_seconds,
|
||||
"E29 identity.timeline_end_seconds",
|
||||
),
|
||||
},
|
||||
metrics: {
|
||||
frames: {
|
||||
total: integerValue(frames.total, "E29 metrics.frames.total"),
|
||||
sourceAvailable: integerValue(
|
||||
frames.source_available,
|
||||
"E29 metrics.frames.source_available",
|
||||
),
|
||||
localSurfaceValid: integerValue(
|
||||
frames.local_surface_valid,
|
||||
"E29 metrics.frames.local_surface_valid",
|
||||
),
|
||||
},
|
||||
semanticObservations: {
|
||||
total: integerValue(
|
||||
semantic.total,
|
||||
"E29 metrics.semantic_observations.total",
|
||||
),
|
||||
current: integerValue(
|
||||
semantic.current,
|
||||
"E29 metrics.semantic_observations.current",
|
||||
),
|
||||
agreementFractionOfCurrent: numberValue(
|
||||
semantic.agreement_fraction_of_current,
|
||||
"E29 metrics.semantic_observations.agreement_fraction_of_current",
|
||||
),
|
||||
geometryStatus: {
|
||||
agree: integerValue(geometryStatus.agree, "E29 geometry_status.agree"),
|
||||
cameraOnly: integerValue(
|
||||
geometryStatus["single-source-camera"],
|
||||
"E29 geometry_status.single-source-camera",
|
||||
),
|
||||
conflict: integerValue(
|
||||
geometryStatus.conflict,
|
||||
"E29 geometry_status.conflict",
|
||||
),
|
||||
unknown: integerValue(
|
||||
geometryStatus.unknown,
|
||||
"E29 geometry_status.unknown",
|
||||
),
|
||||
},
|
||||
},
|
||||
geometryOnlyOccupied: {
|
||||
clusterCount: integerValue(
|
||||
geometryOnly.cluster_count,
|
||||
"E29 geometry_only_occupied.cluster_count",
|
||||
),
|
||||
pointCount: integerValue(
|
||||
geometryOnly.point_count,
|
||||
"E29 geometry_only_occupied.point_count",
|
||||
),
|
||||
},
|
||||
runtime: {
|
||||
buildElapsedMs: numberValue(
|
||||
runtime.build_elapsed_ms,
|
||||
"E29 runtime.build_elapsed_ms",
|
||||
),
|
||||
frameProcessingP95Ms: numberValue(
|
||||
frameProcessing.p95,
|
||||
"E29 runtime.frame_processing_ms.p95",
|
||||
),
|
||||
},
|
||||
},
|
||||
decision: {
|
||||
cameraFirstContractImplemented: booleanValue(
|
||||
decision.camera_first_contract_implemented,
|
||||
"E29 decision.camera_first_contract_implemented",
|
||||
),
|
||||
parallelGeometryOnlyLayerImplemented: booleanValue(
|
||||
decision.parallel_geometry_only_layer_implemented,
|
||||
"E29 decision.parallel_geometry_only_layer_implemented",
|
||||
),
|
||||
productionPromotion: booleanValue(
|
||||
decision.production_promotion,
|
||||
"E29 decision.production_promotion",
|
||||
),
|
||||
nextGate: stringValue(decision.next_gate, "E29 decision.next_gate"),
|
||||
},
|
||||
limitations: source.limitations.map((item, index) => (
|
||||
stringValue(item, `E29 limitations[${index}]`)
|
||||
)),
|
||||
authority: {
|
||||
commandsEnabled: falseValue(
|
||||
authority.commands_enabled,
|
||||
"E29 authority.commands_enabled",
|
||||
),
|
||||
navigationOrSafetyAccepted: falseValue(
|
||||
authority.navigation_or_safety_accepted,
|
||||
"E29 authority.navigation_or_safety_accepted",
|
||||
),
|
||||
},
|
||||
reviewFrames: source.review_frames.map(parseReviewFrame),
|
||||
linkedEvidence: {
|
||||
sourceSessionId: stringValue(
|
||||
linked.source_session_id,
|
||||
"E29 linked_evidence.source_session_id",
|
||||
),
|
||||
localSurfaceModelId: stringValue(
|
||||
linked.local_surface_model_id,
|
||||
"E29 linked_evidence.local_surface_model_id",
|
||||
),
|
||||
sourcePackId: stringValue(
|
||||
linked.source_pack_id,
|
||||
"E29 linked_evidence.source_pack_id",
|
||||
),
|
||||
sourceResultId: stringValue(
|
||||
linked.source_result_id,
|
||||
"E29 linked_evidence.source_result_id",
|
||||
),
|
||||
publishedOnControlPlane: trueValue(
|
||||
linked.published_on_control_plane,
|
||||
"E29 linked_evidence.published_on_control_plane",
|
||||
),
|
||||
workerCopyRequired: falseValue(
|
||||
linked.worker_copy_required,
|
||||
"E29 linked_evidence.worker_copy_required",
|
||||
),
|
||||
},
|
||||
groundTruth: falseValue(source.ground_truth, "E29 ground_truth"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE29EvidenceCatalog(value: unknown): E29EvidenceCatalog {
|
||||
const source = record(value, "E29 catalog");
|
||||
if (source.schema_version !== "missioncore.laboratory-e29-catalog/v1") {
|
||||
throw new E29EvidenceContractError("E29 catalog schema не поддерживается.");
|
||||
}
|
||||
if (!Array.isArray(source.items)) {
|
||||
throw new E29EvidenceContractError("E29 catalog.items имеет неверный формат.");
|
||||
}
|
||||
return {
|
||||
configured: booleanValue(source.configured, "E29 catalog.configured"),
|
||||
candidateTotal: integerValue(
|
||||
source.candidate_total,
|
||||
"E29 catalog.candidate_total",
|
||||
),
|
||||
invalidTotal: integerValue(
|
||||
source.invalid_total,
|
||||
"E29 catalog.invalid_total",
|
||||
),
|
||||
items: source.items.map(parseEvidenceResult),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSemanticObservation(value: unknown): E29SemanticObservation {
|
||||
const source = record(value, "E29 semantic observation");
|
||||
const support = record(source.support, "E29 semantic observation.support");
|
||||
return {
|
||||
trackId: integerValue(source.track_id, "E29 semantic observation.track_id"),
|
||||
label: stringValue(source.label, "E29 semantic observation.label"),
|
||||
geometryStatus: stringValue(
|
||||
source.geometry_status,
|
||||
"E29 semantic observation.geometry_status",
|
||||
),
|
||||
geometryReason: stringValue(
|
||||
source.geometry_reason,
|
||||
"E29 semantic observation.geometry_reason",
|
||||
),
|
||||
rangeM: nullableNumber(source.range_m, "E29 semantic observation.range_m"),
|
||||
score: numberValue(source.score, "E29 semantic observation.score"),
|
||||
support: {
|
||||
classifiedPoints: integerValue(
|
||||
support.classified_points_in_bbox,
|
||||
"E29 support.classified_points_in_bbox",
|
||||
),
|
||||
occupiedPoints: integerValue(
|
||||
support.occupied_points_in_bbox,
|
||||
"E29 support.occupied_points_in_bbox",
|
||||
),
|
||||
surfacePoints: integerValue(
|
||||
support.surface_points_in_bbox,
|
||||
"E29 support.surface_points_in_bbox",
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseGeometryOnly(value: unknown): E29GeometryOnlyCluster {
|
||||
const source = record(value, "E29 geometry-only cluster");
|
||||
return {
|
||||
nearestRangeM: numberValue(
|
||||
source.nearest_range_m,
|
||||
"E29 geometry-only.nearest_range_m",
|
||||
),
|
||||
pointCount: integerValue(
|
||||
source.point_count,
|
||||
"E29 geometry-only.point_count",
|
||||
),
|
||||
voxelCount: integerValue(
|
||||
source.voxel_count,
|
||||
"E29 geometry-only.voxel_count",
|
||||
),
|
||||
heightRangeM: tuple2(
|
||||
source.height_range_m,
|
||||
"E29 geometry-only.height_range_m",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE29EvidenceFrame(value: unknown): E29EvidenceFrame {
|
||||
const source = record(value, "E29 frame response");
|
||||
if (source.schema_version !== "missioncore.laboratory-e29-frame/v1") {
|
||||
throw new E29EvidenceContractError("E29 frame schema не поддерживается.");
|
||||
}
|
||||
const frame = record(source.frame, "E29 frame");
|
||||
if (
|
||||
frame.schema_version !== "missioncore.e29-camera-geometry-frame/v1"
|
||||
|| !Array.isArray(frame.semantic_observations)
|
||||
|| !Array.isArray(frame.geometry_only_occupied)
|
||||
) {
|
||||
throw new E29EvidenceContractError("E29 frame имеет неверный формат.");
|
||||
}
|
||||
return {
|
||||
resultId: stringValue(source.result_id, "E29 frame result_id"),
|
||||
frameIndex: integerValue(frame.frame_index, "E29 frame.frame_index"),
|
||||
sourceFrameIndex: integerValue(
|
||||
frame.source_frame_index,
|
||||
"E29 frame.source_frame_index",
|
||||
),
|
||||
sessionSeconds: numberValue(
|
||||
frame.session_seconds,
|
||||
"E29 frame.session_seconds",
|
||||
),
|
||||
semanticObservations: frame.semantic_observations.map(
|
||||
parseSemanticObservation,
|
||||
),
|
||||
geometryOnlyOccupied: frame.geometry_only_occupied.map(parseGeometryOnly),
|
||||
};
|
||||
}
|
||||
|
||||
async function responseJson(response: Response, fallback: string): Promise<unknown> {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
// Preserve the status-aware fallback below.
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail?: unknown }).detail)
|
||||
: fallback;
|
||||
throw new E29EvidenceContractError(detail);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function fetchE29EvidenceCatalog(
|
||||
options: { signal?: AbortSignal; fetcher?: E29Fetch } = {},
|
||||
): Promise<E29EvidenceCatalog> {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher("/api/v1/laboratory/e29/results?limit=1", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
});
|
||||
return parseE29EvidenceCatalog(
|
||||
await responseJson(response, "Не удалось получить LAB E29."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchE29EvidenceFrame(
|
||||
resultId: string,
|
||||
frameIndex: number,
|
||||
options: { signal?: AbortSignal; fetcher?: E29Fetch } = {},
|
||||
): Promise<E29EvidenceFrame> {
|
||||
if (
|
||||
!/^e29-camera-geometry-[a-f0-9]{64}$/.test(resultId)
|
||||
|| !Number.isSafeInteger(frameIndex)
|
||||
|| frameIndex < 0
|
||||
) {
|
||||
throw new E29EvidenceContractError("Некорректный LAB E29 frame id.");
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e29/results/${resultId}/frames/${frameIndex}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE29EvidenceFrame(
|
||||
await responseJson(response, "Не удалось получить кадр LAB E29."),
|
||||
);
|
||||
}
|
||||
@@ -1488,7 +1488,7 @@ export async function fetchLidarLocalSurfaces(
|
||||
options: { signal?: AbortSignal; fetcher?: LidarFetch } = {},
|
||||
): Promise<LidarLocalSurfaceCatalog> {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher("/api/v1/lidar/local-surfaces?limit=10", {
|
||||
const response = await fetcher("/api/v1/lidar/local-surfaces?limit=1", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
|
||||
@@ -2592,6 +2592,111 @@
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review h2 {
|
||||
margin: 0.3rem 0 0;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker button {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.65rem 0.75rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker button:hover,
|
||||
.laboratory-frame-review__picker button.is-active {
|
||||
background: rgb(255 255 255 / 0.09);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker span {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 660;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker small,
|
||||
.laboratory-frame-review__detail small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__state {
|
||||
display: flex;
|
||||
min-height: 6rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail > div {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail > div > span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail > div > strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail .laboratory-frame-review__conflicts {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__conflicts p {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
padding-top: 0.45rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.laboratory-selector {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
@@ -2601,6 +2706,14 @@
|
||||
.laboratory-result-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail .laboratory-frame-review__conflicts {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.dataset-entry {
|
||||
|
||||
@@ -44,6 +44,16 @@ import type {
|
||||
MissionRuntimeState,
|
||||
ObservationSourceDescriptor,
|
||||
} from "../core/runtime/contracts";
|
||||
import {
|
||||
fetchE29EvidenceCatalog,
|
||||
fetchE29EvidenceFrame,
|
||||
type E29EvidenceFrame,
|
||||
type E29EvidenceResult,
|
||||
} from "../core/laboratory/e29Evidence";
|
||||
import {
|
||||
fetchLidarLocalSurfaces,
|
||||
type LidarLocalSurfaceModel,
|
||||
} from "../core/lidar/localSurface";
|
||||
import {
|
||||
RerunViewport,
|
||||
isRecordedPlaybackPresentationReady,
|
||||
@@ -1271,39 +1281,200 @@ function LaboratoryTask({
|
||||
);
|
||||
}
|
||||
|
||||
function E29LaboratoryResult({ rigLabel }: { rigLabel: string }) {
|
||||
function formatSeconds(value: number): string {
|
||||
return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} с`;
|
||||
}
|
||||
|
||||
function E29LaboratoryResult({
|
||||
props,
|
||||
rigLabel,
|
||||
result,
|
||||
sourceSession,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
props: WorkspaceRendererProps;
|
||||
rigLabel: string;
|
||||
result: E29EvidenceResult;
|
||||
sourceSession: ObservationSessionSummary;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
const [selectedFrameIndex, setSelectedFrameIndex] = useState(
|
||||
result.reviewFrames[0]?.frameIndex ?? 0,
|
||||
);
|
||||
const [frame, setFrame] = useState<E29EvidenceFrame | null>(null);
|
||||
const [frameLoading, setFrameLoading] = useState(false);
|
||||
const [frameError, setFrameError] = useState<string | null>(null);
|
||||
const replayReady = props.recordedReplay?.sessionId === sourceSession.id;
|
||||
const semantic = result.metrics.semanticObservations;
|
||||
const geometryStatus = semantic.geometryStatus;
|
||||
const conflicts = frame?.semanticObservations.filter(
|
||||
(observation) => observation.geometryStatus === "conflict",
|
||||
) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setFrameLoading(true);
|
||||
setFrameError(null);
|
||||
void fetchE29EvidenceFrame(result.resultId, selectedFrameIndex, {
|
||||
signal: controller.signal,
|
||||
}).then((next) => {
|
||||
setFrame(next);
|
||||
}).catch((caught: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setFrame(null);
|
||||
setFrameError(
|
||||
caught instanceof Error ? caught.message : "Кадр E29 недоступен.",
|
||||
);
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setFrameLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [result.resultId, selectedFrameIndex]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<LaboratoryTask
|
||||
title="LAB E29 · camera-first semantics + независимая геометрия"
|
||||
description="Проверяется рабочая продуктовая граница: камера сохраняет семантический класс и идентичность объекта, LiDAR подтверждает метрическую дальность и занятую геометрию по локальной поверхности L2.6. Отсутствие точек не объявляется свободным пространством."
|
||||
status="Диагностический replay"
|
||||
status="Проверенные артефакты"
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} · камера + LiDAR · worker D` },
|
||||
{ label: "Источник", value: "RAVNOVES00 · 4 489 кадров" },
|
||||
{ label: "Объектов", value: "20 513 наблюдений" },
|
||||
{ label: "Полномочия", value: "Только shadow · команды запрещены" },
|
||||
{
|
||||
label: "Источник",
|
||||
value: `${sourceSession.label} · ${formatNumber(result.identity.frameCount, 0)} кадров`,
|
||||
},
|
||||
{
|
||||
label: "Наблюдений",
|
||||
value: formatNumber(semantic.total, 0),
|
||||
},
|
||||
{
|
||||
label: "Артефакты",
|
||||
value: "Control plane · read-only · hash verified",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<section className="lab-result-surface">
|
||||
<header>
|
||||
<span className="section-eyebrow">ИСХОДНЫЕ ДАННЫЕ</span>
|
||||
<strong>LiDAR, траектория и камера RAVNOVES00</strong>
|
||||
</header>
|
||||
{replayReady ? (
|
||||
<SpatialWorkspace {...props} />
|
||||
) : (
|
||||
<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
|
||||
? "Viewer появится после серверной проверки неизменяемого RRD."
|
||||
: "Выберите LAB E29 повторно, чтобы открыть связанный источник.")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="laboratory-result-summary">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">РЕЗУЛЬТАТ И ВЫВОД</span>
|
||||
<h2>Архитектурная схема подтверждена, safety-gate не пройден</h2>
|
||||
<h2>Camera-first контракт рассчитан, production gate не пройден</h2>
|
||||
</div>
|
||||
<StatusBadge tone="success">Replay завершён</StatusBadge>
|
||||
<StatusBadge tone={result.decision.productionPromotion ? "success" : "warning"}>
|
||||
{result.decision.productionPromotion ? "Допущено" : "Только диагностика"}
|
||||
</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>
|
||||
<span>Поддержка геометрией</span>
|
||||
<strong>{formatNumber(geometryStatus.agree, 0)}</strong>
|
||||
<small>{(semantic.agreementFractionOfCurrent * 100).toLocaleString("ru-RU", { maximumFractionDigits: 2 })}% current</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Только камера</span>
|
||||
<strong>{formatNumber(geometryStatus.cameraOnly, 0)}</strong>
|
||||
<small>Семантика без LiDAR-подтверждения</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Postprocess p95</span>
|
||||
<strong>{result.metrics.runtime.frameProcessingP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс</strong>
|
||||
<small>{formatSeconds(result.metrics.runtime.buildElapsedMs / 1000)} полный build</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Только геометрия</span>
|
||||
<strong>{formatNumber(result.metrics.geometryOnlyOccupied.clusterCount, 0)}</strong>
|
||||
<small>{formatNumber(result.metrics.geometryOnlyOccupied.pointCount, 0)} точек</small>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
Следующий gate — покадровый разбор конфликтов, camera-only пробелов и
|
||||
geometry-only компонентов, затем source-paced shadow с deadline и staleness.
|
||||
</p>
|
||||
<p>{result.decision.nextGate}</p>
|
||||
</section>
|
||||
|
||||
<section className="laboratory-frame-review">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">КАДРЫ С КОНФЛИКТОМ</span>
|
||||
<h2>Покадровое доказательство из camera-geometry-frames.jsonl</h2>
|
||||
</div>
|
||||
<StatusBadge tone="warning">
|
||||
{formatNumber(geometryStatus.conflict, 0)} конфликтов
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="laboratory-frame-review__picker" role="list">
|
||||
{result.reviewFrames.slice(0, 16).map((review) => (
|
||||
<button
|
||||
key={review.frameIndex}
|
||||
type="button"
|
||||
className={review.frameIndex === selectedFrameIndex ? "is-active" : undefined}
|
||||
onClick={() => setSelectedFrameIndex(review.frameIndex)}
|
||||
>
|
||||
<span>Кадр {formatNumber(review.sourceFrameIndex, 0)}</span>
|
||||
<small>{formatSeconds(review.sessionSeconds)} · {review.conflictCount} конфликт</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{frameLoading ? (
|
||||
<div className="laboratory-frame-review__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Читаем подтверждённый кадр</span>
|
||||
</div>
|
||||
) : frameError || !frame ? (
|
||||
<div className="laboratory-frame-review__state" role="status">
|
||||
<Icon name="database" size={18} />
|
||||
<span>{frameError ?? "Кадр недоступен."}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="laboratory-frame-review__detail">
|
||||
<div>
|
||||
<span>Кадр источника</span>
|
||||
<strong>{formatNumber(frame.sourceFrameIndex, 0)}</strong>
|
||||
<small>{formatSeconds(frame.sessionSeconds)}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Семантические наблюдения</span>
|
||||
<strong>{formatNumber(frame.semanticObservations.length, 0)}</strong>
|
||||
<small>{formatNumber(conflicts.length, 0)} требуют разбора</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Geometry-only компоненты</span>
|
||||
<strong>{formatNumber(frame.geometryOnlyOccupied.length, 0)}</strong>
|
||||
<small>Класс не назначается</small>
|
||||
</div>
|
||||
<div className="laboratory-frame-review__conflicts">
|
||||
<span>Фактические конфликты</span>
|
||||
{conflicts.length ? conflicts.map((observation) => (
|
||||
<p key={observation.trackId}>
|
||||
<strong>{observation.label} · track {observation.trackId}</strong>
|
||||
<small>
|
||||
{observation.geometryReason} · classified {observation.support.classifiedPoints}
|
||||
{" · "}surface {observation.support.surfacePoints}
|
||||
</small>
|
||||
</p>
|
||||
)) : <small>В этом кадре конфликт не найден.</small>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
@@ -1421,6 +1592,10 @@ function PublishedLaboratoryResult({
|
||||
function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
||||
const [profileId, setProfileId] = useState<LaboratoryProfileId>("sensor-fusion");
|
||||
const [workId, setWorkId] = useState<LaboratoryWorkId>("e28-local-surface");
|
||||
const [e28Model, setE28Model] = useState<LidarLocalSurfaceModel | null>(null);
|
||||
const [e29Result, setE29Result] = useState<E29EvidenceResult | null>(null);
|
||||
const [evidenceLoading, setEvidenceLoading] = useState(true);
|
||||
const [evidenceError, setEvidenceError] = useState<string | null>(null);
|
||||
const sessions = useObservationSessions({
|
||||
limit: 100,
|
||||
replayEnabled: props.sessionArchive.blockedReason === null,
|
||||
@@ -1429,9 +1604,47 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
||||
onReplaySettled: props.sessionArchive.onReplaySettled,
|
||||
});
|
||||
const publishedWorks = useMemo(
|
||||
() => sessions.items.filter((session) => session.lab !== null),
|
||||
() => sessions.items.filter((session) => (
|
||||
session.lab !== null
|
||||
&& session.status === "ready"
|
||||
&& session.replayable
|
||||
&& session.modalities.includes("point-cloud")
|
||||
)),
|
||||
[sessions.items],
|
||||
);
|
||||
const sourceSessions = useMemo(
|
||||
() => new Map(sessions.items.map((session) => [session.id, session])),
|
||||
[sessions.items],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setEvidenceLoading(true);
|
||||
setEvidenceError(null);
|
||||
void Promise.allSettled([
|
||||
fetchLidarLocalSurfaces({ signal: controller.signal }),
|
||||
fetchE29EvidenceCatalog({ signal: controller.signal }),
|
||||
]).then(([e28, e29]) => {
|
||||
if (controller.signal.aborted) return;
|
||||
const nextE28 = e28.status === "fulfilled" ? e28.value.items[0] ?? null : null;
|
||||
const nextE29 = e29.status === "fulfilled" ? e29.value.items[0] ?? null : null;
|
||||
setE28Model(nextE28);
|
||||
setE29Result(nextE29);
|
||||
const failures = [
|
||||
e28.status === "rejected" ? "E28" : null,
|
||||
e29.status === "rejected" ? "E29" : null,
|
||||
].filter(Boolean);
|
||||
setEvidenceError(
|
||||
failures.length
|
||||
? `${failures.join(" и ")} не прошли серверную проверку и скрыты.`
|
||||
: null,
|
||||
);
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setEvidenceLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const rigLabel = useMemo(() => {
|
||||
if (props.deviceLabel) return props.deviceLabel;
|
||||
const coordinateFrame = publishedWorks
|
||||
@@ -1442,22 +1655,44 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
||||
: "";
|
||||
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 sensorWorks = useMemo(() => {
|
||||
const items: LaboratoryOption<LaboratoryWorkId>[] = [];
|
||||
if (e28Model) {
|
||||
items.push({
|
||||
id: "e28-local-surface",
|
||||
label: "LAB E28 · локальная поверхность L2.6",
|
||||
});
|
||||
}
|
||||
if (
|
||||
e29Result
|
||||
&& sourceSessions.has(e29Result.linkedEvidence.sourceSessionId)
|
||||
) {
|
||||
items.push({
|
||||
id: "e29-camera-geometry",
|
||||
label: "LAB E29 · camera-first + geometry",
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [e28Model, e29Result, sourceSessions]);
|
||||
const profiles = useMemo(() => {
|
||||
const items: LaboratoryOption<LaboratoryProfileId>[] = [];
|
||||
if (sensorWorks.length) {
|
||||
items.push({
|
||||
id: "sensor-fusion",
|
||||
label: `${rigLabel} · камера + LiDAR · control plane`,
|
||||
});
|
||||
}
|
||||
if (publishedWorks.length) {
|
||||
items.push({
|
||||
id: "published-perception",
|
||||
label: `${rigLabel} · опубликованный perception pipeline`,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [publishedWorks.length, rigLabel, sensorWorks.length]);
|
||||
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" },
|
||||
]
|
||||
? sensorWorks
|
||||
: publishedWorks.map((session) => ({
|
||||
id: `session:${session.id}` as const,
|
||||
label: `${session.lab?.labId ?? "LAB"} · ${laboratorySessionTitle(session)}`,
|
||||
@@ -1468,18 +1703,49 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
||||
const selectedSession = selectedSessionId
|
||||
? publishedWorks.find((session) => session.id === selectedSessionId) ?? null
|
||||
: null;
|
||||
const e29SourceSession = e29Result
|
||||
? sourceSessions.get(e29Result.linkedEvidence.sourceSessionId) ?? 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]);
|
||||
if (
|
||||
evidenceLoading
|
||||
|| sessions.state === "idle"
|
||||
|| sessions.state === "loading"
|
||||
) return;
|
||||
if (!profiles.some((profile) => profile.id === profileId)) {
|
||||
const firstProfile = profiles[0];
|
||||
if (!firstProfile) return;
|
||||
setProfileId(firstProfile.id);
|
||||
if (firstProfile.id === "sensor-fusion") {
|
||||
const firstWork = sensorWorks[0];
|
||||
if (firstWork) setWorkId(firstWork.id);
|
||||
} else {
|
||||
const first = publishedWorks[0];
|
||||
if (first) setWorkId(`session:${first.id}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!workOptions.some((work) => work.id === workId)) {
|
||||
const firstWork = workOptions[0];
|
||||
if (firstWork) setWorkId(firstWork.id);
|
||||
}
|
||||
}, [
|
||||
evidenceLoading,
|
||||
profileId,
|
||||
profiles,
|
||||
publishedWorks,
|
||||
sensorWorks,
|
||||
sessions.state,
|
||||
workId,
|
||||
workOptions,
|
||||
]);
|
||||
|
||||
const selectProfile = (next: LaboratoryProfileId) => {
|
||||
setProfileId(next);
|
||||
if (next === "sensor-fusion") {
|
||||
setWorkId("e28-local-surface");
|
||||
const first = sensorWorks[0];
|
||||
if (first) setWorkId(first.id);
|
||||
return;
|
||||
}
|
||||
const first = publishedWorks[0];
|
||||
@@ -1491,10 +1757,41 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
||||
|
||||
const selectWork = (next: LaboratoryWorkId) => {
|
||||
setWorkId(next);
|
||||
if (!next.startsWith("session:")) return;
|
||||
void sessions.replay(next.slice("session:".length));
|
||||
if (next.startsWith("session:")) {
|
||||
void sessions.replay(next.slice("session:".length));
|
||||
return;
|
||||
}
|
||||
if (next === "e29-camera-geometry" && e29SourceSession) {
|
||||
void sessions.replay(e29SourceSession.id);
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
evidenceLoading
|
||||
|| sessions.state === "idle"
|
||||
|| sessions.state === "loading"
|
||||
) {
|
||||
return (
|
||||
<div className="laboratory-result-pending" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<strong>Ревизия лабораторных данных</strong>
|
||||
<p>Проверяем артефакты, связанные исходные записи и доступность replay.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profiles.length) {
|
||||
return (
|
||||
<div className="laboratory-result-pending" role="status">
|
||||
<Icon name="database" size={20} />
|
||||
<strong>Подтверждённых лабораторных работ нет</strong>
|
||||
<p>
|
||||
{evidenceError ?? sessions.error ?? "Непроверенные и отсутствующие результаты скрыты."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lab-archive-workspace">
|
||||
<LaboratorySelector
|
||||
@@ -1524,12 +1821,15 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
||||
<LaboratoryTask
|
||||
title="LAB E28 · локальная модель поверхности L2.6"
|
||||
description="Полная запись RAVNOVES00 воспроизводится через bounded shadow-контур. Модель оценивает локальную поверхность, наблюдаемые препятствия, временные скачки и ошибки предсказания без изменения исходного сенсора и без командного канала."
|
||||
status="Диагностический replay"
|
||||
status="Проверенные артефакты"
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} · LiDAR + pose · worker D` },
|
||||
{ label: "Покрытие", value: "3 928 / 3 928 кадров" },
|
||||
{
|
||||
label: "Покрытие",
|
||||
value: `${formatNumber(e28Model?.metrics.frames.valid ?? 0, 0)} / ${formatNumber(e28Model?.metrics.frames.total ?? 0, 0)} кадров`,
|
||||
},
|
||||
{ label: "Режим", value: "Recorded-source-paced shadow" },
|
||||
{ label: "Полномочия", value: "Навигация и команды запрещены" },
|
||||
{ label: "Артефакты", value: "Control plane · read-only · hash verified" },
|
||||
]}
|
||||
/>
|
||||
<LidarQualityWorkspace
|
||||
@@ -1538,8 +1838,19 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
||||
onOpenObservation={() => props.navigation.openView("spatial-scene")}
|
||||
/>
|
||||
</>
|
||||
) : workId === "e29-camera-geometry" ? (
|
||||
<E29LaboratoryResult rigLabel={rigLabel} />
|
||||
) : workId === "e29-camera-geometry" && e29Result && e29SourceSession ? (
|
||||
<E29LaboratoryResult
|
||||
props={props}
|
||||
rigLabel={rigLabel}
|
||||
result={e29Result}
|
||||
sourceSession={e29SourceSession}
|
||||
loading={sessions.replayingSessionId === e29SourceSession.id}
|
||||
error={
|
||||
sessions.failedSessionId === e29SourceSession.id
|
||||
? sessions.error
|
||||
: null
|
||||
}
|
||||
/>
|
||||
) : selectedSession ? (
|
||||
<PublishedLaboratoryResult
|
||||
props={props}
|
||||
@@ -1550,8 +1861,8 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
||||
) : (
|
||||
<div className="laboratory-result-pending">
|
||||
<Icon name="database" size={20} />
|
||||
<strong>В этом профиле пока нет опубликованных работ</strong>
|
||||
<p>Работы появятся после публикации проверенного LAB-provenance.</p>
|
||||
<strong>Работа не прошла ревизию</strong>
|
||||
<p>Неподтверждённый результат скрыт из лабораторного каталога.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user