diff --git a/apps/control-station/src/core/laboratory/advancedIndex.ts b/apps/control-station/src/core/laboratory/advancedIndex.ts index 51cf2fe..de9db95 100644 --- a/apps/control-station/src/core/laboratory/advancedIndex.ts +++ b/apps/control-station/src/core/laboratory/advancedIndex.ts @@ -14,9 +14,11 @@ import { fetchE34TemporalLayerResult } from "./e34TemporalLayer"; import { fetchE35DegradationRecoveryResult } from "./e35DegradationRecovery"; import { fetchE40ProductGateResult } from "./e40ProductGate"; import { fetchL3PointPillarsVisualAudit } from "./l3PointPillarsVisualAudit"; +import { fetchL31PointPillarsRavnoves } from "./l31PointPillarsRavnoves"; export type AdvancedLaboratoryWorkId = | "l3-pointpillars-visual-audit" + | "l31-pointpillars-ravnoves" | "e31-source-binding" | "e32-track-geometry" | "e33-worker-shadow" @@ -35,6 +37,7 @@ export interface AdvancedLaboratoryIndexItem { const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [ "l3-pointpillars-visual-audit", + "l31-pointpillars-ravnoves", "e31-source-binding", "e32-track-geometry", "e33-worker-shadow", @@ -48,6 +51,7 @@ const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [ const RESULT_PREFIX: Readonly> = { "l3-pointpillars-visual-audit": "l3-pointpillars-visual-audit", + "l31-pointpillars-ravnoves": "l31-pointpillars-ravnoves", "e31-source-binding": "e31-source-qualification", "e32-track-geometry": "e32-track-geometry", "e33-worker-shadow": "e33-worker-shadow", @@ -68,6 +72,7 @@ export function isAdvancedLaboratoryWorkId( export function emptyAdvancedLaboratoryResults(): AdvancedLaboratoryResults { return { l3: null, + l31: null, e31: null, e32: null, e33: null, @@ -169,6 +174,7 @@ export function advancedLaboratoryResultAvailable( results: AdvancedLaboratoryResults, ): boolean { return workId === "l3-pointpillars-visual-audit" ? results.l3 !== null + : workId === "l31-pointpillars-ravnoves" ? results.l31 !== null : workId === "e31-source-binding" ? results.e31 !== null : workId === "e32-track-geometry" ? results.e32 !== null : workId === "e33-worker-shadow" ? results.e33 !== null @@ -193,6 +199,8 @@ export async function fetchAdvancedLaboratoryResult( const results = emptyAdvancedLaboratoryResults(); if (workId === "l3-pointpillars-visual-audit") { results.l3 = await fetchL3PointPillarsVisualAudit({ fetcher, signal }); + } else if (workId === "l31-pointpillars-ravnoves") { + results.l31 = await fetchL31PointPillarsRavnoves({ fetcher, signal }); } else if (workId === "e31-source-binding") { results.e31 = await fetchOne( "/api/v1/laboratory/e31/results?limit=1", diff --git a/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts b/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts new file mode 100644 index 0000000..906cf7b --- /dev/null +++ b/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts @@ -0,0 +1,27 @@ +import type { + E31LaboratoryResult, + E32LaboratoryResult, + E33LaboratoryResult, + E37AcceptanceContractResult, + E38PerceptionBaselineResult, + E39PerceptionRefinementResult, +} from "./advancedResults"; +import type { E34TemporalLayerResult } from "./e34TemporalLayer"; +import type { E35DegradationRecoveryResult } from "./e35DegradationRecovery"; +import type { E40PerceptionProductGateResult } from "./e40ProductGate"; +import type { L3PointPillarsVisualAuditResult } from "./l3PointPillarsVisualAudit"; +import type { L31PointPillarsRavnovesResult } from "./l31PointPillarsRavnoves"; + +export interface AdvancedLaboratoryResults { + l3: L3PointPillarsVisualAuditResult | null; + l31: L31PointPillarsRavnovesResult | null; + e31: E31LaboratoryResult | null; + e32: E32LaboratoryResult | null; + e33: E33LaboratoryResult | null; + e34: E34TemporalLayerResult | null; + e35: E35DegradationRecoveryResult | null; + e37: E37AcceptanceContractResult | null; + e38: E38PerceptionBaselineResult | null; + e39: E39PerceptionRefinementResult | null; + e40: E40PerceptionProductGateResult | null; +} diff --git a/apps/control-station/src/core/laboratory/advancedResults.ts b/apps/control-station/src/core/laboratory/advancedResults.ts index 785a87e..382343e 100644 --- a/apps/control-station/src/core/laboratory/advancedResults.ts +++ b/apps/control-station/src/core/laboratory/advancedResults.ts @@ -1,17 +1,15 @@ import { fetchE34TemporalLayerResult, - type E34TemporalLayerResult, } from "./e34TemporalLayer"; import { fetchE35DegradationRecoveryResult, - type E35DegradationRecoveryResult, } from "./e35DegradationRecovery"; import { fetchE40ProductGateResult, - type E40PerceptionProductGateResult, } from "./e40ProductGate"; import { settledCatalogValue } from "./catalogTransport"; -import type { L3PointPillarsVisualAuditResult } from "./l3PointPillarsVisualAudit"; +import type { AdvancedLaboratoryResults } from "./advancedLaboratoryResults"; +export type { AdvancedLaboratoryResults } from "./advancedLaboratoryResults"; export interface E31LaboratoryResult { resultId: string; @@ -238,19 +236,6 @@ export interface E39PerceptionRefinementResult { access: "read-only"; } -export interface AdvancedLaboratoryResults { - l3: L3PointPillarsVisualAuditResult | null; - e31: E31LaboratoryResult | null; - e32: E32LaboratoryResult | null; - e33: E33LaboratoryResult | null; - e34: E34TemporalLayerResult | null; - e35: E35DegradationRecoveryResult | null; - e37: E37AcceptanceContractResult | null; - e38: E38PerceptionBaselineResult | null; - e39: E39PerceptionRefinementResult | null; - e40: E40PerceptionProductGateResult | null; -} - export class AdvancedLaboratoryContractError extends Error { constructor(message: string) { super(message); @@ -990,5 +975,17 @@ export async function fetchAdvancedLaboratoryResults({ const e38 = settledCatalogValue(settled[6]); const e39 = settledCatalogValue(settled[7]); const e40 = settledCatalogValue(settled[8]); - return { l3: null, e31, e32, e33, e34, e35, e37, e38, e39, e40 }; + return { + l3: null, + l31: null, + e31, + e32, + e33, + e34, + e35, + e37, + e38, + e39, + e40, + }; } diff --git a/apps/control-station/src/core/laboratory/l31PointPillarsRavnoves.ts b/apps/control-station/src/core/laboratory/l31PointPillarsRavnoves.ts new file mode 100644 index 0000000..943d4a9 --- /dev/null +++ b/apps/control-station/src/core/laboratory/l31PointPillarsRavnoves.ts @@ -0,0 +1,475 @@ +import { + AdvancedLaboratoryContractError, + type LaboratoryFetch, +} from "./advancedResults"; +import type { L3VisualBox } from "./l3PointPillarsVisualAudit"; + +export interface L31ClassCounts { + Vehicle: number; + Pedestrian: number; + Cyclist: number; +} + +export interface L31VisualFrameSummary { + frameId: string; + frameIndex: number; + sessionSeconds: number; + sourcePointCount: number; + predictionCount: number; + classCounts: L31ClassCounts; + inferenceMs: number; +} + +export interface L31PointPillarsRavnovesResult { + resultId: string; + createdAtUtc: string; + status: "cross-domain-transfer-measured-visual-review-required"; + sourceSessionId: string; + sourcePackId: string; + sourceLogicalContentSha256: string; + model: { + name: "pointpillars"; + sourceModelSha256: string; + engineSha256: string; + embeddedScoreThreshold: number; + }; + execution: { + workerHostId: "worker-006"; + sequential: true; + parallelWorkers: 1; + sourcePaced: false; + existingTritonOnly: true; + }; + metrics: { + frameCount: number; + inputAdmissionFraction: number; + outputSchemaValidFraction: number; + framesWithPredictions: number; + framesWithVehiclePredictions: number; + predictionCount: number; + classCounts: L31ClassCounts; + inferenceLatencyMs: { p50: number; p95: number; maximum: number }; + poseBindingAgeMs: { p50: number; p95: number; maximum: number }; + deterministicReplayFraction: number; + deterministicReplayFrames: number; + }; + frames: readonly L31VisualFrameSummary[]; + limitations: readonly string[]; +} + +export interface L31VisualFrame { + frameId: string; + summary: L31VisualFrameSummary & { + deterministicReplay: boolean; + replayInferenceMs: number; + visualBoxCount: number; + visualBoxTruncated: boolean; + }; + sourcePointCount: number; + modelRangePointCount: number; + sampledPointCount: number; + pointsXyzi: readonly number[]; + truthBoxes: readonly L3VisualBox[]; + predictionBoxes: readonly L3VisualBox[]; +} + +const RESULT_ID = /^l31-pointpillars-ravnoves-[a-f0-9]{64}$/; +const PACK_ID = /^lidar-replay-pack-[a-f0-9]{64}$/; +const SHA256 = /^[a-f0-9]{64}$/; +const FRAME_ID = /^[0-9]{6}$/; + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new AdvancedLaboratoryContractError(`${label}: ожидался объект.`); + } + return value as Record; +} + +function array(value: unknown, label: string): readonly unknown[] { + if (!Array.isArray(value)) { + throw new AdvancedLaboratoryContractError(`${label}: ожидался массив.`); + } + return value; +} + +function string(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new AdvancedLaboratoryContractError(`${label}: ожидалась строка.`); + } + return value; +} + +function exact(value: unknown, expected: T, label: string): T { + if (value !== expected) { + throw new AdvancedLaboratoryContractError(`${label}: нарушен контракт.`); + } + return expected; +} + +function number(value: unknown, label: string, minimum = 0): number { + if ( + typeof value !== "number" + || !Number.isFinite(value) + || value < minimum + ) { + throw new AdvancedLaboratoryContractError(`${label}: неверное число.`); + } + return value; +} + +function integer(value: unknown, label: string): number { + const parsed = number(value, label); + if (!Number.isInteger(parsed)) { + throw new AdvancedLaboratoryContractError(`${label}: ожидалось целое.`); + } + return parsed; +} + +function boolean(value: unknown, label: string): boolean { + if (typeof value !== "boolean") { + throw new AdvancedLaboratoryContractError(`${label}: ожидался boolean.`); + } + return value; +} + +function exactBoolean( + value: unknown, + expected: boolean, + label: string, +): boolean { + const parsed = boolean(value, label); + if (parsed !== expected) { + throw new AdvancedLaboratoryContractError(`${label}: нарушен контракт.`); + } + return parsed; +} + +function classCounts(value: unknown, label: string): L31ClassCounts { + const counts = record(value, label); + if ( + Object.keys(counts).sort().join(",") !== "Cyclist,Pedestrian,Vehicle" + ) { + throw new AdvancedLaboratoryContractError(`${label}: классы изменились.`); + } + return { + Vehicle: integer(counts.Vehicle, `${label}.Vehicle`), + Pedestrian: integer(counts.Pedestrian, `${label}.Pedestrian`), + Cyclist: integer(counts.Cyclist, `${label}.Cyclist`), + }; +} + +function distribution( + value: unknown, + label: string, +): { p50: number; p95: number; maximum: number } { + const item = record(value, label); + return { + p50: number(item.p50, `${label}.p50`), + p95: number(item.p95, `${label}.p95`), + maximum: number(item.maximum, `${label}.maximum`), + }; +} + +function parseSummary(value: unknown): L31VisualFrameSummary { + const item = record(value, "L3.1 frame"); + const frameId = string(item.frame_id, "L3.1 frame_id"); + if (!FRAME_ID.test(frameId)) { + throw new AdvancedLaboratoryContractError("L3.1 frame_id: неверный формат."); + } + return { + frameId, + frameIndex: integer(item.frame_index, "L3.1 frame_index"), + sessionSeconds: number(item.session_seconds, "L3.1 session_seconds"), + sourcePointCount: integer( + item.source_point_count, + "L3.1 source_point_count", + ), + predictionCount: integer( + item.prediction_count, + "L3.1 prediction_count", + ), + classCounts: classCounts(item.class_counts, "L3.1 class_counts"), + inferenceMs: number( + item.inference_ms, + "L3.1 inference_ms", + Number.MIN_VALUE, + ), + }; +} + +function parseResult(value: unknown): L31PointPillarsRavnovesResult { + const item = record(value, "L3.1 result"); + exact( + item.schema_version, + "missioncore.l31-pointpillars-ravnoves-result/v1", + "L3.1 schema_version", + ); + exact(item.access, "read-only", "L3.1 access"); + const resultId = string(item.result_id, "L3.1 result_id"); + const sourcePackId = string(item.source_pack_id, "L3.1 source_pack_id"); + const logicalSha = string( + item.source_logical_content_sha256, + "L3.1 logical content", + ); + if ( + !RESULT_ID.test(resultId) + || !PACK_ID.test(sourcePackId) + || !SHA256.test(logicalSha) + ) { + throw new AdvancedLaboratoryContractError( + "L3.1: нарушена идентичность результата.", + ); + } + const model = record(item.model, "L3.1 model"); + const execution = record(item.execution, "L3.1 execution"); + const metrics = record(item.metrics, "L3.1 metrics"); + const frames = array(item.frames, "L3.1 frames").map(parseSummary); + if ( + !frames.length + || frames.length > 18 + || new Set(frames.map(({ frameId }) => frameId)).size !== frames.length + ) { + throw new AdvancedLaboratoryContractError("L3.1 frames: неверный каталог."); + } + return { + resultId, + createdAtUtc: string(item.created_at_utc, "L3.1 created_at_utc"), + status: exact( + item.status, + "cross-domain-transfer-measured-visual-review-required", + "L3.1 status", + ), + sourceSessionId: exact( + item.source_session_id, + "20260720T065719Z_viewer_live", + "L3.1 source_session_id", + ), + sourcePackId, + sourceLogicalContentSha256: logicalSha, + model: { + name: exact(model.name, "pointpillars", "L3.1 model.name"), + sourceModelSha256: string( + model.source_model_sha256, + "L3.1 model sha", + ), + engineSha256: string(model.engine_sha256, "L3.1 engine sha"), + embeddedScoreThreshold: number( + model.embedded_score_threshold, + "L3.1 score threshold", + ), + }, + execution: { + workerHostId: exact( + execution.worker_host_id, + "worker-006", + "L3.1 worker", + ), + sequential: exactBoolean( + execution.sequential, + true, + "L3.1 sequential", + ) as true, + parallelWorkers: ( + integer(execution.parallel_workers, "L3.1 parallel workers") === 1 + ? 1 + : (() => { + throw new AdvancedLaboratoryContractError( + "L3.1 parallel workers: нарушен контракт.", + ); + })() + ), + sourcePaced: exactBoolean( + execution.source_paced, + false, + "L3.1 source paced", + ) as false, + existingTritonOnly: exactBoolean( + execution.existing_triton_only, + true, + "L3.1 Triton", + ) as true, + }, + metrics: { + frameCount: integer(metrics.frame_count, "L3.1 frame count"), + inputAdmissionFraction: number( + metrics.input_admission_fraction, + "L3.1 admission", + ), + outputSchemaValidFraction: number( + metrics.output_schema_valid_fraction, + "L3.1 schema valid", + ), + framesWithPredictions: integer( + metrics.frames_with_predictions, + "L3.1 frames with predictions", + ), + framesWithVehiclePredictions: integer( + metrics.frames_with_vehicle_predictions, + "L3.1 frames with vehicles", + ), + predictionCount: integer( + metrics.prediction_count, + "L3.1 predictions", + ), + classCounts: classCounts(metrics.class_counts, "L3.1 metric classes"), + inferenceLatencyMs: distribution( + metrics.inference_latency_ms, + "L3.1 latency", + ), + poseBindingAgeMs: distribution( + metrics.pose_binding_age_ms, + "L3.1 pose age", + ), + deterministicReplayFraction: number( + metrics.deterministic_replay_fraction, + "L3.1 determinism", + ), + deterministicReplayFrames: integer( + metrics.deterministic_replay_frames, + "L3.1 determinism frames", + ), + }, + frames, + limitations: array(item.limitations, "L3.1 limitations").map( + (entry) => string(entry, "L3.1 limitation"), + ), + }; +} + +function parseBox(value: unknown): L3VisualBox { + const box = record(value, "L3.1 box"); + const center = [ + number(box.x_m, "L3.1 box.x", -Infinity), + number(box.y_m, "L3.1 box.y", -Infinity), + number(box.z_m, "L3.1 box.z", -Infinity), + ] as const; + const size = [ + number(box.length_m, "L3.1 box.length", Number.MIN_VALUE), + number(box.width_m, "L3.1 box.width", Number.MIN_VALUE), + number(box.height_m, "L3.1 box.height", Number.MIN_VALUE), + ] as const; + const modelClass = string(box.model_class, "L3.1 box.class"); + if (!["Vehicle", "Pedestrian", "Cyclist"].includes(modelClass)) { + throw new AdvancedLaboratoryContractError("L3.1 box.class: неизвестен."); + } + return { + benchmarkClass: modelClass, + centerXyzM: center, + sizeLwhM: size, + yawRad: number(box.yaw_rad, "L3.1 box.yaw", -Infinity), + status: "model-prediction", + score: number(box.score, "L3.1 box.score"), + }; +} + +export async function fetchL31PointPillarsRavnoves({ + fetcher = fetch, + signal, +}: { + fetcher?: LaboratoryFetch; + signal?: AbortSignal; +} = {}): Promise { + const response = await fetcher( + "/api/v1/laboratory/l31/pointpillars-ravnoves/results?limit=1", + { method: "GET", headers: { Accept: "application/json" }, signal }, + ); + if (!response.ok) { + throw new AdvancedLaboratoryContractError( + `L3.1 RAVNOVES недоступен: HTTP ${response.status}.`, + ); + } + const catalog = record(await response.json(), "L3.1 catalog"); + exact( + catalog.schema_version, + "missioncore.l31-pointpillars-ravnoves-catalog-results/v1", + "L3.1 catalog schema", + ); + const items = array(catalog.items, "L3.1 catalog.items"); + if (items.length > 1) { + throw new AdvancedLaboratoryContractError("L3.1 catalog: лишние результаты."); + } + return items.length ? parseResult(items[0]) : null; +} + +export async function fetchL31PointPillarsRavnovesFrame( + resultId: string, + frameId: string, + { + fetcher = fetch, + signal, + }: { + fetcher?: LaboratoryFetch; + signal?: AbortSignal; + } = {}, +): Promise { + if (!RESULT_ID.test(resultId) || !FRAME_ID.test(frameId)) { + throw new AdvancedLaboratoryContractError("L3.1 frame: неверная identity."); + } + const response = await fetcher( + `/api/v1/laboratory/l31/pointpillars-ravnoves/${resultId}/frames/${frameId}`, + { method: "GET", headers: { Accept: "application/json" }, signal }, + ); + if (!response.ok) { + throw new AdvancedLaboratoryContractError( + `L3.1 frame недоступен: HTTP ${response.status}.`, + ); + } + const payload = record(await response.json(), "L3.1 visual frame"); + exact( + payload.schema_version, + "missioncore.l31-pointpillars-ravnoves-visual-frame/v1", + "L3.1 visual schema", + ); + exact(payload.frame_id, frameId, "L3.1 visual frame_id"); + const summaryRaw = record(payload.summary, "L3.1 visual summary"); + const summary = parseSummary(summaryRaw); + const points = record(payload.points, "L3.1 points"); + exact(points.layout, "flat-xyzi", "L3.1 points.layout"); + const pointValues = array(points.values, "L3.1 points.values").map( + (entry, index) => number(entry, `L3.1 points[${index}]`, -Infinity), + ); + const sampledPointCount = integer( + points.sampled_point_count, + "L3.1 sampled points", + ); + if (sampledPointCount > 12_000 || pointValues.length !== sampledPointCount * 4) { + throw new AdvancedLaboratoryContractError("L3.1 points: нарушен bound."); + } + return { + frameId, + summary: { + ...summary, + deterministicReplay: boolean( + summaryRaw.deterministic_replay, + "L3.1 deterministic replay", + ), + replayInferenceMs: number( + summaryRaw.replay_inference_ms, + "L3.1 replay latency", + ), + visualBoxCount: integer( + summaryRaw.visual_box_count, + "L3.1 visual box count", + ), + visualBoxTruncated: boolean( + summaryRaw.visual_box_truncated, + "L3.1 visual box truncated", + ), + }, + sourcePointCount: integer( + points.source_point_count, + "L3.1 source points", + ), + modelRangePointCount: integer( + points.model_range_point_count, + "L3.1 model range points", + ), + sampledPointCount, + pointsXyzi: pointValues, + truthBoxes: [], + predictionBoxes: array( + payload.prediction_boxes, + "L3.1 prediction boxes", + ).map(parseBox), + }; +} diff --git a/apps/control-station/src/core/laboratory/l3PointPillarsVisualAudit.ts b/apps/control-station/src/core/laboratory/l3PointPillarsVisualAudit.ts index 3aaaea1..580a2a0 100644 --- a/apps/control-station/src/core/laboratory/l3PointPillarsVisualAudit.ts +++ b/apps/control-station/src/core/laboratory/l3PointPillarsVisualAudit.ts @@ -21,7 +21,12 @@ export interface L3VisualBox { centerXyzM: readonly [number, number, number]; sizeLwhM: readonly [number, number, number]; yawRad: number; - status: "matched" | "false-negative" | "true-positive" | "false-positive"; + status: + | "matched" + | "false-negative" + | "true-positive" + | "false-positive" + | "model-prediction"; score: number | null; } diff --git a/apps/control-station/src/styles/l3-pointpillars-visual-audit.css b/apps/control-station/src/styles/l3-pointpillars-visual-audit.css index 0ebdf42..11374eb 100644 --- a/apps/control-station/src/styles/l3-pointpillars-visual-audit.css +++ b/apps/control-station/src/styles/l3-pointpillars-visual-audit.css @@ -131,6 +131,10 @@ background: rgb(var(--nodedc-warning-rgb)); } +.l3-visual-audit__legend span[data-tone="prediction"]::before { + background: rgb(var(--nodedc-accent-rgb)); +} + @media (max-width: 900px) { .l3-visual-audit__overlay { grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx b/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx index 23fe812..7d8d675 100644 --- a/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx +++ b/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx @@ -19,6 +19,7 @@ import { E38Result } from "./E38Result"; import { E39Result } from "./E39Result"; import { E40Result } from "./E40Result"; import { L3PointPillarsResult } from "./L3PointPillarsResult"; +import { L31PointPillarsRavnovesResult } from "./L31PointPillarsRavnovesResult"; import { RecordedReplayEvidence } from "./RecordedReplayEvidence"; export { isAdvancedLaboratoryWorkId }; @@ -33,9 +34,13 @@ export function advancedLaboratoryWorkOptions( ): readonly LaboratoryOption[] { const available = new Set(index.map(({ workId }) => workId)); const options: readonly LaboratoryOption[] = [ + { + id: "l31-pointpillars-ravnoves", + label: "L3.1 · PointPillars на RAVNOVES00", + }, { id: "l3-pointpillars-visual-audit", - label: "L3 · визуальный аудит PointPillars", + label: "L3 · KITTI · внешний PointPillars benchmark", }, { id: "e31-source-binding", label: "LAB E31 · source binding" }, { id: "e32-track-geometry", label: "LAB E32 · TrackGeometry v1" }, @@ -90,6 +95,9 @@ export function AdvancedLaboratoryResult({ if (workId === "l3-pointpillars-visual-audit" && results.l3) { return ; } + if (workId === "l31-pointpillars-ravnoves" && results.l31) { + return ; + } if (workId === "e40-perception-product-gate" && results.e40) { return ; } diff --git a/apps/control-station/src/workspaces/laboratory/L31PointPillarsRavnovesResult.tsx b/apps/control-station/src/workspaces/laboratory/L31PointPillarsRavnovesResult.tsx new file mode 100644 index 0000000..c21dc6d --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/L31PointPillarsRavnovesResult.tsx @@ -0,0 +1,141 @@ +import { + LaboratoryEvidence, + LaboratoryResultSummary, + LaboratorySummary, + LaboratoryWorkTemplate, +} from "../../components/laboratory/LaboratoryPresentation"; +import type { + L31PointPillarsRavnovesResult, +} from "../../core/laboratory/l31PointPillarsRavnoves"; +import { L31PointPillarsRavnovesVisual } from "./L31PointPillarsRavnovesVisual"; + +function percent(value: number, digits = 1): string { + return `${(value * 100).toLocaleString("ru-RU", { + maximumFractionDigits: digits, + })}%`; +} + +export function L31PointPillarsRavnovesResult({ + result, +}: { + result: L31PointPillarsRavnovesResult; +}) { + const metrics = result.metrics; + return ( + + )} + evidence={( + + + + )} + result={( + + )} + /> + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/L31PointPillarsRavnovesVisual.tsx b/apps/control-station/src/workspaces/laboratory/L31PointPillarsRavnovesVisual.tsx new file mode 100644 index 0000000..696144f --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/L31PointPillarsRavnovesVisual.tsx @@ -0,0 +1,178 @@ +import { useEffect, useState } from "react"; +import { Icon, IconButton, Select } from "@nodedc/ui-react"; + +import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer"; +import { + fetchL31PointPillarsRavnovesFrame, + type L31PointPillarsRavnovesResult, + type L31VisualFrame, +} from "../../core/laboratory/l31PointPillarsRavnoves"; +import { + L3PointPillarsScene, + type L3VisualMode, +} from "./L3PointPillarsScene"; + +function frameLabel( + frame: L31PointPillarsRavnovesResult["frames"][number], +): string { + return ( + `${frame.sessionSeconds.toLocaleString("ru-RU", { + maximumFractionDigits: 1, + })} с · кадр ${frame.frameId}` + + ` · Vehicle ${frame.classCounts.Vehicle}` + ); +} + +export function L31PointPillarsRavnovesVisual({ + result, +}: { + result: L31PointPillarsRavnovesResult; +}) { + const [selectedFrameId, setSelectedFrameId] = useState( + result.frames[0]?.frameId ?? "", + ); + const [frame, setFrame] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [mode, setMode] = useState("3d"); + const [expanded, setExpanded] = useState(false); + + useEffect(() => { + if (!selectedFrameId) return; + const controller = new AbortController(); + setFrame(null); + setLoading(true); + setError(null); + void fetchL31PointPillarsRavnovesFrame( + result.resultId, + selectedFrameId, + { signal: controller.signal }, + ).then((next) => { + if (!controller.signal.aborted) setFrame(next); + }).catch((caught: unknown) => { + if (controller.signal.aborted) return; + setError( + caught instanceof Error + ? caught.message + : "Визуальный кадр L3.1 недоступен.", + ); + }).finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [result.resultId, selectedFrameId]); + + const selectedIndex = result.frames.findIndex( + ({ frameId }) => frameId === selectedFrameId, + ); + const navigate = (offset: -1 | 1) => { + if (!result.frames.length || selectedIndex < 0) return; + const index = ( + selectedIndex + offset + result.frames.length + ) % result.frames.length; + setSelectedFrameId(result.frames[index].frameId); + }; + + const controls = ( +
+
+ navigate(-1)} + > + + + navigate(1)} + > + + +
+