diff --git a/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx b/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx index 99bcb4b..6d333fe 100644 --- a/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx +++ b/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx @@ -26,6 +26,7 @@ export interface LaboratoryMetricObstacleVisual { state: "current" | "retained" | "held" | "expired"; centroidBodyXyzM: LaboratoryMetricPoint3; cellCentersBodyXyzM: readonly LaboratoryMetricPoint3[]; + occupancySource?: "baseline" | "mixed" | "additive-low-step"; } export interface LaboratoryMetricRigVisual { @@ -41,7 +42,7 @@ export interface LaboratoryMetricCorridorVisual { } export interface LaboratoryMetricLegendEntry { - id: LaboratoryMetricDecision | "context" | "local-surface" | "rolling"; + id: LaboratoryMetricDecision | "context" | "local-surface" | "rolling" | "low-step"; label: string; } @@ -52,6 +53,7 @@ export function laboratoryMetricLegendEntries({ showCurrentIncrement, showLocalSurface, showRollingMap, + showLowStep = true, }: { pointCloudCount: number; localSurfaceCount: number; @@ -59,19 +61,29 @@ export function laboratoryMetricLegendEntries({ showCurrentIncrement: boolean; showLocalSurface: boolean; showRollingMap: boolean; + showLowStep?: boolean; }): readonly LaboratoryMetricLegendEntry[] { const visibleObstacles = obstacles.filter((obstacle) => ( - obstacle.state === "current" + (showLowStep || obstacle.occupancySource === undefined || obstacle.occupancySource === "baseline") + && (obstacle.state === "current" ? showCurrentIncrement : obstacle.state === "retained" ? showRollingMap - : false + : false) )); const decisions = new Set(visibleObstacles.map(({ decision }) => decision)); const entries: LaboratoryMetricLegendEntry[] = []; if (decisions.has("threat")) entries.push({ id: "threat", label: "Угроза" }); if (decisions.has("not-threat")) entries.push({ id: "not-threat", label: "Вне коридора" }); if (decisions.has("unknown")) entries.push({ id: "unknown", label: "Неизвестно" }); + if ( + showLowStep + && visibleObstacles.some((obstacle) => ( + obstacle.occupancySource !== undefined && obstacle.occupancySource !== "baseline" + )) + ) { + entries.push({ id: "low-step", label: "LOW-STEP · добавлено системой" }); + } if (showCurrentIncrement && pointCloudCount > 0) { entries.push({ id: "context", label: "Текущий кадр" }); } @@ -153,6 +165,16 @@ function decisionColor( return tokenColor(host, "--nodedc-warning-rgb", [255, 197, 92]); } +function obstacleColor( + host: HTMLElement, + obstacle: LaboratoryMetricObstacleVisual, +): THREE.Color { + if (obstacle.occupancySource !== "baseline") { + return tokenColor(host, "--nodedc-accent-rgb", [232, 56, 126]); + } + return decisionColor(host, obstacle.decision); +} + export interface LaboratoryMetricEvidenceSceneHandle { resetView: () => void; } @@ -171,6 +193,7 @@ LaboratoryMetricEvidenceSceneHandle, showCurrentIncrement: boolean; showLocalSurface: boolean; showRollingMap: boolean; + showLowStep?: boolean; pointSemanticClassIds?: readonly (number | null)[]; semanticClasses?: readonly RecordedEvidenceSemanticClass[]; semanticPalette?: readonly RecordedEvidenceSemanticPaletteEntry[]; @@ -187,6 +210,7 @@ LaboratoryMetricEvidenceSceneHandle, showCurrentIncrement, showLocalSurface, showRollingMap, + showLowStep = true, pointSemanticClassIds, semanticClasses, semanticPalette, @@ -352,6 +376,12 @@ LaboratoryMetricEvidenceSceneHandle, for (const obstacle of obstacles) { if ( + ( + !showLowStep + && obstacle.occupancySource !== undefined + && obstacle.occupancySource !== "baseline" + ) + || (obstacle.state === "current" && !showCurrentIncrement) || (obstacle.state === "retained" && !showRollingMap) || obstacle.state === "held" @@ -359,7 +389,7 @@ LaboratoryMetricEvidenceSceneHandle, ) { continue; } - const color = decisionColor(host, obstacle.decision); + const color = obstacleColor(host, obstacle); if (obstacle.state === "retained") { const geometry = new THREE.BoxGeometry( occupiedVoxelSizeM * 0.82, @@ -424,6 +454,7 @@ LaboratoryMetricEvidenceSceneHandle, showCurrentIncrement, showLocalSurface, showRollingMap, + showLowStep, ]); useEffect(() => { @@ -538,6 +569,7 @@ LaboratoryMetricEvidenceSceneHandle, showCurrentIncrement, showLocalSurface, showRollingMap, + showLowStep, }); return ( diff --git a/apps/control-station/src/core/laboratory/advancedIndex.ts b/apps/control-station/src/core/laboratory/advancedIndex.ts index 2b8ac35..a13fcf2 100644 --- a/apps/control-station/src/core/laboratory/advancedIndex.ts +++ b/apps/control-station/src/core/laboratory/advancedIndex.ts @@ -43,6 +43,7 @@ import { } from "./m48ObjectCentricQuality"; import { fetchM48SmallStaticRegression } from "./m48SmallStaticRegression"; import { fetchM48StaticOccupancyQualification } from "./m48StaticOccupancyQualification"; +import { fetchM48R3StaticOccupancyShadow } from "./m48r3StaticOccupancyShadow"; import { fetchM48SFixedClassDetectorResult } from "./m48sFixedClassDetector"; import { fetchM48TRiskQualityResult } from "./m48tRiskQuality"; @@ -50,6 +51,7 @@ export type AdvancedLaboratoryWorkId = | "m48-object-centric-quality" | "m48-small-static-passage-regression" | "m48-static-occupancy-qualification" + | "m48r3-static-occupancy-shadow" | "m48s-fixed-class-detector" | "m48t-risk-quality-temporal" | "m47-reference-graph-shadow" @@ -97,6 +99,7 @@ const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [ "m48-object-centric-quality", "m48-small-static-passage-regression", "m48-static-occupancy-qualification", + "m48r3-static-occupancy-shadow", "m48s-fixed-class-detector", "m48t-risk-quality-temporal", "m47-reference-graph-shadow", @@ -139,6 +142,7 @@ const RESULT_PREFIX: Readonly> = { "m48-object-centric-quality": "m48-object-quality-(?:pack|result)", "m48-small-static-passage-regression": "m48-small-static-passage-regression", "m48-static-occupancy-qualification": "m48-static-occupancy-qualification", + "m48r3-static-occupancy-shadow": "m48r3-static-occupancy-shadow", "m48s-fixed-class-detector": "m48s-fixed-class-detector-lab", "m48t-risk-quality-temporal": "(?:m48t-risk-quality-temporal-lab|m48q-native-risk-quality-lab)", "m47-reference-graph-shadow": "m47-reference-graph-lab", @@ -189,6 +193,7 @@ export function emptyAdvancedLaboratoryResults(): AdvancedLaboratoryResults { m48: null, m48SmallStatic: null, m48StaticOccupancy: null, + m48r3StaticOccupancy: null, m48s: null, m48t: null, m4Threat: null, @@ -318,6 +323,7 @@ export function advancedLaboratoryResultAvailable( return workId === "m48-object-centric-quality" ? results.m48 !== null : workId === "m48-small-static-passage-regression" ? results.m48SmallStatic !== null : workId === "m48-static-occupancy-qualification" ? results.m48StaticOccupancy !== null + : workId === "m48r3-static-occupancy-shadow" ? results.m48r3StaticOccupancy !== null : workId === "m48s-fixed-class-detector" ? results.m48s !== null : workId === "m48t-risk-quality-temporal" ? results.m48t !== null : workId === "m47-reference-graph-shadow" ? results.m47Graph !== null @@ -378,6 +384,9 @@ export async function fetchAdvancedLaboratoryResult( } else if (workId === "m48-static-occupancy-qualification") { if (!resultId) throw new AdvancedLaboratoryContractError("M4.8R2 qualification identity не выбрана."); results.m48StaticOccupancy = await fetchM48StaticOccupancyQualification(resultId, { fetcher, signal }); + } else if (workId === "m48r3-static-occupancy-shadow") { + if (!resultId) throw new AdvancedLaboratoryContractError("M4.8R3 Worker shadow identity не выбрана."); + results.m48r3StaticOccupancy = await fetchM48R3StaticOccupancyShadow(resultId, { fetcher, signal }); } else if (workId === "m48s-fixed-class-detector") { if (!resultId) throw new AdvancedLaboratoryContractError("M4.8S LAB identity не выбрана."); results.m48s = await fetchM48SFixedClassDetectorResult(resultId, { fetcher, signal }); diff --git a/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts b/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts index 92bd08e..28a1a59 100644 --- a/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts +++ b/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts @@ -37,6 +37,7 @@ import type { M47ReferenceGraphLabResult } from "./m47ReferenceGraph"; import type { M48AdvancedResult } from "./m48ObjectCentricQuality"; import type { M48SmallStaticRegressionResult } from "./m48SmallStaticRegression"; import type { M48StaticOccupancyQualificationResult } from "./m48StaticOccupancyQualification"; +import type { M48R3StaticOccupancyShadowResult } from "./m48r3StaticOccupancyShadow"; import type { M48SFixedClassDetectorResult } from "./m48sFixedClassDetector"; import type { M48TRiskQualityResult } from "./m48tRiskQuality"; @@ -45,6 +46,7 @@ export interface AdvancedLaboratoryResults { m48: M48AdvancedResult | null; m48SmallStatic: M48SmallStaticRegressionResult | null; m48StaticOccupancy: M48StaticOccupancyQualificationResult | null; + m48r3StaticOccupancy: M48R3StaticOccupancyShadowResult | null; m48s: M48SFixedClassDetectorResult | null; m48t: M48TRiskQualityResult | null; m4Threat: M4ThreatReplayResult | null; diff --git a/apps/control-station/src/core/laboratory/advancedResults.ts b/apps/control-station/src/core/laboratory/advancedResults.ts index b0d7673..6b7d7fe 100644 --- a/apps/control-station/src/core/laboratory/advancedResults.ts +++ b/apps/control-station/src/core/laboratory/advancedResults.ts @@ -968,6 +968,7 @@ export async function fetchAdvancedLaboratoryResults({ const e40 = settledCatalogValue(settled[8]); return { m47Graph: null, m48: null, m48SmallStatic: null, m48StaticOccupancy: null, + m48r3StaticOccupancy: null, m48s: null, m48t: null, m4Threat: null, l3: null, l31: null, l32: null, l33: null, e31, diff --git a/apps/control-station/src/core/laboratory/m48r3StaticOccupancyShadow.ts b/apps/control-station/src/core/laboratory/m48r3StaticOccupancyShadow.ts new file mode 100644 index 0000000..ef5ff3f --- /dev/null +++ b/apps/control-station/src/core/laboratory/m48r3StaticOccupancyShadow.ts @@ -0,0 +1,343 @@ +import type { LaboratoryFetch } from "./advancedResults"; +import type { M48Authority } from "./m48ObjectCentricQuality"; + +const RESULT_ID = /^m48r3-static-occupancy-shadow-[a-f0-9]{64}$/; + +interface M48R3WorkerPerformance { + admittedFrames: number; + deliveredFrames: number; + fps: number; + worldP95Ms: number; + worldP99Ms: number; + geometryP95Ms: number; + geometryP99Ms: number; +} + +export interface M48R3StaticOccupancyShadowResult { + resultId: string; + createdAtUtc: string; + accepted: boolean; + profile: { + id: string; + sha256: string; + minimumPoints: number; + }; + metrics: { + frames: { expected: number; baselineDelivered: number; candidateDelivered: number }; + performance: { + baseline: M48R3WorkerPerformance; + candidate: M48R3WorkerPerformance; + fpsRegressionFraction: number; + worldStateP95DeltaMs: number; + }; + occupancy: { + baselineCellTotal: number; + candidateCellTotal: number; + addedCellTotal: number; + lostCellTotal: number; + baselineComponentTotal: number; + candidateComponentTotal: number; + meanCellGrowthFraction: number; + meanComponentGrowthFraction: number; + maximumAddedCellsPerFrame: number; + maximumCandidateComponentsPerFrame: number; + }; + provider: { + additiveObservationCount: number; + additiveVoxelCount: number; + framesWithAdditions: number; + additiveMeanMsPerFrame: number; + peakTemporalComponents: number; + peakRollingCells: number; + }; + anchors: { + count: number; + criticalNearCount: number; + criticalNearRecall: number; + matchedCount: number; + canonicalEngineeringRecall: number; + separation: readonly { + displayFrame: number; + expectedMinimumComponents: number; + observedComponents: number; + passed: boolean; + interpretation: string; + }[]; + }; + capacityDropCount: number; + falseFreeCount: number; + }; + gates: Readonly>; + decision: { + state: "accepted-bounded-worker-shadow" | "rejected-bounded-worker-shadow"; + candidateAccepted: boolean; + productionAccepted: false; + nextAction: string; + }; + limitations: readonly string[]; + authority: M48Authority; +} + +export interface M48R3StaticOccupancyCase { + anchorId: string; + displayFrame: number; + sourceSequence: number; + extentXyxy: readonly [number, number, number, number]; + distanceBand: "critical-near" | "approach" | "outside-qualified-bands"; + componentCount: number; + matched: boolean; +} + +export class M48R3StaticOccupancyContractError extends Error {} + +function objectValue(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new M48R3StaticOccupancyContractError(`${label}: ожидался объект.`); + } + return value as Record; +} + +function text(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new M48R3StaticOccupancyContractError(`${label}: ожидалась строка.`); + } + return value; +} + +function numberValue(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new M48R3StaticOccupancyContractError(`${label}: ожидалось число.`); + } + return value; +} + +function integer(value: unknown, label: string): number { + const parsed = numberValue(value, label); + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new M48R3StaticOccupancyContractError(`${label}: ожидалось целое число.`); + } + return parsed; +} + +function bool(value: unknown, label: string): boolean { + if (typeof value !== "boolean") { + throw new M48R3StaticOccupancyContractError(`${label}: ожидался флаг.`); + } + return value; +} + +function exact(value: unknown, expected: string | boolean, label: string): void { + if (value !== expected) { + throw new M48R3StaticOccupancyContractError(`${label}: нарушен контракт.`); + } +} + +function sha256(value: unknown, label: string): string { + const parsed = text(value, label); + if (!/^[a-f0-9]{64}$/.test(parsed)) { + throw new M48R3StaticOccupancyContractError(`${label}: неверный SHA-256.`); + } + return parsed; +} + +function extent(value: unknown): readonly [number, number, number, number] { + if (!Array.isArray(value) || value.length !== 4) { + throw new M48R3StaticOccupancyContractError("M4.8R3 extent: нарушен контракт."); + } + const parsed = value.map((item) => numberValue(item, "M4.8R3 extent")); + return [parsed[0]!, parsed[1]!, parsed[2]!, parsed[3]!]; +} + +function authority(value: unknown): M48Authority { + const row = objectValue(value, "M4.8R3 authority"); + exact(row.mode, "replay-simulated", "M4.8R3 authority.mode"); + exact(row.physical_live, false, "M4.8R3 authority.physical_live"); + exact(row.commands_enabled, false, "M4.8R3 authority.commands_enabled"); + exact(row.actuation_allowed, false, "M4.8R3 authority.actuation_allowed"); + exact( + row.navigation_or_safety_accepted, + false, + "M4.8R3 authority.navigation_or_safety_accepted", + ); + return { + mode: "replay-simulated", + physicalLive: false, + commandsEnabled: false, + actuationAllowed: false, + navigationOrSafetyAccepted: false, + }; +} + +function workerPerformance(value: unknown, label: string): M48R3WorkerPerformance { + const row = objectValue(value, label); + return { + admittedFrames: integer(row.admitted_frames, `${label}.admitted_frames`), + deliveredFrames: integer(row.delivered_frames, `${label}.delivered_frames`), + fps: numberValue(row.fps, `${label}.fps`), + worldP95Ms: numberValue(row.world_p95_ms, `${label}.world_p95_ms`), + worldP99Ms: numberValue(row.world_p99_ms, `${label}.world_p99_ms`), + geometryP95Ms: numberValue(row.geometry_p95_ms, `${label}.geometry_p95_ms`), + geometryP99Ms: numberValue(row.geometry_p99_ms, `${label}.geometry_p99_ms`), + }; +} + +export async function fetchM48R3StaticOccupancyShadow( + resultId: string, + { fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}, +): Promise { + if (!RESULT_ID.test(resultId)) { + throw new M48R3StaticOccupancyContractError("M4.8R3 identity недопустима."); + } + const response = await fetcher( + `/api/v1/laboratory/m48r3/static-occupancy/${encodeURIComponent(resultId)}`, + { method: "GET", headers: { Accept: "application/json" }, signal }, + ); + if (!response.ok) { + throw new M48R3StaticOccupancyContractError(`M4.8R3 недоступен: HTTP ${response.status}.`); + } + const payload = objectValue(await response.json(), "M4.8R3"); + exact( + payload.schema_version, + "missioncore.m48r3-static-occupancy-shadow-view/v1", + "M4.8R3 schema", + ); + exact(payload.result_id, resultId, "M4.8R3 result"); + exact(payload.ground_truth, false, "M4.8R3 ground truth"); + const profile = objectValue(payload.profile, "M4.8R3 profile"); + const componentization = objectValue(profile.componentization, "M4.8R3 componentization"); + const metrics = objectValue(payload.metrics, "M4.8R3 metrics"); + const frames = objectValue(metrics.frames, "M4.8R3 frames"); + const performance = objectValue(metrics.performance, "M4.8R3 performance"); + const occupancy = objectValue(metrics.occupancy, "M4.8R3 occupancy"); + const provider = objectValue(metrics.provider, "M4.8R3 provider"); + const anchors = objectValue(metrics.assisted_anchors, "M4.8R3 anchors"); + if (!Array.isArray(anchors.separation)) { + throw new M48R3StaticOccupancyContractError("M4.8R3 separation: ожидался массив."); + } + const gates = objectValue(payload.gates, "M4.8R3 gates"); + const parsedGates = Object.fromEntries( + Object.entries(gates).map(([key, value]) => [key, bool(value, `M4.8R3 gate ${key}`)]), + ); + const decision = objectValue(payload.decision, "M4.8R3 decision"); + const state = text(decision.state, "M4.8R3 decision.state"); + if (state !== "accepted-bounded-worker-shadow" && state !== "rejected-bounded-worker-shadow") { + throw new M48R3StaticOccupancyContractError("M4.8R3 decision.state: неизвестное состояние."); + } + exact(decision.production_accepted, false, "M4.8R3 production acceptance"); + if (!Array.isArray(payload.limitations)) { + throw new M48R3StaticOccupancyContractError("M4.8R3 limitations: ожидался массив."); + } + return { + resultId, + createdAtUtc: text(payload.created_at_utc, "M4.8R3 created"), + accepted: bool(payload.accepted, "M4.8R3 accepted"), + profile: { + id: text(profile.id, "M4.8R3 profile id"), + sha256: sha256(profile.sha256, "M4.8R3 profile sha"), + minimumPoints: integer(componentization.minimum_points, "M4.8R3 minimum points"), + }, + metrics: { + frames: { + expected: integer(frames.expected, "M4.8R3 expected frames"), + baselineDelivered: integer(frames.baseline_delivered, "M4.8R3 baseline frames"), + candidateDelivered: integer(frames.candidate_delivered, "M4.8R3 candidate frames"), + }, + performance: { + baseline: workerPerformance(performance.baseline, "M4.8R3 baseline"), + candidate: workerPerformance(performance.candidate, "M4.8R3 candidate"), + fpsRegressionFraction: numberValue(performance.fps_regression_fraction, "M4.8R3 FPS regression"), + worldStateP95DeltaMs: numberValue(performance.world_state_p95_delta_ms, "M4.8R3 p95 delta"), + }, + occupancy: { + baselineCellTotal: integer(occupancy.baseline_cell_total, "M4.8R3 baseline cells"), + candidateCellTotal: integer(occupancy.candidate_cell_total, "M4.8R3 candidate cells"), + addedCellTotal: integer(occupancy.added_cell_total, "M4.8R3 added cells"), + lostCellTotal: integer(occupancy.lost_cell_total, "M4.8R3 lost cells"), + baselineComponentTotal: integer(occupancy.baseline_component_total, "M4.8R3 baseline components"), + candidateComponentTotal: integer(occupancy.candidate_component_total, "M4.8R3 candidate components"), + meanCellGrowthFraction: numberValue(occupancy.mean_cell_growth_fraction, "M4.8R3 cell growth"), + meanComponentGrowthFraction: numberValue(occupancy.mean_component_growth_fraction, "M4.8R3 component growth"), + maximumAddedCellsPerFrame: integer(occupancy.maximum_added_cells_per_frame, "M4.8R3 maximum added cells"), + maximumCandidateComponentsPerFrame: integer(occupancy.maximum_candidate_components_per_frame, "M4.8R3 maximum components"), + }, + provider: { + additiveObservationCount: integer(provider.additive_observation_count, "M4.8R3 observations"), + additiveVoxelCount: integer(provider.additive_voxel_count, "M4.8R3 voxels"), + framesWithAdditions: integer(provider.frames_with_additions, "M4.8R3 added frames"), + additiveMeanMsPerFrame: numberValue(provider.additive_mean_ms_per_frame, "M4.8R3 additive mean"), + peakTemporalComponents: integer(provider.peak_temporal_components, "M4.8R3 temporal peak"), + peakRollingCells: integer(provider.peak_rolling_cells, "M4.8R3 rolling peak"), + }, + anchors: { + count: integer(anchors.count, "M4.8R3 anchor count"), + criticalNearCount: integer(anchors.critical_near_count, "M4.8R3 near anchors"), + criticalNearRecall: numberValue(anchors.critical_near_recall, "M4.8R3 near recall"), + matchedCount: integer(anchors.matched_count, "M4.8R3 matched anchors"), + canonicalEngineeringRecall: numberValue(anchors.canonical_engineering_recall, "M4.8R3 canonical recall"), + separation: anchors.separation.map((value, index) => { + const row = objectValue(value, `M4.8R3 separation ${index}`); + return { + displayFrame: integer(row.display_frame, "M4.8R3 separation frame"), + expectedMinimumComponents: integer(row.expected_minimum_components, "M4.8R3 expected components"), + observedComponents: integer(row.observed_components, "M4.8R3 observed components"), + passed: bool(row.passed, "M4.8R3 separation passed"), + interpretation: text(row.interpretation, "M4.8R3 separation interpretation"), + }; + }), + }, + capacityDropCount: integer(metrics.capacity_drop_count, "M4.8R3 capacity drops"), + falseFreeCount: integer(metrics.false_free_count, "M4.8R3 false free"), + }, + gates: parsedGates, + decision: { + state, + candidateAccepted: bool(decision.candidate_accepted, "M4.8R3 candidate accepted"), + productionAccepted: false, + nextAction: text(decision.next_action, "M4.8R3 next action"), + }, + limitations: payload.limitations.map((value, index) => text(value, `M4.8R3 limitation ${index}`)), + authority: authority(payload.authority), + }; +} + +export async function fetchM48R3StaticOccupancyCases( + resultId: string, + { fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}, +): Promise { + if (!RESULT_ID.test(resultId)) { + throw new M48R3StaticOccupancyContractError("M4.8R3 identity недопустима."); + } + const response = await fetcher( + `/api/v1/laboratory/m48r3/static-occupancy/${encodeURIComponent(resultId)}/cases`, + { method: "GET", headers: { Accept: "application/json" }, signal }, + ); + if (!response.ok) { + throw new M48R3StaticOccupancyContractError(`M4.8R3 cases недоступны: HTTP ${response.status}.`); + } + const payload = objectValue(await response.json(), "M4.8R3 cases"); + exact( + payload.schema_version, + "missioncore.m48r3-static-occupancy-shadow-cases/v1", + "M4.8R3 cases schema", + ); + exact(payload.result_id, resultId, "M4.8R3 cases result"); + if (!Array.isArray(payload.cases) || payload.cases.length !== integer(payload.case_count, "M4.8R3 case count")) { + throw new M48R3StaticOccupancyContractError("M4.8R3 cases: нарушен размер."); + } + return payload.cases.map((value, index) => { + const row = objectValue(value, `M4.8R3 case ${index}`); + const distanceBand = text(row.distance_band, "M4.8R3 distance band"); + if (distanceBand !== "critical-near" && distanceBand !== "approach" && distanceBand !== "outside-qualified-bands") { + throw new M48R3StaticOccupancyContractError("M4.8R3 distance band: неизвестное значение."); + } + return { + anchorId: text(row.anchor_id, "M4.8R3 anchor id"), + displayFrame: integer(row.display_frame, "M4.8R3 display frame"), + sourceSequence: integer(row.source_sequence, "M4.8R3 source sequence"), + extentXyxy: extent(row.extent_xyxy), + distanceBand, + componentCount: integer(row.component_count, "M4.8R3 component count"), + matched: bool(row.matched, "M4.8R3 matched"), + }; + }); +} diff --git a/apps/control-station/src/core/laboratory/m4ReplayThreat.ts b/apps/control-station/src/core/laboratory/m4ReplayThreat.ts index f64391a..073f090 100644 --- a/apps/control-station/src/core/laboratory/m4ReplayThreat.ts +++ b/apps/control-station/src/core/laboratory/m4ReplayThreat.ts @@ -1,5 +1,6 @@ export type M4ThreatDecision = "threat" | "not-threat" | "unknown"; export type M4ThreatMotion = "moving" | "stationary" | "unknown"; +export type M4OccupancySource = "baseline" | "mixed" | "additive-low-step"; export type M4Point3 = readonly [number, number, number]; export type M4Matrix3 = readonly [M4Point3, M4Point3, M4Point3]; @@ -76,6 +77,7 @@ export interface M4ThreatMetricVisual { centroidBodyXyzM: M4Point3; cellCentersBodyXyzM: readonly M4Point3[]; assessment: M4ThreatAssessment; + occupancySource: M4OccupancySource; } export interface M4ThreatCameraProposal { @@ -174,6 +176,7 @@ export interface M4ThreatTimeline { cameraPointWindowSeconds: number; cameraPointSampleLimit: number; worldStateDelivery: "source-paced-latest-wins" | null; + occupancyProvenanceDelivery: "baseline-versus-additive-component-diff" | null; worldStateFrameCount: number; supersededFrameCount: number; sourceRepresentationId: "registered-map-increment-v1"; @@ -331,6 +334,9 @@ function parseMetricVisual(value: unknown): M4ThreatMetricVisual { ) { throw new M4ThreatContractError("M4.6 temporal state: неизвестное состояние."); } + const occupancySource = item.occupancy_source === undefined + ? "baseline" + : memberOccupancySource(item.occupancy_source); return { componentId: text(item.component_id, "M4.6 visual component"), state, @@ -340,9 +346,17 @@ function parseMetricVisual(value: unknown): M4ThreatMetricVisual { (point) => vector(point, 3, "M4.6 cell") as [number, number, number], ), assessment: parseAssessment(item.assessment), + occupancySource, }; } +function memberOccupancySource(value: unknown): M4OccupancySource { + if (value !== "baseline" && value !== "mixed" && value !== "additive-low-step") { + throw new M4ThreatContractError("M4.8R3 occupancy source: неизвестное состояние."); + } + return value; +} + export async function fetchM4ThreatReplayResult({ resultId: requestedResultId, fetcher = fetch, @@ -683,6 +697,13 @@ export async function fetchM4ThreatTimeline( "source-paced-latest-wins", "M4.6 world-state delivery", ), + occupancyProvenanceDelivery: payload.occupancy_provenance_delivery == null + ? null + : exact( + payload.occupancy_provenance_delivery, + "baseline-versus-additive-component-diff", + "M4.8R3 occupancy provenance", + ), worldStateFrameCount: payload.world_state_frame_count === undefined ? frameCount : integer(payload.world_state_frame_count, "M4.6 world-state frames"), diff --git a/apps/control-station/src/styles/m4-replay-threat.css b/apps/control-station/src/styles/m4-replay-threat.css index a49ecc0..41e85e0 100644 --- a/apps/control-station/src/styles/m4-replay-threat.css +++ b/apps/control-station/src/styles/m4-replay-threat.css @@ -334,6 +334,12 @@ background: transparent; } +.laboratory-metric-evidence-scene__legend span[data-decision="low-step"]::before { + box-sizing: border-box; + border: 1px solid rgb(var(--nodedc-foreground-rgb)); + background: rgb(var(--nodedc-accent-rgb)); +} + .laboratory-metric-evidence-scene__legend span[data-decision="local-surface"]::before { background: rgb(var(--nodedc-accent-rgb)); opacity: 0.72; diff --git a/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx b/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx index 18f1145..0cbbdf3 100644 --- a/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx +++ b/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx @@ -45,6 +45,7 @@ import { M47ReferenceGraphResultView } from "./M47ReferenceGraphResult"; import { M48ObjectCentricQualityResultView } from "./M48ObjectCentricQualityResult"; import { M48SmallStaticPassageRegressionResultView } from "./M48SmallStaticPassageRegressionResult"; import { M48StaticOccupancyQualificationResultView } from "./M48StaticOccupancyQualificationResult"; +import { M48R3StaticOccupancyShadowResultView } from "./M48R3StaticOccupancyShadowResult"; import { M48SFixedClassDetectorResultView } from "./M48SFixedClassDetectorResult"; import { M48TRiskQualityResultView } from "./M48TRiskQualityResult"; @@ -98,6 +99,9 @@ export function AdvancedLaboratoryResult({ if (workId === "m48-static-occupancy-qualification" && results.m48StaticOccupancy) { return ; } + if (workId === "m48r3-static-occupancy-shadow" && results.m48r3StaticOccupancy) { + return ; + } if (workId === "m48s-fixed-class-detector" && results.m48s) { return ; } diff --git a/apps/control-station/src/workspaces/laboratory/M48R3StaticOccupancyShadowEvidence.tsx b/apps/control-station/src/workspaces/laboratory/M48R3StaticOccupancyShadowEvidence.tsx new file mode 100644 index 0000000..911d060 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/M48R3StaticOccupancyShadowEvidence.tsx @@ -0,0 +1,81 @@ +import { useEffect, useMemo, useState } from "react"; +import { Icon } from "@nodedc/ui-react"; + +import { + fetchM48R3StaticOccupancyCases, + type M48R3StaticOccupancyCase, + type M48R3StaticOccupancyShadowResult, +} from "../../core/laboratory/m48r3StaticOccupancyShadow"; +import { + M4ReplayThreatVisual, + type M4ReplayThreatReviewAnchor, +} from "./M4ReplayThreatVisual"; + +function message(error: unknown): string { + return error instanceof Error && error.message.trim() + ? error.message + : "M4.8R3 timeline недоступен."; +} + +export function M48R3StaticOccupancyShadowEvidence({ + result, +}: { + result: M48R3StaticOccupancyShadowResult; +}) { + const [cases, setCases] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(null); + void fetchM48R3StaticOccupancyCases(result.resultId, { signal: controller.signal }) + .then((nextCases) => { + if (!controller.signal.aborted) setCases(nextCases); + }) + .catch((caught: unknown) => { + if (!controller.signal.aborted) setError(message(caught)); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [result.resultId]); + + const reviewAnchors = useMemo(() => ( + cases.map((item) => ({ + id: item.anchorId, + sourceSequence: item.sourceSequence, + extentXyxyNormalized: item.extentXyxy, + matchedAtThreshold: item.matched, + })) + ), [cases]); + + if (loading) { + return ( +
+
+ ); + } + if (error) { + return ( +
+ + {error} +
+ ); + } + return ( + + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/M48R3StaticOccupancyShadowResult.tsx b/apps/control-station/src/workspaces/laboratory/M48R3StaticOccupancyShadowResult.tsx new file mode 100644 index 0000000..9ca971f --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/M48R3StaticOccupancyShadowResult.tsx @@ -0,0 +1,101 @@ +import { + LaboratoryEvidence, + LaboratoryResultSummary, + LaboratorySummary, + LaboratoryWorkTemplate, +} from "../../components/laboratory/LaboratoryPresentation"; +import type { M48R3StaticOccupancyShadowResult } from "../../core/laboratory/m48r3StaticOccupancyShadow"; +import { M48R3StaticOccupancyShadowEvidence } from "./M48R3StaticOccupancyShadowEvidence"; + +function number(value: number, digits = 2): string { + return value.toLocaleString("ru-RU", { maximumFractionDigits: digits }); +} + +function percent(value: number): string { + return `${number(value * 100, 1)}%`; +} + +export function M48R3StaticOccupancyShadowResultView({ + rigLabel, + result, +}: { + rigLabel: string; + result: M48R3StaticOccupancyShadowResult; +}) { + const candidate = result.metrics.performance.candidate; + const occupancy = result.metrics.occupancy; + const separation = result.metrics.anchors.separation; + const separated = separation.every((item) => item.passed); + const status = result.accepted + ? "Полный Worker shadow принят: realtime и раздельные препятствия сохранены" + : "Worker shadow не прошёл один или несколько предобъявленных gate"; + const separatedLabel = separation.length + ? separation.map((item) => `${item.observedComponents}/${item.expectedMinimumComponents}`).join(" · ") + : "—"; + return ( + + )} + evidence={( + + + + )} + result={( + + )} + /> + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx index 9504a5f..5e58831 100644 --- a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx +++ b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx @@ -110,12 +110,16 @@ export function M4ReplayThreatVisual({ resultId, semantic, reviewAnchors = EMPTY_REVIEW_ANCHORS, + showReviewAnchorBoxes = true, + reviewLabel = "Контрольные примеры M4.8R1", timelineEndpointRoot, evidenceLabel = "M4.6", }: { resultId: string; semantic?: M4ReplayThreatSemanticLayer; reviewAnchors?: readonly M4ReplayThreatReviewAnchor[]; + showReviewAnchorBoxes?: boolean; + reviewLabel?: string; timelineEndpointRoot?: string; evidenceLabel?: string; }) { @@ -124,6 +128,7 @@ export function M4ReplayThreatVisual({ const [showCurrentIncrement, setShowCurrentIncrement] = useState(true); const [showLocalSurface, setShowLocalSurface] = useState(true); const [showRollingMap, setShowRollingMap] = useState(true); + const [showLowStep, setShowLowStep] = useState(true); const [showMediaSemantic, setShowMediaSemantic] = useState(true); const [showSpatialSemantic, setShowSpatialSemantic] = useState(true); const [showMediaPoints, setShowMediaPoints] = useState(false); @@ -262,7 +267,7 @@ export function M4ReplayThreatVisual({ }, [metadata.timeline, resultId, reviewAnchorIdentity, reviewAnchors, seekPlayback, setPlaybackPlaying]); const reviewAnchorBoxes = useMemo(() => { const timeline = metadata.timeline; - if (!frame || !timeline) return []; + if (!frame || !timeline || !showReviewAnchorBoxes) return []; return reviewAnchors .filter((anchor) => anchor.sourceSequence === frame.sequence) .map((anchor) => { @@ -281,7 +286,7 @@ export function M4ReplayThreatVisual({ dashed: true, }; }); - }, [frame, metadata.timeline, reviewAnchors]); + }, [frame, metadata.timeline, reviewAnchors, showReviewAnchorBoxes]); const activeBoxes = useMemo( () => [...boxes(frame?.cameraProposals ?? []), ...reviewAnchorBoxes], [frame, reviewAnchorBoxes], @@ -345,6 +350,7 @@ export function M4ReplayThreatVisual({ state: obstacle.state, centroidBodyXyzM: obstacle.centroidBodyXyzM, cellCentersBodyXyzM: obstacle.cellCentersBodyXyzM, + occupancySource: obstacle.occupancySource, })) ?? [], [spatialFrame]); const currentIncrementObstacles = spatialFrame?.metricObstacles.filter( (item) => item.state === "current", @@ -352,6 +358,9 @@ export function M4ReplayThreatVisual({ const rollingMapObstacles = spatialFrame?.metricObstacles.filter( (item) => item.state === "retained", ) ?? []; + const lowStepObstacles = spatialFrame?.metricObstacles.filter( + (item) => item.occupancySource !== "baseline", + ) ?? []; const nearest = spatialFrame?.metricObstacles .map((item) => item.assessment.closestApproachM) .filter((value): value is number => value !== null) @@ -513,6 +522,18 @@ export function M4ReplayThreatVisual({ > ROLLING + {metadata.timeline?.occupancyProvenanceDelivery ? ( + + ) : null} {semantic ? (