diff --git a/README.md b/README.md index 53974f5..4292333 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,11 @@ estimates a rolling local surface from map points plus compatible pose, and publishes height, slope, roughness, confidence and conservative observed surface/occupied/unknown evidence. All `526/526` available samples produced a diagnostic result; no free-space, command, navigation or safety authority is -inferred. The selected scene is visible in **Парк → Диагностика LiDAR**. +inferred. Leave-current-frame-out qualification produced `525` next-frame +samples with `0.038543 m` p50 median residual and `12` strict temporal jumps; +the p95 residual tail remains too large for planner use. The selected scene and +clickable complete-recording timeline are visible in +**Парк → Диагностика LiDAR**. The complete RELLIS-3D v1.1 release is now admitted there and its full `2,413`-frame validation split is available in **Полигон → Датасеты**. The diff --git a/apps/control-station/src/core/lidar/localSurface.ts b/apps/control-station/src/core/lidar/localSurface.ts index 2d9bb76..c06acd3 100644 --- a/apps/control-station/src/core/lidar/localSurface.ts +++ b/apps/control-station/src/core/lidar/localSurface.ts @@ -48,6 +48,28 @@ export interface LidarLocalSurfaceModel { confidence: LidarLocalSurfaceDistribution; poseBindingAgeMs: LidarLocalSurfaceDistribution; surfaceMaxAgeMs: LidarLocalSurfaceDistribution; + temporalQualification: { + prediction: { + currentFrameExcluded: true; + sampleCount: number; + residualP50M: LidarLocalSurfaceDistribution; + residualP95M: LidarLocalSurfaceDistribution; + inlierFraction: LidarLocalSurfaceDistribution; + }; + stability: { + sampleCount: number; + heightDeltaM: LidarLocalSurfaceDistribution; + slopeDeltaDeg: LidarLocalSurfaceDistribution; + roughnessDeltaM: LidarLocalSurfaceDistribution; + jumpCount: number; + }; + stepCandidates: { + isGroundTruth: false; + framesWithCandidates: number; + cellCount: LidarLocalSurfaceDistribution; + pointCount: LidarLocalSurfaceDistribution; + }; + } | null; }; anchors: LidarLocalSurfaceAnchor[]; occupancyPolicy: { @@ -88,6 +110,7 @@ export interface LidarLocalSurfaceFrame { pointsXyzM: Array<[number, number, number]>; pointClass: number[]; pointHeightM: number[]; + pointStepCandidate: number[]; pose: { positionXyzM: [number, number, number]; orientationXyzw: [number, number, number, number]; @@ -108,12 +131,56 @@ export interface LidarLocalSurfaceFrame { surface: number; occupied: number; belowSurface: number; + stepCandidate: number; + }; + prediction: { + available: boolean; + currentFrameExcluded: true; + cellCount: number; + residualP50M: number; + residualP95M: number; + inlierFraction: number; + }; + temporal: { + compared: boolean; + heightDeltaM: number; + slopeDeltaDeg: number; + roughnessDeltaM: number; + jump: boolean; + stepCandidateCellCount: number; }; occupancyPolicy: LidarLocalSurfaceModel["occupancyPolicy"]; groundTruth: false; authority: LidarLocalSurfaceModel["authority"]; } +export interface LidarLocalSurfaceTimeline { + modelId: string; + sourcePackId: string; + sessionId: string; + frameCount: number; + sourceFrameIndex: number[]; + sessionSeconds: number[]; + sourceAvailable: number[]; + valid: number[]; + predictionAvailable: number[]; + predictionResidualP50M: number[]; + predictionResidualP95M: number[]; + predictionInlierFraction: number[]; + sensorHeightM: number[]; + slopeDeg: number[]; + roughnessM: number[]; + confidence: number[]; + temporalCompared: number[]; + heightDeltaM: number[]; + slopeDeltaDeg: number[]; + roughnessDeltaM: number[]; + temporalJump: number[]; + stepCandidatePointCount: number[]; + groundTruth: false; + authority: LidarLocalSurfaceModel["authority"]; +} + export class LidarLocalSurfaceContractError extends Error {} export class LidarLocalSurfaceApiError extends Error { @@ -199,6 +266,39 @@ function tuple( return values; } +function finiteVector( + value: unknown, + length: number, + label: string, +): number[] { + const values = array(value, label).map((item, index) => + finite(item, `${label}[${index}]`) + ); + if (values.length !== length) { + throw new LidarLocalSurfaceContractError(`${label}: неверная длина`); + } + return values; +} + +function integerVector( + value: unknown, + length: number, + label: string, + maximum?: number, +): number[] { + const values = array(value, label).map((item, index) => { + const result = integer(item, `${label}[${index}]`); + if (maximum !== undefined && result > maximum) { + throw new LidarLocalSurfaceContractError(`${label}: значение вне диапазона`); + } + return result; + }); + if (values.length !== length) { + throw new LidarLocalSurfaceContractError(`${label}: неверная длина`); + } + return values; +} + function distribution( value: unknown, label: string, @@ -288,6 +388,90 @@ function anchor(value: unknown): LidarLocalSurfaceAnchor { }; } +function temporalQualification( + value: unknown, +): LidarLocalSurfaceModel["metrics"]["temporalQualification"] { + if (value === undefined || value === null) return null; + const source = record(value, "temporal_qualification"); + const prediction = record(source.prediction, "temporal_qualification.prediction"); + const stability = record(source.stability, "temporal_qualification.stability"); + const steps = record( + source.step_candidates, + "temporal_qualification.step_candidates", + ); + if ( + prediction.current_frame_excluded !== true + || steps.is_ground_truth !== false + ) { + throw new LidarLocalSurfaceContractError( + "LiDAR temporal qualification завышает evidence", + ); + } + const predictionSampleCount = integer( + prediction.sample_count, + "prediction.sample_count", + ); + const stabilitySampleCount = integer( + stability.sample_count, + "stability.sample_count", + ); + const jumpCount = integer(stability.jump_count, "stability.jump_count"); + if (jumpCount > stabilitySampleCount) { + throw new LidarLocalSurfaceContractError( + "LiDAR temporal jump count несовместим", + ); + } + return { + prediction: { + currentFrameExcluded: true, + sampleCount: predictionSampleCount, + residualP50M: distribution( + prediction.residual_p50_m, + "prediction.residual_p50_m", + ), + residualP95M: distribution( + prediction.residual_p95_m, + "prediction.residual_p95_m", + ), + inlierFraction: distribution( + prediction.inlier_fraction, + "prediction.inlier_fraction", + ), + }, + stability: { + sampleCount: stabilitySampleCount, + heightDeltaM: distribution( + stability.height_delta_m, + "stability.height_delta_m", + ), + slopeDeltaDeg: distribution( + stability.slope_delta_deg, + "stability.slope_delta_deg", + ), + roughnessDeltaM: distribution( + stability.roughness_delta_m, + "stability.roughness_delta_m", + ), + jumpCount, + }, + stepCandidates: { + isGroundTruth: false, + framesWithCandidates: integer( + steps.frames_with_candidates, + "step_candidates.frames_with_candidates", + ), + cellCount: distribution( + steps.cell_count, + "step_candidates.cell_count", + ), + pointCount: distribution( + steps.point_count, + "step_candidates.point_count", + ), + }, + }; +} + function model(value: unknown): LidarLocalSurfaceModel { const source = record(value, "LiDAR local-surface model"); const sourceEvidence = record(source.source, "source"); @@ -373,6 +557,9 @@ function model(value: unknown): LidarLocalSurfaceModel { metrics.surface_max_age_ms, "surface_max_age_ms", ), + temporalQualification: temporalQualification( + metrics.temporal_qualification, + ), }, anchors, occupancyPolicy: occupancyPolicy(source.occupancy_policy), @@ -446,10 +633,23 @@ export function parseLidarLocalSurfaceFrame( const pointHeightM = array(source.point_height_m, "point_height_m").map( (item, index) => finite(item, `point_height_m[${index}]`), ); + const pointStepCandidate = array( + source.point_step_candidate, + "point_step_candidate", + ).map((item, index) => { + const value = integer(item, `point_step_candidate[${index}]`); + if (value > 1) { + throw new LidarLocalSurfaceContractError( + "Некорректная step-candidate mask", + ); + } + return value; + }); if ( pointsXyzM.length !== pointCount || pointClass.length !== pointCount || pointHeightM.length !== pointCount + || pointStepCandidate.length !== pointCount ) { throw new LidarLocalSurfaceContractError( "LiDAR local-surface point arrays расходятся", @@ -457,12 +657,15 @@ export function parseLidarLocalSurfaceFrame( } const pose = record(source.pose, "pose"); const surface = record(source.surface, "surface"); + const prediction = record(source.prediction, "prediction"); + const temporal = record(source.temporal, "temporal"); const counts = record(source.counts, "counts"); const parsedCounts = { classified: integer(counts.classified, "counts.classified"), surface: integer(counts.surface, "counts.surface"), occupied: integer(counts.occupied, "counts.occupied"), belowSurface: integer(counts.below_surface, "counts.below_surface"), + stepCandidate: integer(counts.step_candidate, "counts.step_candidate"), }; if ( parsedCounts.classified !== pointClass.filter((item) => item !== 0).length @@ -470,6 +673,8 @@ export function parseLidarLocalSurfaceFrame( || parsedCounts.occupied !== pointClass.filter((item) => item === 2).length || parsedCounts.belowSurface !== pointClass.filter((item) => item === 3).length + || parsedCounts.stepCandidate + !== pointStepCandidate.filter((item) => item === 1).length ) { throw new LidarLocalSurfaceContractError( "LiDAR local-surface counts расходятся", @@ -486,6 +691,20 @@ export function parseLidarLocalSurfaceFrame( 4, "surface.plane_coefficients_map", ); + if (prediction.current_frame_excluded !== true) { + throw new LidarLocalSurfaceContractError( + "Текущий кадр попал в prediction input", + ); + } + const predictionInlierFraction = finite( + prediction.inlier_fraction, + "prediction.inlier_fraction", + ); + if (predictionInlierFraction < 0 || predictionInlierFraction > 1) { + throw new LidarLocalSurfaceContractError( + "Prediction inlier fraction несовместим", + ); + } return { modelId: text(source.model_id, "model_id", SAFE_MODEL_ID), sourcePackId: text(source.source_pack_id, "source_pack_id", SAFE_PACK_ID), @@ -503,6 +722,7 @@ export function parseLidarLocalSurfaceFrame( pointsXyzM, pointClass, pointHeightM, + pointStepCandidate, pose: { positionXyzM: [position[0], position[1], position[2]], orientationXyzw: [ @@ -530,12 +750,181 @@ export function parseLidarLocalSurfaceFrame( ), }, counts: parsedCounts, + prediction: { + available: boolean(prediction.available, "prediction.available"), + currentFrameExcluded: true, + cellCount: integer(prediction.cell_count, "prediction.cell_count"), + residualP50M: finite( + prediction.residual_p50_m, + "prediction.residual_p50_m", + ), + residualP95M: finite( + prediction.residual_p95_m, + "prediction.residual_p95_m", + ), + inlierFraction: predictionInlierFraction, + }, + temporal: { + compared: boolean(temporal.compared, "temporal.compared"), + heightDeltaM: finite( + temporal.height_delta_m, + "temporal.height_delta_m", + ), + slopeDeltaDeg: finite( + temporal.slope_delta_deg, + "temporal.slope_delta_deg", + ), + roughnessDeltaM: finite( + temporal.roughness_delta_m, + "temporal.roughness_delta_m", + ), + jump: boolean(temporal.jump, "temporal.jump"), + stepCandidateCellCount: integer( + temporal.step_candidate_cell_count, + "temporal.step_candidate_cell_count", + ), + }, occupancyPolicy: occupancyPolicy(source.occupancy_policy), groundTruth: false, authority: authority(source.authority), }; } +export function parseLidarLocalSurfaceTimeline( + value: unknown, +): LidarLocalSurfaceTimeline { + const source = record(value, "LiDAR local-surface timeline"); + if ( + source.schema_version !== `${LOCAL_SURFACE_SCHEMA_PREFIX}-timeline/v1` + || source.access !== "read-only" + || source.ground_truth !== false + ) { + throw new LidarLocalSurfaceContractError( + "LiDAR local-surface timeline несовместим", + ); + } + const frameCount = integer(source.frame_count, "frame_count"); + if (frameCount < 1 || frameCount > 100_000) { + throw new LidarLocalSurfaceContractError( + "LiDAR local-surface timeline слишком большой", + ); + } + const sourceFrameIndex = integerVector( + source.source_frame_index, + frameCount, + "source_frame_index", + ); + const sessionSeconds = finiteVector( + source.session_seconds, + frameCount, + "session_seconds", + ); + const sourceAvailable = integerVector( + source.source_available, + frameCount, + "source_available", + 1, + ); + const valid = integerVector(source.valid, frameCount, "valid", 1); + const predictionAvailable = integerVector( + source.prediction_available, + frameCount, + "prediction_available", + 1, + ); + const predictionResidualP50M = finiteVector( + source.prediction_residual_p50_m, + frameCount, + "prediction_residual_p50_m", + ); + const predictionResidualP95M = finiteVector( + source.prediction_residual_p95_m, + frameCount, + "prediction_residual_p95_m", + ); + const predictionInlierFraction = finiteVector( + source.prediction_inlier_fraction, + frameCount, + "prediction_inlier_fraction", + ); + const confidence = finiteVector(source.confidence, frameCount, "confidence"); + const temporalCompared = integerVector( + source.temporal_compared, + frameCount, + "temporal_compared", + 1, + ); + const temporalJump = integerVector( + source.temporal_jump, + frameCount, + "temporal_jump", + 1, + ); + if ( + sourceFrameIndex.some( + (item, index) => index > 0 && item <= sourceFrameIndex[index - 1], + ) + || sessionSeconds.some( + (item, index) => index > 0 && item <= sessionSeconds[index - 1], + ) + || predictionInlierFraction.some((item) => item < 0 || item > 1) + || confidence.some((item) => item < 0 || item > 1) + || temporalJump.some( + (item, index) => item === 1 && temporalCompared[index] !== 1, + ) + ) { + throw new LidarLocalSurfaceContractError( + "LiDAR local-surface timeline content несовместим", + ); + } + return { + modelId: text(source.model_id, "model_id", SAFE_MODEL_ID), + sourcePackId: text(source.source_pack_id, "source_pack_id", SAFE_PACK_ID), + sessionId: text(source.session_id, "session_id", SAFE_ID), + frameCount, + sourceFrameIndex, + sessionSeconds, + sourceAvailable, + valid, + predictionAvailable, + predictionResidualP50M, + predictionResidualP95M, + predictionInlierFraction, + sensorHeightM: finiteVector( + source.sensor_height_m, + frameCount, + "sensor_height_m", + ), + slopeDeg: finiteVector(source.slope_deg, frameCount, "slope_deg"), + roughnessM: finiteVector(source.roughness_m, frameCount, "roughness_m"), + confidence, + temporalCompared, + heightDeltaM: finiteVector( + source.height_delta_m, + frameCount, + "height_delta_m", + ), + slopeDeltaDeg: finiteVector( + source.slope_delta_deg, + frameCount, + "slope_delta_deg", + ), + roughnessDeltaM: finiteVector( + source.roughness_delta_m, + frameCount, + "roughness_delta_m", + ), + temporalJump, + stepCandidatePointCount: integerVector( + source.step_candidate_point_count, + frameCount, + "step_candidate_point_count", + ), + groundTruth: false, + authority: authority(source.authority), + }; +} + async function responseJson( response: Response, fallback: string, @@ -600,3 +989,29 @@ export async function fetchLidarLocalSurfaceFrame( ), ); } + +export async function fetchLidarLocalSurfaceTimeline( + modelId: string, + options: { signal?: AbortSignal; fetcher?: LidarFetch } = {}, +): Promise { + if (!SAFE_MODEL_ID.test(modelId)) { + throw new LidarLocalSurfaceContractError( + "Некорректный LiDAR local-surface timeline", + ); + } + const fetcher = options.fetcher ?? fetch; + const response = await fetcher( + `/api/v1/lidar/local-surfaces/${modelId}/timeline`, + { + method: "GET", + headers: { Accept: "application/json" }, + signal: options.signal, + }, + ); + return parseLidarLocalSurfaceTimeline( + await responseJson( + response, + "Не удалось получить LiDAR local-surface timeline.", + ), + ); +} diff --git a/apps/control-station/src/styles/workspaces.css b/apps/control-station/src/styles/workspaces.css index 218cd8e..245e88d 100644 --- a/apps/control-station/src/styles/workspaces.css +++ b/apps/control-station/src/styles/workspaces.css @@ -2884,6 +2884,97 @@ font-size: 0.68rem; } +.lidar-local-surface__timeline { + display: grid; + gap: 0.38rem; + background: rgb(255 255 255 / 0.018); + padding: 0.62rem 0.68rem 0.48rem; +} + +.lidar-local-surface__timeline header, +.lidar-local-surface__timeline footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.8rem; +} + +.lidar-local-surface__timeline header > div { + display: grid; + gap: 0.1rem; +} + +.lidar-local-surface__timeline header > div:last-child { + text-align: right; +} + +.lidar-local-surface__timeline span, +.lidar-local-surface__timeline small { + color: var(--nodedc-text-muted); + font-size: 0.56rem; +} + +.lidar-local-surface__timeline strong { + color: var(--nodedc-text-primary); + font-size: 0.66rem; +} + +.lidar-local-surface__timeline svg { + width: 100%; + height: 8.3rem; + cursor: crosshair; + outline: 0; +} + +.lidar-local-surface__timeline svg:focus-visible { + background: rgb(255 255 255 / 0.018); +} + +.lidar-local-surface__timeline-baseline { + stroke: rgb(255 255 255 / 0.08); + stroke-width: 1; +} + +.lidar-local-surface__timeline-reference { + stroke: rgb(255 255 255 / 0.08); + stroke-dasharray: 4 8; + stroke-width: 1; +} + +.lidar-local-surface__timeline-line { + fill: none; + stroke: #a1b87d; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; + vector-effect: non-scaling-stroke; +} + +.lidar-local-surface__timeline-jump { + stroke: #f5c23d; + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.lidar-local-surface__timeline-selected { + stroke: rgb(255 255 255 / 0.88); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.lidar-local-surface__timeline footer span:nth-child(2) { + display: flex; + align-items: center; + gap: 0.3rem; +} + +.lidar-local-surface__timeline footer i { + width: 0.42rem; + height: 0.42rem; + border-radius: 50%; + background: #f5c23d; +} + .lidar-local-surface__stage { display: grid; overflow: hidden; @@ -2953,6 +3044,10 @@ background: #a1b87d; } +.lidar-local-surface__legend i[data-class="step"] { + background: #f5c23d; +} + .lidar-local-surface__legend i[data-class="occupied"] { background: #f0783d; } diff --git a/apps/control-station/src/workspaces/LidarGroundPointCloud.tsx b/apps/control-station/src/workspaces/LidarGroundPointCloud.tsx index 8a74eb2..1292245 100644 --- a/apps/control-station/src/workspaces/LidarGroundPointCloud.tsx +++ b/apps/control-station/src/workspaces/LidarGroundPointCloud.tsx @@ -27,6 +27,7 @@ export interface LidarGroundPointCloudFrame { groundTruthGround?: number[]; evaluationMask?: number[]; localSurfaceClass?: number[]; + localStepCandidate?: number[]; }; } @@ -71,7 +72,10 @@ function frameColors( const candidateAssigned = frame.masks.candidateAssigned[index] === 1; if (mode === "local-surface") { const localClass = frame.masks.localSurfaceClass?.[index] ?? 0; - if (localClass === 1) { + const stepCandidate = frame.masks.localStepCandidate?.[index] === 1; + if (stepCandidate) { + setRgb(colors, offset, 0.96, 0.76, 0.24); + } else if (localClass === 1) { setRgb(colors, offset, 0.63, 0.72, 0.49); } else if (localClass === 2) { setRgb(colors, offset, 0.94, 0.48, 0.24); diff --git a/apps/control-station/src/workspaces/LidarLocalSurfacePanel.tsx b/apps/control-station/src/workspaces/LidarLocalSurfacePanel.tsx index de79255..9d92622 100644 --- a/apps/control-station/src/workspaces/LidarLocalSurfacePanel.tsx +++ b/apps/control-station/src/workspaces/LidarLocalSurfacePanel.tsx @@ -3,11 +3,14 @@ import { StatusBadge } from "@nodedc/ui-react"; import { fetchLidarLocalSurfaceFrame, + fetchLidarLocalSurfaceTimeline, fetchLidarLocalSurfaces, type LidarLocalSurfaceFrame, type LidarLocalSurfaceModel, + type LidarLocalSurfaceTimeline as Timeline, } from "../core/lidar/localSurface"; import { LidarGroundPointCloud } from "./LidarGroundPointCloud"; +import { LidarLocalSurfaceTimeline } from "./LidarLocalSurfaceTimeline"; function formatNumber(value: number | null, digits = 2): string { if (value === null) return "—"; @@ -29,6 +32,10 @@ export function LidarLocalSurfacePanel({ }) { const [model, setModel] = useState(null); const [frame, setFrame] = useState(null); + const [timeline, setTimeline] = useState(null); + const [selectedFrameIndex, setSelectedFrameIndex] = useState( + null, + ); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -48,12 +55,19 @@ export function LidarLocalSurfacePanel({ void fetchLidarLocalSurfaces({ signal: controller.signal }) .then((catalog) => { if (controller.signal.aborted) return; - setModel(catalog.items[0] ?? null); + setModel( + catalog.items.find( + (item) => item.metrics.temporalQualification !== null, + ) + ?? catalog.items[0] + ?? null, + ); }) .catch((loadError) => { if (controller.signal.aborted) return; setModel(null); setFrame(null); + setTimeline(null); setError(errorMessage(loadError)); }) .finally(() => { @@ -63,14 +77,38 @@ export function LidarLocalSurfacePanel({ }, [reloadGeneration]); useEffect(() => { - if (!model || !anchor) { + setSelectedFrameIndex(anchor?.frameIndex ?? null); + }, [anchor]); + + useEffect(() => { + if (!model) { + setTimeline(null); + return; + } + const controller = new AbortController(); + void fetchLidarLocalSurfaceTimeline(model.modelId, { + signal: controller.signal, + }) + .then((nextTimeline) => { + if (!controller.signal.aborted) setTimeline(nextTimeline); + }) + .catch((loadError) => { + if (controller.signal.aborted) return; + setTimeline(null); + setError(errorMessage(loadError)); + }); + return () => controller.abort(); + }, [model]); + + useEffect(() => { + if (!model || selectedFrameIndex === null) { setFrame(null); return; } const controller = new AbortController(); setLoading(true); setError(null); - void fetchLidarLocalSurfaceFrame(model.modelId, anchor.frameIndex, { + void fetchLidarLocalSurfaceFrame(model.modelId, selectedFrameIndex, { signal: controller.signal, }) .then((nextFrame) => { @@ -85,7 +123,7 @@ export function LidarLocalSurfacePanel({ if (!controller.signal.aborted) setLoading(false); }); return () => controller.abort(); - }, [anchor, model]); + }, [model, selectedFrameIndex]); const cloudFrame = useMemo(() => { if (!frame) return null; @@ -101,6 +139,7 @@ export function LidarLocalSurfacePanel({ candidateAssigned: emptyMask, disagreement: emptyMask, localSurfaceClass: frame.pointClass, + localStepCandidate: frame.pointStepCandidate, }, }; }, [frame]); @@ -119,15 +158,27 @@ export function LidarLocalSurfacePanel({ ЛОКАЛЬНАЯ МОДЕЛЬ LIDAR · L2.6

Поверхность и наблюдаемые препятствия

- Производная от неизменяемого RAVNOVES00: высота и уклон - вычисляются из текущей позы и локальной поверхности, без константы - 1,27 м и без команд в сканер. + Поверхность строится по предыдущему TTL-окну и проверяется на + следующем кадре. Текущий кадр исключён из prediction input; + константа 1,27 м и команды в сканер не используются.

- + {error ? "Недоступно" - : frame?.valid + : frame?.temporal.jump + ? "Temporal jump" + : frame?.valid ? "Кадр рассчитан" : "Диагностический режим"} @@ -143,20 +194,46 @@ export function LidarLocalSurfacePanel({
- Высота над поверхностью · p50 - {formatNumber(model.metrics.sensorHeightM.p50)} м + Prediction residual · p50 + + {formatNumber( + model.metrics.temporalQualification?.prediction + .residualP50M.p50 ?? null, + 3, + )}{" "} + м +
- Шероховатость · p95 - {formatNumber(model.metrics.roughnessM.p95, 3)} м + Prediction inliers · p50 + + {formatNumber( + (model.metrics.temporalQualification?.prediction + .inlierFraction.p50 ?? 0) * 100, + 1, + )} + % +
- Pose binding · p95 - {formatNumber(model.metrics.poseBindingAgeMs.p95)} мс + Temporal jumps + + {model.metrics.temporalQualification?.stability.jumpCount ?? 0} + {" / "} + {model.metrics.temporalQualification?.stability.sampleCount ?? 0} +
) : null} + {timeline && selectedFrameIndex !== null ? ( + + ) : null} +
{cloudFrame && frame ? ( @@ -192,6 +269,22 @@ export function LidarLocalSurfacePanel({
Confidence
{formatNumber(frame.surface.confidence * 100, 0)}%
+
+
Prediction p50
+
+ {frame.prediction.available + ? `${formatNumber(frame.prediction.residualP50M, 3)} м` + : "—"} +
+
+
+
Prediction inliers
+
+ {frame.prediction.available + ? `${formatNumber(frame.prediction.inlierFraction * 100, 1)}%` + : "—"} +
+
Поверхность
{frame.counts.surface.toLocaleString("ru-RU")} точек
@@ -200,16 +293,24 @@ export function LidarLocalSurfacePanel({
Препятствия
{frame.counts.occupied.toLocaleString("ru-RU")} точек
+
+
Перепады-кандидаты
+
+ {frame.counts.stepCandidate.toLocaleString("ru-RU")} точек +
+
+ Перепад / бордюр-кандидат Наблюдаемая поверхность Выше поверхности Нижний выброс Не классифицировано

- Пустота между точками остаётся unknown. Этот слой не разрешает - движение и не меняет постоянную реконструкцию территории. + Жёлтый слой — геометрический кандидат, не распознанный бордюр и + не ground truth. Пустота остаётся unknown; движение этим слоем + не разрешается.

) : null} diff --git a/apps/control-station/src/workspaces/LidarLocalSurfaceTimeline.tsx b/apps/control-station/src/workspaces/LidarLocalSurfaceTimeline.tsx new file mode 100644 index 0000000..2d4e68d --- /dev/null +++ b/apps/control-station/src/workspaces/LidarLocalSurfaceTimeline.tsx @@ -0,0 +1,160 @@ +import { useMemo } from "react"; + +import type { LidarLocalSurfaceTimeline as Timeline } from "../core/lidar/localSurface"; + +const VIEWBOX_WIDTH = 1000; +const VIEWBOX_HEIGHT = 168; +const PLOT_TOP = 22; +const PLOT_BOTTOM = 132; + +function formatMeters(value: number): string { + return value.toLocaleString("ru-RU", { + minimumFractionDigits: 3, + maximumFractionDigits: 3, + }); +} + +function xAt(index: number, frameCount: number): number { + if (frameCount <= 1) return 0; + return (index / (frameCount - 1)) * VIEWBOX_WIDTH; +} + +export function LidarLocalSurfaceTimeline({ + timeline, + selectedFrameIndex, + onSelectFrame, +}: { + timeline: Timeline; + selectedFrameIndex: number; + onSelectFrame: (frameIndex: number) => void; +}) { + const plot = useMemo(() => { + const samples = timeline.predictionResidualP50M.filter( + (value, index) => timeline.predictionAvailable[index] === 1 + && Number.isFinite(value), + ); + const sorted = [...samples].sort((left, right) => left - right); + const robustMaximum = sorted.length + ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.98))] + : 0; + const scaleMaximum = Math.max(0.06, robustMaximum * 1.15); + const points = timeline.predictionResidualP50M + .map((value, index) => { + if (timeline.predictionAvailable[index] !== 1) return null; + const bounded = Math.min(scaleMaximum, Math.max(0, value)); + const y = PLOT_BOTTOM + - (bounded / scaleMaximum) * (PLOT_BOTTOM - PLOT_TOP); + return `${xAt(index, timeline.frameCount).toFixed(2)},${y.toFixed(2)}`; + }) + .filter((value): value is string => value !== null) + .join(" "); + return { points, scaleMaximum }; + }, [timeline]); + + const selectedIndex = Math.min( + timeline.frameCount - 1, + Math.max(0, selectedFrameIndex), + ); + const selectedResidual = timeline.predictionAvailable[selectedIndex] === 1 + ? timeline.predictionResidualP50M[selectedIndex] + : null; + const jumpCount = timeline.temporalJump.reduce( + (total, value) => total + value, + 0, + ); + + const selectAtPointer = (clientX: number, target: SVGSVGElement) => { + const bounds = target.getBoundingClientRect(); + if (bounds.width <= 0) return; + const fraction = Math.min( + 1, + Math.max(0, (clientX - bounds.left) / bounds.width), + ); + onSelectFrame(Math.round(fraction * (timeline.frameCount - 1))); + }; + + const referenceY = PLOT_BOTTOM + - (0.04 / plot.scaleMaximum) * (PLOT_BOTTOM - PLOT_TOP); + + return ( +
+
+
+ Вся запись · current frame excluded + Ошибка предсказания поверхности +
+
+ + кадр {timeline.sourceFrameIndex[selectedIndex]} + {" · "} + {selectedResidual === null + ? "нет prediction" + : `${formatMeters(selectedResidual)} м`} + + {jumpCount} temporal jumps +
+
+ selectAtPointer(event.clientX, event.currentTarget)} + onKeyDown={(event) => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + onSelectFrame(Math.max(0, selectedIndex - 1)); + } + if (event.key === "ArrowRight") { + event.preventDefault(); + onSelectFrame(Math.min(timeline.frameCount - 1, selectedIndex + 1)); + } + }} + > + + + + {timeline.temporalJump.map((value, index) => + value === 1 ? ( + + ) : null + )} + + +
+ начало + скачок модели + конец +
+
+ ); +} diff --git a/apps/control-station/test/localSurface.test.mjs b/apps/control-station/test/localSurface.test.mjs index fb8acbb..1d292e8 100644 --- a/apps/control-station/test/localSurface.test.mjs +++ b/apps/control-station/test/localSurface.test.mjs @@ -6,6 +6,8 @@ import { createServer } from "vite"; let server; let parseLidarLocalSurfaceCatalog; let parseLidarLocalSurfaceFrame; +let parseLidarLocalSurfaceTimeline; +let fetchLidarLocalSurfaceTimeline; let LidarLocalSurfaceContractError; const modelId = `k1-local-surface-${"a".repeat(64)}`; @@ -71,6 +73,28 @@ function model(overrides = {}) { confidence: distribution(0.8), pose_binding_age_ms: distribution(7), surface_max_age_ms: distribution(1000), + temporal_qualification: { + prediction: { + current_frame_excluded: true, + sample_count: 9, + residual_p50_m: distribution(0.04), + residual_p95_m: distribution(0.2), + inlier_fraction: distribution(0.95), + }, + stability: { + sample_count: 9, + height_delta_m: distribution(0.01), + slope_delta_deg: distribution(0.1), + roughness_delta_m: distribution(0.002), + jump_count: 1, + }, + step_candidates: { + is_ground_truth: false, + frames_with_candidates: 8, + cell_count: distribution(20), + point_count: distribution(12), + }, + }, build_elapsed_ms: 20, }, anchors: [{ @@ -127,6 +151,7 @@ function frame(overrides = {}) { points_xyz_m: [[0, 0, 0], [1, 0, 0], [1, 1, 0.7], [2, 0, -0.3]], point_class: [1, 1, 2, 3], point_height_m: [0, 0.02, 0.7, -0.3], + point_step_candidate: [0, 1, 0, 0], pose: { position_xyz_m: [0, 0, 1.3], orientation_xyzw: [0, 0, 0, 1], @@ -147,6 +172,23 @@ function frame(overrides = {}) { surface: 2, occupied: 1, below_surface: 1, + step_candidate: 1, + }, + prediction: { + available: true, + current_frame_excluded: true, + cell_count: 32, + residual_p50_m: 0.04, + residual_p95_m: 0.2, + inlier_fraction: 0.95, + }, + temporal: { + compared: true, + height_delta_m: 0.01, + slope_delta_deg: 0.1, + roughness_delta_m: 0.002, + jump: false, + step_candidate_cell_count: 5, }, classes: {}, occupancy_policy: policy(), @@ -160,6 +202,41 @@ function frame(overrides = {}) { }; } +function timeline(overrides = {}) { + return { + schema_version: "missioncore.k1-local-surface-timeline/v1", + model_id: modelId, + source_pack_id: sourcePackId, + session_id: "20260720T065719Z_viewer_live", + frame_count: 4, + source_frame_index: [1000, 1001, 1002, 1003], + session_seconds: [0, 0.1, 0.2, 0.3], + source_available: [1, 1, 1, 1], + valid: [1, 1, 1, 1], + prediction_available: [0, 1, 1, 1], + prediction_residual_p50_m: [0, 0.04, 0.05, 0.03], + prediction_residual_p95_m: [0, 0.2, 0.3, 0.15], + prediction_inlier_fraction: [0, 0.95, 0.92, 0.97], + sensor_height_m: [1.3, 1.31, 1.3, 1.29], + slope_deg: [1, 1.1, 1.2, 1], + roughness_m: [0.04, 0.04, 0.05, 0.04], + confidence: [0.8, 0.8, 0.75, 0.82], + temporal_compared: [0, 1, 1, 1], + height_delta_m: [0, 0.01, 0.01, 0.01], + slope_delta_deg: [0, 0.1, 0.1, 0.2], + roughness_delta_m: [0, 0, 0.01, 0.01], + temporal_jump: [0, 0, 1, 0], + step_candidate_point_count: [1, 2, 3, 4], + ground_truth: false, + access: "read-only", + authority: { + commands_enabled: false, + navigation_or_safety_accepted: false, + }, + ...overrides, + }; +} + before(async () => { server = await createServer({ appType: "custom", @@ -169,6 +246,8 @@ before(async () => { ({ parseLidarLocalSurfaceCatalog, parseLidarLocalSurfaceFrame, + parseLidarLocalSurfaceTimeline, + fetchLidarLocalSurfaceTimeline, LidarLocalSurfaceContractError, } = await server.ssrLoadModule("/src/core/lidar/localSurface.ts")); }); @@ -182,10 +261,21 @@ test("decodes passive local-surface evidence", () => { assert.equal(decoded.items[0].source.passiveProcessingOnly, true); assert.equal(decoded.items[0].metrics.sensorHeightM.p50, 1.3); assert.equal(decoded.items[0].anchors[0].sourceFrameIndex, 1350); + assert.equal( + decoded.items[0].metrics.temporalQualification.prediction.sampleCount, + 9, + ); const decodedFrame = parseLidarLocalSurfaceFrame(frame()); assert.equal(decodedFrame.counts.occupied, 1); + assert.equal(decodedFrame.counts.stepCandidate, 1); + assert.equal(decodedFrame.prediction.currentFrameExcluded, true); assert.equal(decodedFrame.surface.sensorHeightM, 1.3); + + const decodedTimeline = parseLidarLocalSurfaceTimeline(timeline()); + assert.equal(decodedTimeline.frameCount, 4); + assert.deepEqual(decodedTimeline.temporalJump, [0, 0, 1, 0]); + assert.equal(decodedTimeline.predictionResidualP50M[2], 0.05); }); test("rejects inferred free space", () => { @@ -210,3 +300,21 @@ test("rejects command authority", () => { LidarLocalSurfaceContractError, ); }); + +test("fetches the complete local-surface timeline read-only", async () => { + const requests = []; + const decoded = await fetchLidarLocalSurfaceTimeline(modelId, { + fetcher: async (input, init) => { + requests.push({ input: String(input), method: init?.method }); + return new Response(JSON.stringify(timeline()), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }, + }); + assert.deepEqual(requests, [{ + input: `/api/v1/lidar/local-surfaces/${modelId}/timeline`, + method: "GET", + }]); + assert.equal(decoded.frameCount, 4); +}); diff --git a/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md b/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md index 6d69481..aa9c87a 100644 --- a/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md +++ b/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md @@ -2,8 +2,9 @@ Date: 2026-07-25 Status: accepted architecture plan; L0/L1 implemented; L2 diagnostic A/B -complete; full GOOSE and RELLIS qualification complete; L2.6a K1 replay -local-surface slice implemented; operator review and live shadow next +complete; full GOOSE and RELLIS qualification complete; L2.6b K1 replay +local-surface temporal qualification implemented; operator review and live +shadow next Scope: passively received real-time K1 point/pose evidence, immutable replay and future live shadow processing Explicitly out of scope: K1 firmware modification, a new onboard exporter, new @@ -398,7 +399,7 @@ Dataset expansion is no longer the next gate. `1.27 m` value. - [x] Publish surface-relative height, slope, roughness and confidence separately from semantic classes. -- [ ] Add independently reviewable step/curb candidates; do not derive them +- [x] Add independently reviewable step/curb candidates; do not derive them from a single global height threshold. - [x] Produce short-TTL observed-surface, `occupied` and `unknown` evidence. Do not infer `free` merely because a mapped point is absent. @@ -408,26 +409,47 @@ Dataset expansion is no longer the next gate. derivatives with independent decay and provenance. - [ ] Reuse the accepted camera-to-LiDAR projection as an optional semantic layer with source, confidence, freshness and conflict fields. -- [ ] Complete the qualification report with per-frame latency, point age, - temporal stability, obstacle preservation and memory growth. +- [x] Qualify next-frame prediction and temporal stability with the evaluated + frame excluded from prediction input. +- [ ] Complete the remaining qualification report with per-frame latency, + point age, obstacle preservation and memory growth. - [ ] Replay the same profiles through a bounded latest-wins live-shadow queue; no K1 command, navigation or safety authority is added. The implemented `missioncore.k1-local-surface/v1` derivative is reproducible through `experiments/perception/run_k1_local_surface.py` and is exposed read-only through `GET /api/v1/lidar/local-surfaces` plus the bound frame -endpoint. **Парк → Диагностика LiDAR** reuses the five RAVNOVES00 scene -selectors and shows the selected source frame as observed surface, observed -occupied-above-surface, negative outlier and unclassified evidence. The React -contract is provider-neutral; K1 remains a bound backend source rather than UI -implementation knowledge. +and timeline endpoints. **Парк → Диагностика LiDAR** reuses the five +RAVNOVES00 scene selectors and also exposes a clickable timeline over the +complete recording. The selected source frame shows observed surface, observed +occupied-above-surface, negative outlier, unclassified evidence and yellow +local-discontinuity candidates. The candidates are not semantic curb +recognition or ground truth. The React contract is provider-neutral; K1 remains +a bound backend source rather than UI implementation knowledge. The complete RAVNOVES00 run covered all `526/526` available LiDAR samples. There were zero stale-pose, insufficient-surface or failed-fit frames. Observed diagnostic distributions are: derived sensor-to-surface height `1.295 m` p50, roughness `0.054 m` p95, slope `3.172°` p95 and pose-binding age `19.113 ms` p95. These values describe this recording only; they are not calibration, -ground truth or a navigation gate. Free space remains unavailable. +ground truth or a navigation gate. + +The L2.6b qualification scores each available frame against a local plane built +only from the preceding TTL window; the frame being scored is excluded from +the prediction input. It produced `525` independent next-frame samples. The +distribution of per-frame median absolute residual has `0.038543 m` p50 and +`0.050398 m` p95. Prediction inlier fraction has `0.96354` p50 and `0.95133` +mean. The temporal comparison detected `12/525` strict-threshold jumps; height +delta is `0.022887 m` p95, slope delta `0.09572°` p95 and roughness delta +`0.003317 m` p95. + +The central surface is therefore repeatable at roughly four-centimetre median +error on this replay. The tail is not yet planner evidence: the distribution +of per-frame p95 residual has `0.41496 m` p95 and reaches `1.083 m`. Step/curb +candidates occur in every available frame (`68.5` cells and `83` points p50), +which proves that the review layer is active but also that it is broad and +still requires independent review. Free space remains unavailable; none of +these metrics grants navigation, command or safety authority. Exit: one immutable K1 session yields both a persistent reconstruction and a bounded local world state without hard-coded terrain height or scanner-side @@ -512,8 +534,10 @@ and rejects Patchwork++ because the small Ground-IoU gain came with unacceptable obstacle loss. Public-dataset ground qualification is therefore complete enough for the current decision. The first K1 local-surface replay slice now covers all available `RAVNOVES00` samples and is visible in the operator -interface. The highest-value immediate work is operator review, explicit -step/curb and temporal-stability qualification, followed by bounded live -shadow. Nvblox, raw-scan detectors and alternative SLAM remain optional later -gates because the current report contract does not carry their required -ray/timing semantics. +interface. Leave-current-frame-out temporal qualification now covers `525` +samples: the median surface error is stable, while the p95 tail remains too +large for a free-space claim. The highest-value immediate work is review of the +`12` temporal jumps and worst prediction tails, then a bounded live-shadow +queue and separate dynamic-observation layer. Nvblox, raw-scan detectors and +alternative SLAM remain optional later gates because the current report +contract does not carry their required ray/timing semantics. diff --git a/docs/14_LIDAR_DATASET_GATEWAY.md b/docs/14_LIDAR_DATASET_GATEWAY.md index 0c52994..ce63795 100644 --- a/docs/14_LIDAR_DATASET_GATEWAY.md +++ b/docs/14_LIDAR_DATASET_GATEWAY.md @@ -49,9 +49,11 @@ Implemented now: - a content-addressed `missioncore.k1-local-surface/v1` replay derivative over immutable `RAVNOVES00`, with dynamic height/slope/roughness/confidence, explicit pose-binding age and conservative observed occupied/unknown policy; +- leave-current-frame-out next-frame qualification over `525` samples, + temporal jump evidence and unverified local-discontinuity candidates; - a provider-neutral read-only local-surface view in **Парк → Диагностика LiDAR**, synchronized to the five existing - RAVNOVES00 scene selectors; + RAVNOVES00 scene selectors and a clickable complete-recording timeline; - worker storage admission for `D:\NDC_MISSIONCORE\datasets` and `/mnt/d/NDC_MISSIONCORE/datasets`; - a dedicated, honest dataset catalog in **Полигон → Датасеты**; @@ -83,7 +85,7 @@ Not implemented: - no model training or production promotion; - no RELLIS ROS bag admission, continuous synchronized playback or production promotion; -- no rolling-map implementation yet. +- no ray-cleared free-space or planner-authoritative rolling occupancy map. ## Product surface boundary diff --git a/src/k1link/compute/__init__.py b/src/k1link/compute/__init__.py index 868a2f4..b1291fd 100644 --- a/src/k1link/compute/__init__.py +++ b/src/k1link/compute/__init__.py @@ -111,6 +111,7 @@ from .lidar_local_surface import ( K1_LOCAL_SURFACE_FRAME_SCHEMA, K1_LOCAL_SURFACE_REPORT_SCHEMA, K1_LOCAL_SURFACE_SCHEMA, + K1_LOCAL_SURFACE_TIMELINE_SCHEMA, K1LocalSurfaceProfile, K1LocalSurfaceV1, build_k1_local_surface, @@ -209,6 +210,7 @@ __all__ = [ "K1_LOCAL_SURFACE_FRAME_SCHEMA", "K1_LOCAL_SURFACE_REPORT_SCHEMA", "K1_LOCAL_SURFACE_SCHEMA", + "K1_LOCAL_SURFACE_TIMELINE_SCHEMA", "LIDAR_FIELD_REVIEW_REPORT_SCHEMA", "LIDAR_FIELD_REVIEW_SCHEMA", "LIDAR_FIELD_REVIEW_WINDOW_SCHEMA", diff --git a/src/k1link/compute/lidar_local_surface.py b/src/k1link/compute/lidar_local_surface.py index d4609bf..01b6009 100644 --- a/src/k1link/compute/lidar_local_surface.py +++ b/src/k1link/compute/lidar_local_surface.py @@ -28,6 +28,7 @@ from .lidar_field_review import ( K1_LOCAL_SURFACE_SCHEMA: Final = "missioncore.k1-local-surface/v1" K1_LOCAL_SURFACE_REPORT_SCHEMA: Final = "missioncore.k1-local-surface-report/v1" K1_LOCAL_SURFACE_FRAME_SCHEMA: Final = "missioncore.k1-local-surface-frame/v1" +K1_LOCAL_SURFACE_TIMELINE_SCHEMA: Final = "missioncore.k1-local-surface-timeline/v1" K1_LOCAL_SURFACE_ARRAYS_NAME: Final = "local-surface.npz" K1_LOCAL_SURFACE_REPORT_NAME: Final = "local-surface.json" K1_LOCAL_SURFACE_MANIFEST_NAME: Final = "manifest.json" @@ -67,6 +68,12 @@ class K1LocalSurfaceProfile: obstacle_max_height_m: float = 3.5 maximum_pose_binding_ms: float = 100.0 maximum_slope_deg: float = 40.0 + step_min_height_m: float = 0.07 + step_max_height_m: float = 0.32 + step_max_plane_residual_m: float = 0.45 + temporal_height_jump_m: float = 0.03 + temporal_slope_jump_deg: float = 0.5 + temporal_roughness_jump_m: float = 0.015 def __post_init__(self) -> None: numeric = ( @@ -82,6 +89,12 @@ class K1LocalSurfaceProfile: self.obstacle_max_height_m, self.maximum_pose_binding_ms, self.maximum_slope_deg, + self.step_min_height_m, + self.step_max_height_m, + self.step_max_plane_residual_m, + self.temporal_height_jump_m, + self.temporal_slope_jump_deg, + self.temporal_roughness_jump_m, ) if ( not self.profile_id.strip() @@ -101,6 +114,11 @@ class K1LocalSurfaceProfile: or not self.obstacle_min_height_m < self.obstacle_max_height_m <= 20.0 or not 1.0 <= self.maximum_pose_binding_ms <= 10_000.0 or not 1.0 <= self.maximum_slope_deg < 90.0 + or not 0.02 <= self.step_min_height_m < self.step_max_height_m + or not self.step_max_height_m <= self.step_max_plane_residual_m <= 2.0 + or not 0.02 <= self.temporal_height_jump_m <= 2.0 + or not 0.1 <= self.temporal_slope_jump_deg <= 45.0 + or not 0.005 <= self.temporal_roughness_jump_m <= 1.0 ): raise LidarGroundError("K1 local-surface profile is invalid") @@ -125,6 +143,9 @@ class K1LocalSurfaceProfile: "robust_mad_scale": self.robust_mad_scale, "minimum_inlier_band_m": self.minimum_inlier_band_m, "maximum_slope_deg": self.maximum_slope_deg, + "step_min_height_m": self.step_min_height_m, + "step_max_height_m": self.step_max_height_m, + "step_max_plane_residual_m": self.step_max_plane_residual_m, }, "classification": { "surface_band_m": self.surface_band_m, @@ -137,6 +158,13 @@ class K1LocalSurfaceProfile: "basis": "recorded-nearest-host-monotonic-arrival", "maximum_age_ms": self.maximum_pose_binding_ms, }, + "temporal_qualification": { + "prediction_input": "previous-ttl-window-only", + "current_frame_excluded_from_prediction": True, + "height_jump_m": self.temporal_height_jump_m, + "slope_jump_deg": self.temporal_slope_jump_deg, + "roughness_jump_m": self.temporal_roughness_jump_m, + }, "authority": { "commands_enabled": False, "navigation_or_safety_accepted": False, @@ -186,7 +214,7 @@ class K1LocalSurfaceV1: def _validate(self) -> None: frame_count = _nonnegative_int(self.identity.get("frame_count"), "frame count") point_count = _nonnegative_int(self.identity.get("point_count"), "point count") - required = { + baseline = { "frame_valid", "frame_failure_code", "plane_coefficients_map", @@ -205,8 +233,25 @@ class K1LocalSurfaceV1: "point_class", "point_height_m", } - if set(self.arrays.files) != required: + qualification = { + "prediction_available", + "prediction_cell_count", + "prediction_residual_p50_m", + "prediction_residual_p95_m", + "prediction_inlier_fraction", + "height_delta_m", + "slope_delta_deg", + "roughness_delta_m", + "temporal_compared", + "temporal_jump", + "step_candidate_cell_count", + "step_candidate_point_count", + "point_step_candidate", + } + files = set(self.arrays.files) + if files not in (baseline, baseline | qualification): raise LidarGroundError("K1 local-surface arrays are incomplete") + self.has_temporal_qualification = qualification <= files vector_f64 = ( "sensor_height_m", "slope_deg", @@ -261,6 +306,63 @@ class K1LocalSurfaceV1: or np.any(value < 0) ): raise LidarGroundError(f"K1 local-surface {name} is invalid") + if self.has_temporal_qualification: + qualification_f64 = ( + "prediction_residual_p50_m", + "prediction_residual_p95_m", + "prediction_inlier_fraction", + "height_delta_m", + "slope_delta_deg", + "roughness_delta_m", + ) + qualification_i64 = ( + "prediction_cell_count", + "step_candidate_cell_count", + "step_candidate_point_count", + ) + for name in qualification_f64: + value = self.arrays[name] + if ( + value.shape != (frame_count,) + or value.dtype != np.dtype(" 1) + or np.any( + self.arrays["prediction_inlier_fraction"][ + self.arrays["prediction_available"] + ] + > 1 + ) + ): + raise LidarGroundError("K1 local-surface step candidates are invalid") valid = self.arrays["frame_valid"] if ( np.any(self.arrays["frame_failure_code"][valid] != FRAME_VALID) @@ -270,6 +372,40 @@ class K1LocalSurfaceV1: or self.identity.get("valid_frame_count") != int(np.count_nonzero(valid)) ): raise LidarGroundError("K1 local-surface frame validity is inconsistent") + if self.has_temporal_qualification: + metrics = _object( + self.report.get("metrics"), + "K1 local-surface metrics", + ) + qualification_report = _object( + metrics.get("temporal_qualification"), + "K1 local-surface temporal qualification", + ) + prediction_report = _object( + qualification_report.get("prediction"), + "K1 local-surface prediction report", + ) + stability_report = _object( + qualification_report.get("stability"), + "K1 local-surface stability report", + ) + step_report = _object( + qualification_report.get("step_candidates"), + "K1 local-surface step report", + ) + if ( + prediction_report.get("current_frame_excluded") is not True + or prediction_report.get("sample_count") + != int(np.count_nonzero(self.arrays["prediction_available"])) + or stability_report.get("sample_count") + != int(np.count_nonzero(self.arrays["temporal_compared"])) + or stability_report.get("jump_count") + != int(np.count_nonzero(self.arrays["temporal_jump"])) + or step_report.get("is_ground_truth") is not False + ): + raise LidarGroundError( + "K1 local-surface temporal report is inconsistent" + ) authority = _object(self.report.get("authority"), "K1 local-surface authority") policy = _object(self.report.get("occupancy_policy"), "K1 local-surface policy") if ( @@ -292,17 +428,27 @@ class K1LocalSurfaceV1: start = int(offsets[frame_index]) end = int(offsets[frame_index + 1]) point_class = self.arrays["point_class"][start:end] + if self.has_temporal_qualification: + step_candidate = self.arrays["point_step_candidate"][start:end] + else: + step_candidate = np.zeros(end - start, dtype=np.uint8) counts = { "classified": int(np.count_nonzero(point_class)), "surface": int(np.count_nonzero(point_class == POINT_SURFACE)), "occupied": int(np.count_nonzero(point_class == POINT_OCCUPIED)), "below_surface": int(np.count_nonzero(point_class == POINT_BELOW_SURFACE)), + "step_candidate": int(np.count_nonzero(step_candidate)), } expected = { "classified": int(self.arrays["classified_point_count"][frame_index]), "surface": int(self.arrays["surface_point_count"][frame_index]), "occupied": int(self.arrays["occupied_point_count"][frame_index]), "below_surface": int(self.arrays["below_surface_point_count"][frame_index]), + "step_candidate": ( + int(self.arrays["step_candidate_point_count"][frame_index]) + if self.has_temporal_qualification + else 0 + ), } if counts != expected: raise LidarGroundError("K1 local-surface frame counts are inconsistent") @@ -328,6 +474,7 @@ class K1LocalSurfaceV1: "point_height_m": self.arrays["point_height_m"][start:end] .astype(np.float64) .tolist(), + "point_step_candidate": step_candidate.astype(np.int64).tolist(), "pose": { "position_xyz_m": source.arrays["pose_positions_map"][frame_index] .astype(np.float64) @@ -356,6 +503,66 @@ class K1LocalSurfaceV1: ), }, "counts": counts, + "prediction": { + "available": ( + bool(self.arrays["prediction_available"][frame_index]) + if self.has_temporal_qualification + else False + ), + "current_frame_excluded": True, + "cell_count": ( + int(self.arrays["prediction_cell_count"][frame_index]) + if self.has_temporal_qualification + else 0 + ), + "residual_p50_m": ( + float(self.arrays["prediction_residual_p50_m"][frame_index]) + if self.has_temporal_qualification + else 0.0 + ), + "residual_p95_m": ( + float(self.arrays["prediction_residual_p95_m"][frame_index]) + if self.has_temporal_qualification + else 0.0 + ), + "inlier_fraction": ( + float(self.arrays["prediction_inlier_fraction"][frame_index]) + if self.has_temporal_qualification + else 0.0 + ), + }, + "temporal": { + "compared": ( + bool(self.arrays["temporal_compared"][frame_index]) + if self.has_temporal_qualification + else False + ), + "height_delta_m": ( + float(self.arrays["height_delta_m"][frame_index]) + if self.has_temporal_qualification + else 0.0 + ), + "slope_delta_deg": ( + float(self.arrays["slope_delta_deg"][frame_index]) + if self.has_temporal_qualification + else 0.0 + ), + "roughness_delta_m": ( + float(self.arrays["roughness_delta_m"][frame_index]) + if self.has_temporal_qualification + else 0.0 + ), + "jump": ( + bool(self.arrays["temporal_jump"][frame_index]) + if self.has_temporal_qualification + else False + ), + "step_candidate_cell_count": ( + int(self.arrays["step_candidate_cell_count"][frame_index]) + if self.has_temporal_qualification + else 0 + ), + }, "classes": { "0": "unclassified-or-outside-local-radius", "1": "observed-surface", @@ -368,6 +575,62 @@ class K1LocalSurfaceV1: "authority": self.report["authority"], } + def timeline_detail(self, source: E10LidarFieldSource) -> dict[str, object]: + _validate_source_binding(self, source) + frame_count = source.frame_count + if self.has_temporal_qualification: + prediction_available = self.arrays["prediction_available"] + temporal_compared = self.arrays["temporal_compared"] + temporal_jump = self.arrays["temporal_jump"] + prediction_p50 = self.arrays["prediction_residual_p50_m"] + prediction_p95 = self.arrays["prediction_residual_p95_m"] + prediction_inlier = self.arrays["prediction_inlier_fraction"] + height_delta = self.arrays["height_delta_m"] + slope_delta = self.arrays["slope_delta_deg"] + roughness_delta = self.arrays["roughness_delta_m"] + step_points = self.arrays["step_candidate_point_count"] + else: + prediction_available = np.zeros(frame_count, dtype=np.bool_) + temporal_compared = np.zeros(frame_count, dtype=np.bool_) + temporal_jump = np.zeros(frame_count, dtype=np.bool_) + prediction_p50 = np.zeros(frame_count, dtype=np.float64) + prediction_p95 = np.zeros(frame_count, dtype=np.float64) + prediction_inlier = np.zeros(frame_count, dtype=np.float64) + height_delta = np.zeros(frame_count, dtype=np.float64) + slope_delta = np.zeros(frame_count, dtype=np.float64) + roughness_delta = np.zeros(frame_count, dtype=np.float64) + step_points = np.zeros(frame_count, dtype=np.int64) + return { + "schema_version": K1_LOCAL_SURFACE_TIMELINE_SCHEMA, + "model_id": self.model_id, + "source_pack_id": source.pack_id, + "session_id": source.identity["session_id"], + "frame_count": frame_count, + "source_frame_index": source.arrays["source_frame_indices"] + .astype(np.int64) + .tolist(), + "session_seconds": source.arrays["session_seconds"].astype(np.float64).tolist(), + "source_available": source.arrays["sample_available"].astype(np.int64).tolist(), + "valid": self.arrays["frame_valid"].astype(np.int64).tolist(), + "prediction_available": prediction_available.astype(np.int64).tolist(), + "prediction_residual_p50_m": prediction_p50.astype(np.float64).tolist(), + "prediction_residual_p95_m": prediction_p95.astype(np.float64).tolist(), + "prediction_inlier_fraction": prediction_inlier.astype(np.float64).tolist(), + "sensor_height_m": self.arrays["sensor_height_m"].astype(np.float64).tolist(), + "slope_deg": self.arrays["slope_deg"].astype(np.float64).tolist(), + "roughness_m": self.arrays["roughness_m"].astype(np.float64).tolist(), + "confidence": self.arrays["confidence"].astype(np.float64).tolist(), + "temporal_compared": temporal_compared.astype(np.int64).tolist(), + "height_delta_m": height_delta.astype(np.float64).tolist(), + "slope_delta_deg": slope_delta.astype(np.float64).tolist(), + "roughness_delta_m": roughness_delta.astype(np.float64).tolist(), + "temporal_jump": temporal_jump.astype(np.int64).tolist(), + "step_candidate_point_count": step_points.astype(np.int64).tolist(), + "ground_truth": False, + "access": "read-only", + "authority": self.report["authority"], + } + def build_k1_local_surface( source: E10LidarFieldSource, @@ -392,6 +655,7 @@ def build_k1_local_surface( times = source_arrays["session_seconds"] pose_delta = np.abs(source_arrays["pose_point_delta_ms"]) cache: dict[tuple[int, int], tuple[float, float]] = {} + previous_surface: tuple[float, float, float, float] | None = None for frame_index in range(frame_count): start = int(offsets[frame_index]) @@ -416,8 +680,27 @@ def build_k1_local_surface( ) local_cloud = cloud[local] _expire_cache(cache, session_seconds, position, profile) + _, prior_cell_points, _ = _local_cache_records(cache, position, profile) + _, current_cell_points = _cloud_cell_observations(local_cloud, profile) + prediction = _prediction_metrics( + prior_cell_points, + current_cell_points, + position, + profile, + ) + if prediction is not None: + residual_p50, residual_p95, inlier_fraction, prediction_cells = prediction + arrays["prediction_available"][frame_index] = True + arrays["prediction_cell_count"][frame_index] = prediction_cells + arrays["prediction_residual_p50_m"][frame_index] = residual_p50 + arrays["prediction_residual_p95_m"][frame_index] = residual_p95 + arrays["prediction_inlier_fraction"][frame_index] = inlier_fraction _update_cache(cache, local_cloud, session_seconds, profile) - cell_points, cell_times = _local_cache_points(cache, position, profile) + cell_keys, cell_points, cell_times = _local_cache_records( + cache, + position, + profile, + ) arrays["surface_cell_count"][frame_index] = cell_points.shape[0] if cell_points.shape[0] < profile.minimum_surface_cells: arrays["frame_failure_code"][frame_index] = FRAME_INSUFFICIENT_SURFACE @@ -442,6 +725,14 @@ def build_k1_local_surface( & (heights <= profile.obstacle_max_height_m) ] = POINT_OCCUPIED local_classes[local & (heights < -profile.surface_band_m)] = POINT_BELOW_SURFACE + step_keys = _step_candidate_keys(cell_keys, cell_points, plane, profile) + step_candidate = _point_step_candidates( + cloud, + local, + heights, + step_keys, + profile, + ) point_heights = np.zeros(cloud.shape[0], dtype=np.float32) point_heights[local] = heights[local].astype(np.float32) sensor_height = float(_height_above_plane(position.reshape(1, 3), plane)[0]) @@ -453,6 +744,23 @@ def build_k1_local_surface( 1.0 - float(pose_delta[frame_index]) / profile.maximum_pose_binding_ms, ) confidence = float(np.clip(coverage * roughness_confidence * pose_confidence, 0.0, 1.0)) + if ( + previous_surface is not None + and session_seconds - previous_surface[0] <= profile.surface_ttl_s + ): + height_delta = abs(sensor_height - previous_surface[1]) + slope_delta = abs(slope_deg - previous_surface[2]) + roughness_delta = abs(roughness - previous_surface[3]) + arrays["height_delta_m"][frame_index] = height_delta + arrays["slope_delta_deg"][frame_index] = slope_delta + arrays["roughness_delta_m"][frame_index] = roughness_delta + arrays["temporal_compared"][frame_index] = True + arrays["temporal_jump"][frame_index] = ( + height_delta > profile.temporal_height_jump_m + or slope_delta > profile.temporal_slope_jump_deg + or roughness_delta > profile.temporal_roughness_jump_m + ) + previous_surface = (session_seconds, sensor_height, slope_deg, roughness) arrays["frame_valid"][frame_index] = True arrays["frame_failure_code"][frame_index] = FRAME_VALID arrays["plane_coefficients_map"][frame_index] = plane @@ -467,6 +775,7 @@ def build_k1_local_surface( arrays["surface_inlier_cell_count"][frame_index] = int(np.count_nonzero(inliers)) arrays["point_class"][start:end] = local_classes arrays["point_height_m"][start:end] = point_heights + arrays["point_step_candidate"][start:end] = step_candidate arrays["classified_point_count"][frame_index] = int( np.count_nonzero(local_classes) ) @@ -479,6 +788,10 @@ def build_k1_local_surface( arrays["below_surface_point_count"][frame_index] = int( np.count_nonzero(local_classes == POINT_BELOW_SURFACE) ) + arrays["step_candidate_cell_count"][frame_index] = len(step_keys) + arrays["step_candidate_point_count"][frame_index] = int( + np.count_nonzero(step_candidate) + ) logical_content_sha256 = _logical_sha256(arrays) valid = arrays["frame_valid"] @@ -579,6 +892,58 @@ def build_k1_local_surface( "surface_max_age_ms": _valid_distribution( arrays["surface_max_age_ms"], valid ), + "temporal_qualification": { + "prediction": { + "current_frame_excluded": True, + "sample_count": int( + np.count_nonzero(arrays["prediction_available"]) + ), + "residual_p50_m": _valid_distribution( + arrays["prediction_residual_p50_m"], + arrays["prediction_available"], + ), + "residual_p95_m": _valid_distribution( + arrays["prediction_residual_p95_m"], + arrays["prediction_available"], + ), + "inlier_fraction": _valid_distribution( + arrays["prediction_inlier_fraction"], + arrays["prediction_available"], + ), + }, + "stability": { + "sample_count": int( + np.count_nonzero(arrays["temporal_compared"]) + ), + "height_delta_m": _valid_distribution( + arrays["height_delta_m"], + arrays["temporal_compared"], + ), + "slope_delta_deg": _valid_distribution( + arrays["slope_delta_deg"], + arrays["temporal_compared"], + ), + "roughness_delta_m": _valid_distribution( + arrays["roughness_delta_m"], + arrays["temporal_compared"], + ), + "jump_count": int(np.count_nonzero(arrays["temporal_jump"])), + }, + "step_candidates": { + "is_ground_truth": False, + "frames_with_candidates": int( + np.count_nonzero(arrays["step_candidate_cell_count"] > 0) + ), + "cell_count": _valid_distribution( + arrays["step_candidate_cell_count"].astype(np.float64), + valid, + ), + "point_count": _valid_distribution( + arrays["step_candidate_point_count"].astype(np.float64), + valid, + ), + }, + }, "build_elapsed_ms": (time.perf_counter() - started) * 1_000.0, }, "anchors": _anchors(source, valid), @@ -666,8 +1031,21 @@ def _empty_arrays( "surface_point_count": np.zeros(frame_count, dtype=" None: + keys, points = _cloud_cell_observations(cloud, profile) + for key, point in zip(keys, points, strict=True): + cache[(int(key[0]), int(key[1]))] = (float(point[2]), session_seconds) + + +def _cloud_cell_observations( + cloud: npt.NDArray[np.float64], + profile: K1LocalSurfaceProfile, +) -> tuple[npt.NDArray[np.int64], npt.NDArray[np.float64]]: if cloud.shape[0] == 0: - return + return np.empty((0, 2), dtype=np.int64), np.empty((0, 3), dtype=np.float64) cells = np.floor(cloud[:, :2] / profile.cell_size_m).astype(np.int64) order = np.lexsort((cells[:, 1], cells[:, 0])) sorted_cells = cells[order] @@ -686,10 +1073,17 @@ def _update_cache( changes = np.flatnonzero(np.any(np.diff(sorted_cells, axis=0) != 0, axis=1)) + 1 starts = np.concatenate((np.asarray([0]), changes)) ends = np.concatenate((changes, np.asarray([cloud.shape[0]]))) - for start, end in zip(starts, ends, strict=True): - key = (int(sorted_cells[start, 0]), int(sorted_cells[start, 1])) - z = float(np.percentile(sorted_z[start:end], profile.cell_lower_percentile)) - cache[key] = (z, session_seconds) + keys = np.empty((starts.shape[0], 2), dtype=np.int64) + points = np.empty((starts.shape[0], 3), dtype=np.float64) + half_cell = profile.cell_size_m * 0.5 + for index, (start, end) in enumerate(zip(starts, ends, strict=True)): + keys[index] = sorted_cells[start] + points[index] = ( + float(sorted_cells[start, 0]) * profile.cell_size_m + half_cell, + float(sorted_cells[start, 1]) * profile.cell_size_m + half_cell, + float(np.percentile(sorted_z[start:end], profile.cell_lower_percentile)), + ) + return keys, points def _expire_cache( @@ -717,11 +1111,15 @@ def _expire_cache( del cache[key] -def _local_cache_points( +def _local_cache_records( cache: Mapping[tuple[int, int], tuple[float, float]], position: npt.NDArray[np.float64], profile: K1LocalSurfaceProfile, -) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]: +) -> tuple[ + npt.NDArray[np.int64], + npt.NDArray[np.float64], + npt.NDArray[np.float64], +]: values = [ ( (key[0] + 0.5) * profile.cell_size_m, @@ -737,9 +1135,14 @@ def _local_cache_points( ) ] if not values: - return np.empty((0, 3), dtype=np.float64), np.empty(0, dtype=np.float64) + return ( + np.empty((0, 2), dtype=np.int64), + np.empty((0, 3), dtype=np.float64), + np.empty(0, dtype=np.float64), + ) array = np.asarray(values, dtype=np.float64) - return array[:, :3], array[:, 3] + keys = np.floor(array[:, :2] / profile.cell_size_m).astype(np.int64) + return keys, array[:, :3], array[:, 3] def _fit_surface( @@ -809,6 +1212,92 @@ def _fit_surface( return plane.astype(" tuple[float, float, float, int] | None: + """Score current lower-cell evidence against a plane built without that frame.""" + + if ( + prior_cell_points.shape[0] < profile.minimum_surface_cells + or current_cell_points.shape[0] < profile.minimum_surface_cells + ): + return None + prior_fit = _fit_surface(prior_cell_points, position, profile) + if prior_fit is None: + return None + prior_plane, _, _ = prior_fit + cutoff = float( + np.quantile(current_cell_points[:, 2], profile.initial_lower_fraction) + ) + evaluation = current_cell_points[:, 2] <= cutoff + if int(np.count_nonzero(evaluation)) < profile.minimum_surface_cells: + return None + residual = np.abs( + _height_above_plane(current_cell_points[evaluation], prior_plane) + ) + if residual.size == 0 or not np.isfinite(residual).all(): + return None + return ( + float(np.percentile(residual, 50)), + float(np.percentile(residual, 95)), + float(np.mean(residual <= profile.surface_band_m)), + int(residual.shape[0]), + ) + + +def _step_candidate_keys( + cell_keys: npt.NDArray[np.int64], + cell_points: npt.NDArray[np.float64], + plane: npt.NDArray[np.float64], + profile: K1LocalSurfaceProfile, +) -> set[tuple[int, int]]: + """Find local discontinuities; the result remains an unverified candidate.""" + + if cell_keys.shape[0] != cell_points.shape[0]: + raise LidarGroundError("K1 local-surface cell alignment is invalid") + residual = _height_above_plane(cell_points, plane) + lookup = { + (int(key[0]), int(key[1])): float(value) + for key, value in zip(cell_keys, residual, strict=True) + if abs(float(value)) <= profile.step_max_plane_residual_m + } + candidates: set[tuple[int, int]] = set() + for key, value in lookup.items(): + for neighbor in ((key[0] + 1, key[1]), (key[0], key[1] + 1)): + neighbor_value = lookup.get(neighbor) + if neighbor_value is None: + continue + delta = abs(value - neighbor_value) + if profile.step_min_height_m <= delta <= profile.step_max_height_m: + candidates.add(key) + candidates.add(neighbor) + return candidates + + +def _point_step_candidates( + cloud: npt.NDArray[np.float64], + local: npt.NDArray[np.bool_], + heights: npt.NDArray[np.float64], + candidate_keys: set[tuple[int, int]], + profile: K1LocalSurfaceProfile, +) -> npt.NDArray[np.uint8]: + result = np.zeros(cloud.shape[0], dtype=np.uint8) + if not candidate_keys: + return result + cells = np.floor(cloud[:, :2] / profile.cell_size_m).astype(np.int64) + for index in np.flatnonzero(local): + key = (int(cells[index, 0]), int(cells[index, 1])) + if ( + key in candidate_keys + and abs(float(heights[index])) <= profile.step_max_plane_residual_m + ): + result[index] = 1 + return result + + def _height_above_plane( points: npt.NDArray[np.float64], plane: npt.NDArray[np.float64], diff --git a/src/k1link/web/lidar_api.py b/src/k1link/web/lidar_api.py index 0812bc5..9d76c25 100644 --- a/src/k1link/web/lidar_api.py +++ b/src/k1link/web/lidar_api.py @@ -545,6 +545,61 @@ def build_lidar_router( "access": "read-only", } + @router.get("/local-surfaces/{model_id}/timeline") + def get_k1_local_surface_timeline(model_id: str) -> dict[str, object]: + if _LOCAL_SURFACE_ID.fullmatch(model_id) is None: + raise HTTPException( + status_code=404, + detail="K1 local-surface timeline не найден", + ) + model_root = local_surface_root_provider() + source_root = e10_source_root_provider() + if model_root is None or not model_root.is_dir(): + raise HTTPException( + status_code=503, + detail="K1 local-surface storage не настроен", + ) + if source_root is None or not source_root.is_dir(): + raise HTTPException( + status_code=503, + detail="E10 LiDAR source storage не настроен", + ) + model_path = model_root / model_id + if not model_path.is_dir(): + raise HTTPException( + status_code=404, + detail="K1 local-surface model не найден", + ) + try: + model = K1LocalSurfaceV1(model_path) + try: + source_pack_id = model.identity.get("source_pack_id") + if ( + not isinstance(source_pack_id, str) + or _E10_PACK_ID.fullmatch(source_pack_id) is None + ): + raise LidarGroundError("K1 local-surface source id is invalid") + source_path = source_root / source_pack_id + if not source_path.is_dir(): + raise HTTPException( + status_code=404, + detail="Связанный E10 LiDAR source не найден", + ) + source = E10LidarFieldSource(source_path) + try: + return model.timeline_detail(source) + finally: + source.close() + finally: + model.close() + except HTTPException: + raise + except (LidarGroundError, OSError) as exc: + raise HTTPException( + status_code=409, + detail="K1 local-surface timeline не прошёл проверку целостности", + ) from exc + @router.get("/local-surfaces/{model_id}/frames/{frame_index}") def get_k1_local_surface_frame( model_id: str, diff --git a/tests/test_lidar_local_surface.py b/tests/test_lidar_local_surface.py index 76dd55d..296268a 100644 --- a/tests/test_lidar_local_surface.py +++ b/tests/test_lidar_local_surface.py @@ -164,10 +164,21 @@ def test_k1_local_surface_is_dynamic_source_bound_and_read_only( assert model.report["surface_model"]["hardcoded_height_m"] is None assert model.report["occupancy_policy"]["absence_of_points_means_free"] is False assert model.report["metrics"]["frames"]["valid"] >= 5 + temporal = model.report["metrics"]["temporal_qualification"] + assert temporal["prediction"]["current_frame_excluded"] is True + assert temporal["prediction"]["sample_count"] >= 4 + assert temporal["prediction"]["residual_p95_m"]["p95"] < 0.05 + assert temporal["stability"]["sample_count"] >= 4 + assert temporal["step_candidates"]["is_ground_truth"] is False + assert temporal["step_candidates"]["frames_with_candidates"] > 0 assert detail["valid"] is True assert 1.2 < detail["surface"]["sensor_height_m"] < 1.6 assert detail["counts"]["surface"] > 50 assert detail["counts"]["occupied"] > 0 + assert detail["counts"]["step_candidate"] > 0 + assert detail["prediction"]["current_frame_excluded"] is True + assert detail["prediction"]["available"] is True + assert detail["temporal"]["compared"] is True assert detail["authority"]["commands_enabled"] is False assert "1.27" not in repr({"identity": model.identity, "report": model.report}) assert str(tmp_path) not in repr(detail) @@ -187,9 +198,18 @@ def test_k1_local_surface_is_dynamic_source_bound_and_read_only( router, "/api/v1/lidar/local-surfaces/{model_id}/frames/{frame_index}", ) + timeline_route = _endpoint( + router, + "/api/v1/lidar/local-surfaces/{model_id}/timeline", + ) catalog = catalog_route(limit=10) # type: ignore[operator] frame = frame_route(model_id=output.name, frame_index=2) # type: ignore[operator] + timeline = timeline_route(model_id=output.name) # type: ignore[operator] assert catalog["valid_total"] == 1 assert catalog["items"][0]["status"] == "diagnostic-only" assert frame["model_id"] == output.name assert frame["access"] == "read-only" + assert timeline["frame_count"] == 8 + assert sum(timeline["prediction_available"]) >= 4 + assert len(timeline["temporal_jump"]) == 8 + assert str(tmp_path) not in repr(timeline)