From b894945344ce0e4bc7dab47abda4e46aba3c76cf Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 27 Jul 2026 17:53:59 +0300 Subject: [PATCH] feat(perception): qualify E35 degradation recovery --- .../src/core/laboratory/advancedResults.ts | 10 +- .../core/laboratory/e35DegradationRecovery.ts | 578 ++++++++ apps/control-station/src/styles.css | 1 + .../src/styles/e35-degradation-recovery.css | 83 ++ .../laboratory/AdvancedLaboratoryResult.tsx | 14 +- .../src/workspaces/laboratory/E35Result.tsx | 340 +++++ .../laboratory/LaboratoryArchiveWorkspace.tsx | 3 +- .../test/advancedLaboratoryResults.test.mjs | 104 +- .../test/laboratoryProductUi.test.mjs | 23 + docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md | 18 +- ...16_ARCHITECTURE_AUDIT_EXECUTION_ROADMAP.md | 11 +- ...-deterministic-degradation-and-recovery.md | 82 ++ .../perception/LAB_E35_REPORT_2026-07-27.md | 153 ++ ...e35_deterministic_degradation_profile.json | 88 ++ .../run_e35_degradation_recovery.py | 51 + src/k1link/compute/degradation_recovery.py | 623 ++++++++ src/k1link/compute/e35_degradation_replay.py | 1283 +++++++++++++++++ src/k1link/web/advanced_laboratory_api.py | 106 ++ src/k1link/web/app.py | 7 + tests/test_advanced_laboratory_api.py | 109 +- tests/test_e35_degradation_recovery.py | 315 ++++ 21 files changed, 3985 insertions(+), 17 deletions(-) create mode 100644 apps/control-station/src/core/laboratory/e35DegradationRecovery.ts create mode 100644 apps/control-station/src/styles/e35-degradation-recovery.css create mode 100644 apps/control-station/src/workspaces/laboratory/E35Result.tsx create mode 100644 docs/adr/0028-e35-deterministic-degradation-and-recovery.md create mode 100644 experiments/perception/LAB_E35_REPORT_2026-07-27.md create mode 100644 experiments/perception/e35_deterministic_degradation_profile.json create mode 100644 experiments/perception/run_e35_degradation_recovery.py create mode 100644 src/k1link/compute/degradation_recovery.py create mode 100644 src/k1link/compute/e35_degradation_replay.py create mode 100644 tests/test_e35_degradation_recovery.py diff --git a/apps/control-station/src/core/laboratory/advancedResults.ts b/apps/control-station/src/core/laboratory/advancedResults.ts index 3c264d2..8d5243a 100644 --- a/apps/control-station/src/core/laboratory/advancedResults.ts +++ b/apps/control-station/src/core/laboratory/advancedResults.ts @@ -94,6 +94,7 @@ export interface AdvancedLaboratoryResults { e32: E32LaboratoryResult | null; e33: E33LaboratoryResult | null; e34: E34TemporalLayerResult | null; + e35: E35DegradationRecoveryResult | null; } export class AdvancedLaboratoryContractError extends Error { @@ -372,15 +373,20 @@ export async function fetchAdvancedLaboratoryResults({ fetcher?: LaboratoryFetch; signal?: AbortSignal; } = {}): Promise { - const [e31, e32, e33, e34] = await Promise.all([ + const [e31, e32, e33, e34, e35] = await Promise.all([ fetchOne("/api/v1/laboratory/e31/results?limit=1", parseE31, fetcher, signal), fetchOne("/api/v1/laboratory/e32/results?limit=1", parseE32, fetcher, signal), fetchOne("/api/v1/laboratory/e33/results?limit=1", parseE33, fetcher, signal), fetchE34TemporalLayerResult({ fetcher, signal }), + fetchE35DegradationRecoveryResult({ fetcher, signal }), ]); - return { e31, e32, e33, e34 }; + return { e31, e32, e33, e34, e35 }; } import { fetchE34TemporalLayerResult, type E34TemporalLayerResult, } from "./e34TemporalLayer"; +import { + fetchE35DegradationRecoveryResult, + type E35DegradationRecoveryResult, +} from "./e35DegradationRecovery"; diff --git a/apps/control-station/src/core/laboratory/e35DegradationRecovery.ts b/apps/control-station/src/core/laboratory/e35DegradationRecovery.ts new file mode 100644 index 0000000..2af850b --- /dev/null +++ b/apps/control-station/src/core/laboratory/e35DegradationRecovery.ts @@ -0,0 +1,578 @@ +import type { + E34OccupancyState, + E34OwnerKind, + E34Point3, + E34TemporalState, +} from "./e34TemporalLayer"; + +export type E35DegradationKind = + | "camera-loss" + | "lidar-loss" + | "pose-staleness" + | "delayed-frames" + | "bounded-drop" + | "timing-offset"; + +export type E35FaultPhase = "before" | "during" | "after"; + +export interface E35ScenarioMetrics { + scenarioId: E35DegradationKind; + kind: E35DegradationKind; + frameStart: number; + frameEnd: number; + framesProcessed: number; + injectedFrames: number; + droppedFrames: number; + hiddenSuccessFrames: number; + falseFreeRows: number; + semanticClaimsDuringCameraLoss: number; + metricRowsDuringLidarOrPoseLoss: number; + agreeClaimsDuringTimingOffset: number; + lateResultsReintroduced: number; + maximumCurrentComponentsDuringFault: number; + maximumHeldComponentsDuringFault: number; + expiredComponentsDuringFault: number; + recoveryFrameIndex: number; + recoverySeconds: number; +} + +export interface E35TemporalComponent { + temporalId: number; + state: E34TemporalState; + occupancyState: E34OccupancyState; + ownerKind: E34OwnerKind; + semanticLabels: readonly string[]; + centroidMapXyzM: E34Point3; + lastObservedAgeSeconds: number; +} + +export interface E35RecoveryReviewFrame { + scenarioId: E35DegradationKind; + kind: E35DegradationKind; + frameIndex: number; + sessionSeconds: number; + faultPhase: E35FaultPhase; + action: string; + channels: Readonly>; + inputPointRows: number; + transformedPointRows: number; + layerState: "current" | "held" | "unknown"; + counts: { + current: number; + held: number; + expired: number; + }; + components: readonly E35TemporalComponent[]; + cellCentersMapXyzM: readonly E34Point3[]; +} + +export interface E35RecoveryReviewScenario { + scenarioId: E35DegradationKind; + kind: E35DegradationKind; + frameStart: number; + frameEnd: number; + frames: readonly E35RecoveryReviewFrame[]; +} + +export interface E35DegradationRecoveryResult { + resultId: string; + createdAtUtc: string | null; + sourceSessionId: string; + status: "accepted-deterministic-degradation-recovery"; + e32ResultId: string; + e33ResultId: string; + e34ResultId: string; + profileId: string; + pipelineId: string; + coordinateFrame: "map"; + configuration: { + maximumRecoverySeconds: number; + scenarioCount: number; + }; + metrics: { + sourceFrames: number; + variantCount: number; + variantFrameOutcomes: number; + injectionRecords: number; + variantFrameProcessingP95Ms: number; + buildElapsedMs: number; + }; + scenarios: readonly E35ScenarioMetrics[]; + reviewScenarios: readonly E35RecoveryReviewScenario[]; + access: "read-only"; +} + +type LaboratoryFetch = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +class E35DegradationRecoveryContractError extends Error { + constructor(message: string) { + super(message); + this.name = "E35DegradationRecoveryContractError"; + } +} + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new E35DegradationRecoveryContractError( + `${label}: ожидался объект.`, + ); + } + return value as Record; +} + +function list(value: unknown, label: string): readonly unknown[] { + if (!Array.isArray(value)) { + throw new E35DegradationRecoveryContractError( + `${label}: ожидался массив.`, + ); + } + return value; +} + +function stringValue(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new E35DegradationRecoveryContractError( + `${label}: ожидалась строка.`, + ); + } + return value; +} + +function optionalString(value: unknown, label: string): string | null { + return value === null ? null : stringValue(value, label); +} + +function numberValue(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + throw new E35DegradationRecoveryContractError( + `${label}: ожидалось неотрицательное число.`, + ); + } + return value; +} + +function integerValue(value: unknown, label: string): number { + const parsed = numberValue(value, label); + if (!Number.isSafeInteger(parsed)) { + throw new E35DegradationRecoveryContractError( + `${label}: ожидалось целое число.`, + ); + } + return parsed; +} + +function exactString( + value: unknown, + expected: T, + label: string, +): T { + if (value !== expected) { + throw new E35DegradationRecoveryContractError( + `${label}: неверное значение.`, + ); + } + return expected; +} + +function oneOf( + value: unknown, + expected: readonly T[], + label: string, +): T { + if (typeof value !== "string" || !expected.includes(value as T)) { + throw new E35DegradationRecoveryContractError( + `${label}: неверное значение.`, + ); + } + return value as T; +} + +function contentId(value: unknown, prefix: string, label: string): string { + const parsed = stringValue(value, label); + if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) { + throw new E35DegradationRecoveryContractError( + `${label}: неверный content id.`, + ); + } + return parsed; +} + +function point(value: unknown, label: string): E34Point3 { + const coordinates = list(value, label); + if (coordinates.length !== 3) { + throw new E35DegradationRecoveryContractError( + `${label}: ожидались XYZ.`, + ); + } + const result = coordinates.map( + (coordinate, index) => { + if (typeof coordinate !== "number" || !Number.isFinite(coordinate)) { + throw new E35DegradationRecoveryContractError( + `${label}[${index}]: ожидалось число.`, + ); + } + return coordinate; + }, + ); + return [result[0]!, result[1]!, result[2]!]; +} + +const DEGRADATION_KINDS = [ + "camera-loss", + "lidar-loss", + "pose-staleness", + "delayed-frames", + "bounded-drop", + "timing-offset", +] as const; + +function degradationKind( + value: unknown, + label: string, +): E35DegradationKind { + return oneOf(value, DEGRADATION_KINDS, label); +} + +function parseComponent( + value: unknown, + label: string, +): E35TemporalComponent { + const item = record(value, label); + return { + temporalId: integerValue(item.temporal_id, `${label}.temporal_id`), + state: oneOf( + item.state, + ["current", "held", "expired"] as const, + `${label}.state`, + ), + occupancyState: oneOf( + item.occupancy_state, + ["occupied", "unknown"] as const, + `${label}.occupancy_state`, + ), + ownerKind: oneOf( + item.owner_kind, + ["camera-track", "geometry-cluster"] as const, + `${label}.owner_kind`, + ), + semanticLabels: list( + item.semantic_labels, + `${label}.semantic_labels`, + ).map((entry, index) => stringValue( + entry, + `${label}.semantic_labels[${index}]`, + )), + centroidMapXyzM: point( + item.centroid_map_xyz_m, + `${label}.centroid_map_xyz_m`, + ), + lastObservedAgeSeconds: numberValue( + item.last_observed_age_seconds, + `${label}.last_observed_age_seconds`, + ), + }; +} + +function parseReviewFrame( + value: unknown, + label: string, +): E35RecoveryReviewFrame { + const item = record(value, label); + const counts = record(item.counts, `${label}.counts`); + const channels = record(item.channels, `${label}.channels`); + return { + scenarioId: degradationKind( + item.scenario_id, + `${label}.scenario_id`, + ), + kind: degradationKind(item.kind, `${label}.kind`), + frameIndex: integerValue(item.frame_index, `${label}.frame_index`), + sessionSeconds: numberValue( + item.session_seconds, + `${label}.session_seconds`, + ), + faultPhase: oneOf( + item.fault_phase, + ["before", "during", "after"] as const, + `${label}.fault_phase`, + ), + action: stringValue(item.action, `${label}.action`), + channels: Object.fromEntries( + Object.entries(channels).map(([key, entry]) => [ + key, + stringValue(entry, `${label}.channels.${key}`), + ]), + ), + inputPointRows: integerValue( + item.input_point_rows, + `${label}.input_point_rows`, + ), + transformedPointRows: integerValue( + item.transformed_point_rows, + `${label}.transformed_point_rows`, + ), + layerState: oneOf( + item.layer_state, + ["current", "held", "unknown"] as const, + `${label}.layer_state`, + ), + counts: { + current: integerValue(counts.current, `${label}.counts.current`), + held: integerValue(counts.held, `${label}.counts.held`), + expired: integerValue(counts.expired, `${label}.counts.expired`), + }, + components: list(item.components, `${label}.components`).map( + (entry, index) => parseComponent( + entry, + `${label}.components[${index}]`, + ), + ), + cellCentersMapXyzM: list( + item.cell_centers_map_xyz_m, + `${label}.cell_centers_map_xyz_m`, + ).map((entry, index) => point( + entry, + `${label}.cell_centers_map_xyz_m[${index}]`, + )), + }; +} + +function parseReviewScenario( + value: unknown, + label: string, +): E35RecoveryReviewScenario { + const item = record(value, label); + const scenario = record(item.scenario, `${label}.scenario`); + return { + scenarioId: degradationKind( + scenario.scenario_id, + `${label}.scenario.scenario_id`, + ), + kind: degradationKind( + scenario.kind, + `${label}.scenario.kind`, + ), + frameStart: integerValue( + scenario.frame_start, + `${label}.scenario.frame_start`, + ), + frameEnd: integerValue( + scenario.frame_end, + `${label}.scenario.frame_end`, + ), + frames: list(item.frames, `${label}.frames`).map( + (entry, index) => parseReviewFrame( + entry, + `${label}.frames[${index}]`, + ), + ), + }; +} + +function parseScenarioMetrics( + value: unknown, + label: string, +): E35ScenarioMetrics { + const item = record(value, label); + const integer = (key: string) => integerValue(item[key], `${label}.${key}`); + return { + scenarioId: degradationKind(item.scenario_id, `${label}.scenario_id`), + kind: degradationKind(item.kind, `${label}.kind`), + frameStart: integer("frame_start"), + frameEnd: integer("frame_end"), + framesProcessed: integer("frames_processed"), + injectedFrames: integer("injected_frames"), + droppedFrames: integer("dropped_frames"), + hiddenSuccessFrames: integer("hidden_success_frames"), + falseFreeRows: integer("false_free_rows"), + semanticClaimsDuringCameraLoss: integer( + "semantic_claims_during_camera_loss", + ), + metricRowsDuringLidarOrPoseLoss: integer( + "metric_rows_during_lidar_or_pose_loss", + ), + agreeClaimsDuringTimingOffset: integer( + "agree_claims_during_timing_offset", + ), + lateResultsReintroduced: integer("late_results_reintroduced"), + maximumCurrentComponentsDuringFault: integer( + "maximum_current_components_during_fault", + ), + maximumHeldComponentsDuringFault: integer( + "maximum_held_components_during_fault", + ), + expiredComponentsDuringFault: integer( + "expired_components_during_fault", + ), + recoveryFrameIndex: integer("recovery_frame_index"), + recoverySeconds: numberValue( + item.recovery_seconds, + `${label}.recovery_seconds`, + ), + }; +} + +function diagnosticAuthority(value: unknown, label: string): void { + const authority = record(value, label); + if ( + authority.commands_enabled !== false + || authority.navigation_or_safety_accepted !== false + ) { + throw new E35DegradationRecoveryContractError( + `${label}: запрещённые полномочия.`, + ); + } +} + +function parseResult(value: unknown): E35DegradationRecoveryResult { + const item = record(value, "E35"); + const configuration = record(item.configuration, "E35.configuration"); + const metrics = record(item.metrics, "E35.metrics"); + const review = record(item.review, "E35.review"); + const acceptance = record(item.acceptance, "E35.acceptance"); + diagnosticAuthority(item.authority, "E35.authority"); + if (acceptance.accepted !== true) { + throw new E35DegradationRecoveryContractError( + "E35.acceptance: результат отклонён.", + ); + } + exactString( + review.schema_version, + "missioncore.e35-recovery-review/v1", + "E35.review.schema_version", + ); + return { + resultId: contentId( + item.result_id, + "e35-degradation-recovery", + "E35.result_id", + ), + createdAtUtc: optionalString(item.created_at_utc, "E35.created_at_utc"), + sourceSessionId: stringValue( + item.source_session_id, + "E35.source_session_id", + ), + status: exactString( + item.status, + "accepted-deterministic-degradation-recovery", + "E35.status", + ), + e32ResultId: contentId( + item.e32_result_id, + "e32-track-geometry", + "E35.e32_result_id", + ), + e33ResultId: contentId( + item.e33_result_id, + "e33-worker-shadow", + "E35.e33_result_id", + ), + e34ResultId: contentId( + item.e34_result_id, + "e34-temporal-occupied", + "E35.e34_result_id", + ), + profileId: stringValue(item.profile_id, "E35.profile_id"), + pipelineId: stringValue(item.pipeline_id, "E35.pipeline_id"), + coordinateFrame: exactString( + item.coordinate_frame, + "map", + "E35.coordinate_frame", + ), + configuration: { + maximumRecoverySeconds: numberValue( + configuration.maximum_recovery_seconds, + "E35.configuration.maximum_recovery_seconds", + ), + scenarioCount: integerValue( + configuration.scenario_count, + "E35.configuration.scenario_count", + ), + }, + metrics: { + sourceFrames: integerValue( + metrics.source_frames, + "E35.metrics.source_frames", + ), + variantCount: integerValue( + metrics.variant_count, + "E35.metrics.variant_count", + ), + variantFrameOutcomes: integerValue( + metrics.variant_frame_outcomes, + "E35.metrics.variant_frame_outcomes", + ), + injectionRecords: integerValue( + metrics.injection_records, + "E35.metrics.injection_records", + ), + variantFrameProcessingP95Ms: numberValue( + metrics.variant_frame_processing_p95_ms, + "E35.metrics.variant_frame_processing_p95_ms", + ), + buildElapsedMs: numberValue( + metrics.build_elapsed_ms, + "E35.metrics.build_elapsed_ms", + ), + }, + scenarios: list(item.scenarios, "E35.scenarios").map( + (entry, index) => parseScenarioMetrics( + entry, + `E35.scenarios[${index}]`, + ), + ), + reviewScenarios: list(review.scenarios, "E35.review.scenarios").map( + (entry, index) => parseReviewScenario( + entry, + `E35.review.scenarios[${index}]`, + ), + ), + access: exactString(item.access, "read-only", "E35.access"), + }; +} + +export async function fetchE35DegradationRecoveryResult({ + fetcher = fetch, + signal, +}: { + fetcher?: LaboratoryFetch; + signal?: AbortSignal; +} = {}): Promise { + const response = await fetcher( + "/api/v1/laboratory/e35/results?limit=1", + { + method: "GET", + headers: { Accept: "application/json" }, + signal, + }, + ); + if (!response.ok) { + throw new E35DegradationRecoveryContractError( + `Каталог LAB E35 недоступен: HTTP ${response.status}.`, + ); + } + const catalog = record(await response.json(), "Каталог LAB E35"); + exactString( + catalog.schema_version, + "missioncore.laboratory-advanced-catalog/v1", + "Каталог LAB E35.schema_version", + ); + if (typeof catalog.configured !== "boolean") { + throw new E35DegradationRecoveryContractError( + "Каталог LAB E35.configured: ожидался boolean.", + ); + } + integerValue(catalog.candidate_total, "Каталог LAB E35.candidate_total"); + integerValue(catalog.invalid_total, "Каталог LAB E35.invalid_total"); + exactString(catalog.access, "read-only", "Каталог LAB E35.access"); + const items = list(catalog.items, "Каталог LAB E35.items"); + if (items.length > 1) { + throw new E35DegradationRecoveryContractError( + "Каталог LAB E35: нарушен limit=1.", + ); + } + return items.length ? parseResult(items[0]) : null; +} diff --git a/apps/control-station/src/styles.css b/apps/control-station/src/styles.css index ffcf1e9..cb5c4b1 100644 --- a/apps/control-station/src/styles.css +++ b/apps/control-station/src/styles.css @@ -4,6 +4,7 @@ @import "./styles/laboratory.css"; @import "./styles/laboratory-reporting.css"; @import "./styles/e34-temporal-layer.css"; +@import "./styles/e35-degradation-recovery.css"; @import "./styles/e30-human-review.css"; @import "./styles/spatial.css"; @import "./styles/device.css"; diff --git a/apps/control-station/src/styles/e35-degradation-recovery.css b/apps/control-station/src/styles/e35-degradation-recovery.css new file mode 100644 index 0000000..0189d61 --- /dev/null +++ b/apps/control-station/src/styles/e35-degradation-recovery.css @@ -0,0 +1,83 @@ +.e35-degradation-evidence { + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; +} + +.e35-degradation-evidence__selectors { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.e35-degradation-evidence__selectors .nodedc-select-anchor { + width: clamp(10rem, 20vw, 15rem); +} + +.e35-degradation-evidence__timeline { + position: absolute; + z-index: 3; + top: 3.7rem; + right: 0.6rem; + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.3rem; + max-width: calc(100% - 1.2rem); +} + +.e35-degradation-evidence__timeline .nodedc-button { + background: var(--nodedc-floating-surface); + backdrop-filter: blur(var(--nodedc-blur-control)); +} + +.e35-degradation-evidence__telemetry { + position: absolute; + z-index: 3; + left: 0.6rem; + bottom: 0.6rem; + display: grid; + width: min(19rem, calc(100% - 1.2rem)); + gap: 0.34rem; + margin: 0; + border-radius: var(--nodedc-radius-control-compact); + background: var(--nodedc-floating-surface); + padding: 0.55rem 0.65rem; + color: var(--nodedc-text-secondary); + pointer-events: none; + backdrop-filter: blur(var(--nodedc-blur-control)); +} + +.e35-degradation-evidence__telemetry > div { + display: grid; + gap: 0.1rem; +} + +.e35-degradation-evidence__telemetry dt, +.e35-degradation-evidence__telemetry dd { + margin: 0; + font-size: 0.5rem; +} + +.e35-degradation-evidence__telemetry dt { + color: var(--nodedc-text-muted); +} + +.e35-degradation-evidence__telemetry dd { + overflow: hidden; + color: var(--nodedc-text-primary); + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 900px) { + .e35-degradation-evidence__timeline { + top: 4rem; + } + + .e35-degradation-evidence__timeline .nodedc-button { + padding-inline: 0.55rem; + } +} diff --git a/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx b/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx index 6e6f85c..73b0f44 100644 --- a/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx +++ b/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx @@ -8,13 +8,15 @@ import { E31Result } from "./E31Result"; import { E32Result } from "./E32Result"; import { E33Result } from "./E33Result"; import { E34Result } from "./E34Result"; +import { E35Result } from "./E35Result"; import { RecordedReplayEvidence } from "./RecordedReplayEvidence"; export type AdvancedLaboratoryWorkId = | "e31-source-binding" | "e32-track-geometry" | "e33-worker-shadow" - | "e34-temporal-layer"; + | "e34-temporal-layer" + | "e35-degradation-recovery"; type LaboratoryWorkspaceProps = WorkspaceRendererProps & { SpatialView: ComponentType; @@ -28,6 +30,7 @@ export function isAdvancedLaboratoryWorkId( || value === "e32-track-geometry" || value === "e33-worker-shadow" || value === "e34-temporal-layer" + || value === "e35-degradation-recovery" ); } @@ -60,6 +63,12 @@ export function advancedLaboratoryWorkOptions( label: "LAB E34 · temporal occupied/unknown", }); } + if (results.e35) { + options.push({ + id: "e35-degradation-recovery", + label: "LAB E35 · degradation recovery", + }); + } return options; } @@ -97,6 +106,9 @@ export function AdvancedLaboratoryResult({ failedSessionId: string | null; replayError: string | null; }) { + if (workId === "e35-degradation-recovery" && results.e35) { + return ; + } if (workId === "e34-temporal-layer" && results.e34) { return ; } diff --git a/apps/control-station/src/workspaces/laboratory/E35Result.tsx b/apps/control-station/src/workspaces/laboratory/E35Result.tsx new file mode 100644 index 0000000..a4547ad --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/E35Result.tsx @@ -0,0 +1,340 @@ +import { useMemo, useState } from "react"; +import { Button, Select } from "@nodedc/ui-react"; + +import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer"; +import { + LaboratoryEvidence, + LaboratoryResultSummary, + LaboratorySummary, + LaboratoryWorkTemplate, +} from "../../components/laboratory/LaboratoryPresentation"; +import type { + E35DegradationKind, + E35DegradationRecoveryResult, + E35RecoveryReviewFrame, + E35RecoveryReviewScenario, +} from "../../core/laboratory/e35DegradationRecovery"; +import type { E34TemporalReviewFrame } from "../../core/laboratory/e34TemporalLayer"; +import { formatNumber } from "../../presentation"; +import { + E34TemporalLayerScene, + type E34TemporalViewMode, +} from "./E34TemporalLayerScene"; + +const SCENARIO_LABELS: Record = { + "camera-loss": "Потеря камеры", + "lidar-loss": "Потеря LiDAR", + "pose-staleness": "Устаревшая поза", + "delayed-frames": "Просроченные кадры", + "bounded-drop": "Ограниченные пропуски", + "timing-offset": "Рассинхрон 250 мс", +}; + +const PHASE_LABELS = { + before: "До отказа", + during: "Во время отказа", + after: "После восстановления", +} as const; + +function sceneFrame(frame: E35RecoveryReviewFrame): E34TemporalReviewFrame { + return { + frameIndex: frame.frameIndex, + sessionSeconds: frame.sessionSeconds, + sourceAvailable: frame.transformedPointRows > 0, + layerState: frame.layerState, + counts: frame.counts, + cellCentersMapXyzM: frame.cellCentersMapXyzM, + components: frame.components.map((component) => ({ + temporalId: component.temporalId, + state: component.state, + occupancyState: component.occupancyState, + ownerKind: component.ownerKind, + centroidMapXyzM: component.centroidMapXyzM, + lastObservedAgeSeconds: component.lastObservedAgeSeconds, + associationReason: frame.action, + history: [], + })), + }; +} + +function channelSummary(frame: E35RecoveryReviewFrame): string { + return Object.entries(frame.channels) + .map(([channel, state]) => `${channel}: ${state}`) + .join(" · "); +} + +function unsafeClaims( + result: E35DegradationRecoveryResult, +): number { + return result.scenarios.reduce((total, scenario) => ( + total + + scenario.hiddenSuccessFrames + + scenario.falseFreeRows + + scenario.semanticClaimsDuringCameraLoss + + scenario.metricRowsDuringLidarOrPoseLoss + + scenario.agreeClaimsDuringTimingOffset + + scenario.lateResultsReintroduced + ), 0); +} + +function maximumRecovery( + result: E35DegradationRecoveryResult, +): number { + return Math.max(...result.scenarios.map( + (scenario) => scenario.recoverySeconds, + )); +} + +function defaultFrame( + scenario: E35RecoveryReviewScenario, +): E35RecoveryReviewFrame | null { + return scenario.frames.find((frame) => ( + frame.faultPhase === "during" + && (frame.counts.held > 0 || frame.counts.expired > 0) + )) ?? scenario.frames[0] ?? null; +} + +function E35Evidence({ + result, +}: { + result: E35DegradationRecoveryResult; +}) { + const initialScenario = result.reviewScenarios[0] ?? null; + const [scenarioId, setScenarioId] = useState( + initialScenario?.scenarioId ?? "camera-loss", + ); + const scenario = result.reviewScenarios.find( + (item) => item.scenarioId === scenarioId, + ) ?? initialScenario; + const initialFrame = scenario ? defaultFrame(scenario) : null; + const [frameIndex, setFrameIndex] = useState(initialFrame?.frameIndex ?? 0); + const frame = scenario?.frames.find( + (item) => item.frameIndex === frameIndex, + ) ?? initialFrame; + const [mode, setMode] = useState("3d"); + const [expanded, setExpanded] = useState(false); + const renderedFrame = useMemo( + () => frame ? sceneFrame(frame) : null, + [frame], + ); + + if (!scenario || !frame || !renderedFrame) { + return ( +
+ Контрольные состояния деградации не опубликованы. +
+ ); + } + + const selectScenario = (value: string) => { + const nextId = value as E35DegradationKind; + const nextScenario = result.reviewScenarios.find( + (item) => item.scenarioId === nextId, + ); + setScenarioId(nextId); + setFrameIndex(defaultFrame(nextScenario ?? scenario)?.frameIndex ?? 0); + }; + + return ( +
+ +