diff --git a/apps/control-station/src/App.tsx b/apps/control-station/src/App.tsx index 8f3945f..b75b7cf 100644 --- a/apps/control-station/src/App.tsx +++ b/apps/control-station/src/App.tsx @@ -27,6 +27,8 @@ import { SystemNavigationPanel } from "./components/system/SystemNavigationPanel import { SystemWorkspaceSelector } from "./components/system/SystemWorkspaceSelector"; import { useComputeContourSettings } from "./components/system/useComputeContourSettings"; import { useApplicationPanelActions } from "./components/useApplicationPanelActions"; +import { useSessionOverviewMode } from "./core/observation/useSessionOverview"; +import { SessionOverviewWorkspace } from "./workspaces/recordings/SessionOverviewWorkspace"; import { useEnvironmentSettings } from "./core/environment/useEnvironmentSettings"; import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost"; import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext"; @@ -150,6 +152,7 @@ export default function App() { ); const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false); const [sourceUrl, setSourceUrl] = useState(""); + const [workspaceHeaderToolsHost, setWorkspaceHeaderToolsHost] = useState(null); const [recordedReplay, setRecordedReplay] = useState(null); const [recordedReplayLabel, setRecordedReplayLabel] = useState(null); const [replayTransitioning, setReplayTransitioning] = useState(false); @@ -609,6 +612,7 @@ export default function App() { }, [layoutSaveNotice]); const [fleetCreateRequest, setFleetCreateRequest] = useState(0); + const sessionOverview = useSessionOverviewMode(activeDefinition?.kind === "recordings"); const onAddVehicle = useCallback(() => setFleetCreateRequest(value => value + 1), []); const contentActions = useApplicationPanelActions({ onAddVehicle, @@ -619,6 +623,7 @@ export default function App() { saveWorkspaceLayout, workspaceLayoutSaving: workspaceLayoutProfile.state === "saving", systemUtilityActions: computeContourSettings.utilityActions, + sessionOverview: { ...sessionOverview, available: Boolean(recordedReplay && replayPresented && !sourceSwitchBlocked) }, }); const header = ( @@ -781,6 +786,8 @@ export default function App() { settleRecordedReplaySwitch(outcome)} onDeleteBegin={releaseRecordedReplayForDelete} /> + ) : activeDefinition.kind === "missions" ? ( +
) : activeDefinition.kind === "vehicles" ? null : activeDefinition.kind === "datasets" ? ( Offline evaluation ) : activeDefinition.kind === "lab-archive" ? ( @@ -804,10 +811,13 @@ export default function App() { onOpenSpatialScene={() => openView("spatial-scene")} onActivateAutomaticSpatialSource={activateAutomaticSpatialSource} /> + ) : activeDefinition.kind === "recordings" && sessionOverview.open && recordedReplay && replayPresented ? ( + ) : ( void) | null = null; let objectUrl = ""; let retryTimer: number | undefined; + let bufferingNoticeTimer: number | undefined; let receivedMedia = false; let failed = false; let playingReported = false; @@ -295,10 +297,15 @@ export function MseFmp4WebSocketPlayer({ ); const queue: ArrayBuffer[] = []; let queuedBytes = 0; + const clearBufferingNotice = () => { + if (bufferingNoticeTimer !== undefined) window.clearTimeout(bufferingNoticeTimer); + bufferingNoticeTimer = undefined; + }; const fail = (copy: string) => { if (!transportIsCurrent() || failed) return; startupWatchdog?.clear(); + clearBufferingNotice(); failed = true; transportHealthyRef.current = false; recoveryPendingAuthorityRef.current = null; @@ -335,6 +342,7 @@ export function MseFmp4WebSocketPlayer({ return; } startupWatchdog?.clear(); + clearBufferingNotice(); failed = true; transportHealthyRef.current = false; const retry = consumeCameraLeaseRetry(leaseRetryRef.current, delivery.id); @@ -392,6 +400,7 @@ export function MseFmp4WebSocketPlayer({ const onPlaying = () => { if (!transportIsCurrent() || failed) return; + clearBufferingNotice(); startupWatchdog?.markPlaying(); transportHealthyRef.current = true; leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id); @@ -410,7 +419,21 @@ export function MseFmp4WebSocketPlayer({ } }; + const onWaiting = () => { + if (!transportIsCurrent() || failed || !playingReported) return; + // A short receipt gap must not flicker or restart the acquisition-owned + // stream. A sustained decoder wait must not retain a playing presentation. + if (bufferingNoticeTimer !== undefined) return; + bufferingNoticeTimer = window.setTimeout(() => { + bufferingNoticeTimer = undefined; + if (!transportIsCurrent() || failed || video.readyState >= 3) return; + setStatus("buffering"); + setMessage("Ожидание новых кадров."); + }, CAMERA_BUFFERING_NOTICE_DELAY_MS); + }; video.addEventListener("playing", onPlaying); + video.addEventListener("waiting", onWaiting); + video.addEventListener("stalled", onWaiting); const appendNext = () => { if ( @@ -594,10 +617,13 @@ export function MseFmp4WebSocketPlayer({ disposed = true; transportHealthyRef.current = false; startupWatchdog?.clear(); + clearBufferingNotice(); if (retryTimer !== undefined) window.clearTimeout(retryTimer); queue.length = 0; socket?.close(1000, "Browser preview transport replaced or hidden"); video.removeEventListener("playing", onPlaying); + video.removeEventListener("waiting", onWaiting); + video.removeEventListener("stalled", onWaiting); mediaSource.removeEventListener("sourceopen", onSourceOpen); if (sourceBuffer && onBufferUpdateEnd) { sourceBuffer.removeEventListener("updateend", onBufferUpdateEnd); @@ -708,7 +734,7 @@ export function MseFmp4WebSocketPlayer({ {status !== "playing" ? (
- {status === "error" ? "Канал прерван" : "Подготовка камеры"} + {status === "error" ? "Канал прерван" : status === "buffering" ? "Ожидание изображения" : "Подготовка камеры"} {message} {status === "error" ? ( + +
; +} diff --git a/apps/control-station/src/components/missions/MissionRoutePreview.tsx b/apps/control-station/src/components/missions/MissionRoutePreview.tsx new file mode 100644 index 0000000..a9bba31 --- /dev/null +++ b/apps/control-station/src/components/missions/MissionRoutePreview.tsx @@ -0,0 +1,29 @@ +import { useEffect, useMemo, useState } from "react"; +import { RangeControl } from "@nodedc/ui-react"; +import { routeLength, type PlanningPose, type PlanningSource } from "../../core/missions/planner"; +/** Equal-axis plan projection; the moving marker is an archive pose, not localization. */ +export function MissionRoutePreview({ source, poses }: { source: PlanningSource; poses: PlanningPose[] }) { + const [cursor, setCursor] = useState(0); + useEffect(() => setCursor(0), [poses]); + const projection = useMemo(() => { + let xmin = Infinity, xmax = -Infinity, ymin = Infinity, ymax = -Infinity; + for (const p of (poses.length ? poses : source.poses)) { xmin = Math.min(xmin, p.position[0]); xmax = Math.max(xmax, p.position[0]); ymin = Math.min(ymin, p.position[1]); ymax = Math.max(ymax, p.position[1]); } + const scale = Math.min(720 / Math.max(1, xmax - xmin), 400 / Math.max(1, ymax - ymin)); + const project = (p: PlanningPose) => [400 + (p.position[0] - (xmin + xmax) / 2) * scale, 240 - (p.position[1] - (ymin + ymax) / 2) * scale]; + const line = (items: PlanningPose[]) => items.filter((_, i) => i % Math.max(1, Math.ceil(items.length / 5000)) === 0 || i === items.length - 1).map(p => project(p).join(",")).join(" "); + return { project, reference: line(source.poses), selected: line(poses), scale }; + }, [source, poses]); + const point = poses[Math.min(cursor, poses.length - 1)]; + if (!point) return
Выберите участок траектории.
; + const [x, y] = projection.project(point); + return

Выбранный маршрут · вид сверху

+ + + + + {(100 / projection.scale).toFixed(1)} м +

Путь: {routeLength(poses).toFixed(2)} м · {poses.length.toLocaleString("ru-RU")} положений · X/Y, масштаб осей одинаковый

+ `${n} / ${poses.length}`} onChange={n => setCursor(n - 1)} /> +

Кадр {point.index + 1} · X {point.position[0].toFixed(2)} · Y {point.position[1].toFixed(2)} · Z {point.position[2].toFixed(2)} м

+
; +} diff --git a/apps/control-station/src/components/missions/MissionZonePreview.tsx b/apps/control-station/src/components/missions/MissionZonePreview.tsx new file mode 100644 index 0000000..43a73ce --- /dev/null +++ b/apps/control-station/src/components/missions/MissionZonePreview.tsx @@ -0,0 +1,12 @@ +import type {ReactNode} from "react"; +import { Button, LoadingRegion } from "@nodedc/ui-react"; +import { useSessionOverview } from "../../core/observation/useSessionOverview"; +import { SessionOverviewScene } from "../observation/SessionOverviewScene"; +export function MissionZonePreview({ sessionId, toolbar }: { sessionId: string; toolbar?:ReactNode }) { + const { data, error, retry } = useSessionOverview(sessionId); + const pending = !error && (!data || data.state === "queued" || data.state === "preparing"); + const failure = error || (data?.state === "error" ? data.message : null); + if (pending) return ; + if (failure || !data?.scene_url) return
{failure || "Облако записи недоступно."}
; + return ; +} diff --git a/apps/control-station/src/components/missions/PlanningConnectionWindow.tsx b/apps/control-station/src/components/missions/PlanningConnectionWindow.tsx new file mode 100644 index 0000000..1cda799 --- /dev/null +++ b/apps/control-station/src/components/missions/PlanningConnectionWindow.tsx @@ -0,0 +1,32 @@ +import {useEffect,useState,type ReactNode} from 'react'; +import {Button,LoadingRegion,StatusBadge,Window} from '@nodedc/ui-react'; +import {useDevicePluginHost} from '../../core/device-plugins/DevicePluginHost'; +import {planningTestTerminal,usePlanningTest} from '../../core/missions/PlanningTestContext'; + +/** Wrap the existing plugin connection flow, retaining its acquisition authority. */ +export function PlanningConnectionWindow({children}:{children:ReactNode}){ + const p=usePlanningTest(); const [open,setOpen]=useState(true); + const {selection,registry,selectModel,selectionTransitionPending}=useDevicePluginHost(); + useEffect(()=>{ + if(!p.selected||!p.test||selection||selectionTransitionPending)return; + const models=registry.models.filter(m=>m.plugin.manifest.metadata.id===p.test!.plugin_id); + if(models.length===1)void selectModel(models[0].model.id); + },[p.selected,p.test?.plugin_id,selection,registry,selectModel,selectionTransitionPending]); + if(!p.selected||!p.test)return <>{children}; + const t=p.test, terminal=planningTestTerminal(t); + const details=<>Профиль · Планирование +

{t.draft.name} · эталон {t.draft.zone.label} · {t.draft.route.length_m.toFixed(1)} м

+

Укажите новое имя проекта сканера. Неподвижная калибровка просматривает весь выбранный маршрут; после калибровки сканера оставайтесь на месте до статуса «Сопровождение». Если совпадение не найдено или неоднозначно, сцена сообщит причину и не начнёт сопровождение.

; + return
+ {details}

{t.message}

{p.error&&

{p.error}

} +
+ {t.state==='running'&&t.planning_phase==='lost'&&!t.tracking_established&&} + {!terminal&&}
+ setOpen(false)}> +
{details} + {t.state==='preparing'? + : terminal?

{t.message}

:children} +
+
+
; +} diff --git a/apps/control-station/src/components/missions/PlanningLiveScene.tsx b/apps/control-station/src/components/missions/PlanningLiveScene.tsx new file mode 100644 index 0000000..6486c9c --- /dev/null +++ b/apps/control-station/src/components/missions/PlanningLiveScene.tsx @@ -0,0 +1,105 @@ +import {useEffect,useRef,useState} from 'react'; +import {Button,LoadingRegion,RangeControl} from '@nodedc/ui-react'; +import {createIsolatedRerunHost} from '../rerun/isolatedRerunHost'; +import {plannerBase} from '../../core/missions/planner'; +import {startPlanningSceneStream} from '../../core/missions/planningSceneStream'; +import {createPlanningPresentationReporter} from '../../core/missions/planningPresentationTelemetry'; + +function nextAnimationFrame(limitMs=500):Promise{ + return new Promise(resolve=>{ + let frame:number|undefined,settled=false; + const finish=(value:number|null)=>{ + if(settled)return; + settled=true; + if(frame!==undefined&&value===null)cancelAnimationFrame(frame); + clearTimeout(timeout);resolve(value); + }; + const timeout=setTimeout(()=>finish(null),limitMs); + frame=requestAnimationFrame(()=>finish(performance.now())); + }); +} + +/** One persistent viewer/channel. Updates replace entities without resetting the camera. */ +type HeightBounds={min:number;max:number}; +const CLIP_CEILING_M=80; +export function PlanningLiveScene({runId,options={},active=false,revision=0,heightBounds=null}:{runId:string;options?:Record;active?:boolean;revision?:number;heightBounds?:HeightBounds|null}){ + const optionsRef=useRef(options);optionsRef.current=options; + const stateRef=useRef({active,revision});stateRef.current={active,revision}; + const ceilingRef=useRef(null); + const boundsRef=useRef<{min:number;max:number}|null>(null); + const host=useRef(null); + const [state,setState]=useState('loading'),[retry,setRetry]=useState(0); + const [bounds,setBounds]=useState<{min:number;max:number}|null>(null); + const [ceiling,setCeiling]=useState(null); + useEffect(()=>{ + ceilingRef.current=null; + const next=heightBounds&&Number.isFinite(heightBounds.min)&&Number.isFinite(heightBounds.max)&&heightBounds.max>heightBounds.min?heightBounds:null; + boundsRef.current=next; + setBounds(next);setCeiling(null); + },[runId,heightBounds?.min,heightBounds?.max]); + useEffect(()=>{ + if(!host.current)return; + let disposed=false,sceneAvailable=false,stopStream:(()=>void)|undefined; + const reporter=createPlanningPresentationReporter({ + url:`${plannerBase}/live-tests/${runId}/presentation-observations`, + }); + const runtime=createIsolatedRerunHost(host.current);setState('loading'); + const timeout=setTimeout(()=>{if(!disposed){setState('error');stopStream?.();runtime.dispose();}},60000); + void runtime.ready.then(async({viewer,mount})=>{ + if(disposed)return; + await viewer.start(null,mount,{width:'100%',height:'100%',hide_welcome_screen:true,enable_history:false,allow_fullscreen:false}); + if(disposed)return; + const channel=viewer.open_channel('planning-'+runId); + viewer.on('recording_open',()=>{if(!disposed&&sceneAvailable){clearTimeout(timeout);setState('ready');}}); + stopStream=startPlanningSceneStream({ + url:`${plannerBase}/live-tests/${runId}/scene-delta.rrd`, + snapshot:()=>({...stateRef.current,options:{...optionsRef.current, + ...(ceilingRef.current===null?{}:{ceiling_m:ceilingRef.current})}}), + apply:async(bytes,delivery)=>{ + if(disposed)return; + if(delivery.heightMinM!==null&&delivery.heightMaxM!==null&&delivery.heightMaxM>delivery.heightMinM){ + const next={min:delivery.heightMinM,max:delivery.heightMaxM}; + const previous=boundsRef.current; + if(!previous||previous.min!==next.min||previous.max!==next.max){ + boundsRef.current=next;setBounds(next); + if(ceilingRef.current!==null&&(ceilingRef.currentnext.max)){ + ceilingRef.current=null;setCeiling(null); + } + } + } + sceneAvailable=true; + const admissionStarted=performance.now(); + channel.send_rrd(bytes); + const admissionEnded=performance.now(); + for(const panel of ['top','blueprint','selection','time'] as const)viewer.override_panel_state(panel,'hidden'); + if(viewer.get_active_recording_id()){clearTimeout(timeout);setState('ready');} + // Rerun exposes no canvas-paint receipt. Two bounded browser frame + // opportunities are retained as an explicit proxy, never as a GPU claim. + const first=await nextAnimationFrame(); + const second=first===null?null:await nextAnimationFrame(); + if(!disposed)reporter.record(delivery,{ + rerunAdmissionMs:admissionEnded-admissionStarted, + firstAnimationFrameMs:first===null?null:first-admissionEnded, + secondAnimationFrameMs:second===null?null:second-admissionEnded, + }); + }, + error:()=>{sceneAvailable=false;if(!disposed)setState('error');}, + }); + }).catch(()=>{if(!disposed){clearTimeout(timeout);setState('error');}}); + return()=>{disposed=true;stopStream?.();reporter.dispose();clearTimeout(timeout);runtime.dispose();}; + },[runId,retry]); + const sourceBounds=bounds??heightBounds; + const rangeBounds=sourceBounds?{min:Math.min(sourceBounds.min,0),max:CLIP_CEILING_M}:null; + return +
+ {rangeBounds&&state==='ready'&&
+ `${value.toFixed(1).replace('.',',')} м`} formatLimit={value=>value.toFixed(1).replace('.',',')} + onChange={value=>{ + const next=value>=rangeBounds.max-Math.max(1,rangeBounds.max-rangeBounds.min)*1e-9?null:value; + ceilingRef.current=next;setCeiling(next); + }}/> +
} + {state==='error'&&
Обновление сцены недоступно.
} + ; +} diff --git a/apps/control-station/src/components/missions/PlanningProjectResult.tsx b/apps/control-station/src/components/missions/PlanningProjectResult.tsx new file mode 100644 index 0000000..992d950 --- /dev/null +++ b/apps/control-station/src/components/missions/PlanningProjectResult.tsx @@ -0,0 +1,25 @@ +import { Inspector, Icon, StatusBadge } from '@nodedc/ui-react'; +import { planningProjectStatus, type PlanningProjectDetail } from '../../core/missions/planningProjects'; + +export function PlanningProjectResult({project:p}:{project:PlanningProjectDetail}) { + const r=p.result; + return ,content:
+

{p.name}

{new Date(p.created_at_utc).toLocaleString('ru-RU')} +
Эталон
{p.reference_label}
Повторный проход
{p.query_label??'—'}
+
Участок эталона
{p.draft.route.length_m.toFixed(2)} м
+
}, + {id:'result',label:'Результат',icon:,content:
+ {planningProjectStatus(p)} + {r?<>
Точки в пределах 0,5 м
{(r.overlap*100).toFixed(1)}%
+
Расхождение поверхностей
{r.inlier_rmse_m==null?'—':`${r.inlier_rmse_m.toFixed(3)} м`}
+ {r.correction_m!=null&&
Уточнение привязки
{r.correction_m.toFixed(2)} м · {r.correction_deg.toFixed(1)}°
} + {r.registration_seconds!=null&&
Расчёт
{r.registration_seconds.toFixed(2)} с
} +
Совпадение поверхностей не является измеренной точностью положения.:

{p.message?`Сохранённое сообщение: ${p.message}`:'Расчёт совмещения ещё не выполнен.'}

} + {p.evidence_relation==='same_recording'&&Оба участка из одной записи: внутренняя проверка общей карты.} + {p.scene_note&&p.scene_url&&{p.scene_note}} + {r?.reasons.map(reason=>{reason})} + {!p.scene_url&&r&&Сохранённое совмещённое облако недоступно.} +
}, + ]}/>; +} diff --git a/apps/control-station/src/components/missions/PlanningProjectSelect.tsx b/apps/control-station/src/components/missions/PlanningProjectSelect.tsx new file mode 100644 index 0000000..470690a --- /dev/null +++ b/apps/control-station/src/components/missions/PlanningProjectSelect.tsx @@ -0,0 +1,47 @@ +import { useState } from 'react'; +import { ConfirmationModal, Icon, Select, ToastStack } from '@nodedc/ui-react'; +import { planningProjectDeletable, planningProjectStatus, type PlanningProject } from '../../core/missions/planningProjects'; + +export function PlanningProjectSelect({items, value, disabled, onChange, onRemove}: { + items: PlanningProject[]; + value: string; + disabled?: boolean; + onChange: (key: string) => void; + onRemove: (project: PlanningProject) => Promise; +}) { + const [target, setTarget] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + return <> + {server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});({startPlanningSceneStream:start}=await server.ssrLoadModule('/src/core/missions/planningSceneStream.ts'));}); +after(async()=>{await server?.close();}); +const settle=()=>new Promise(resolve=>setImmediate(resolve)); + +function fixture(){ + let sequence=0,state={active:true,revision:1,options:{mode:'3d'}},clock=0; + const timers=new Map(),requests=[],applied=[],errors=[]; + const stop=start({url:'/scene',snapshot:()=>state,now:()=>clock, + schedule:(fn,ms)=>{const id=++sequence;timers.set(id,{fn,ms});return id;},cancel:id=>timers.delete(id), + fetcher:(url,init)=>new Promise((resolve,reject)=>requests.push({url,init,resolve,reject})), + apply:bytes=>applied.push(bytes),error:()=>errors.push(true)}); + return {timers,requests,applied,errors,stop,setState:value=>{state=value;},setClock:value=>{clock=value;}, + next:()=>{const [id,timer]=[...timers].find(([,t])=>t.ms!==2500);timers.delete(id);timer.fn();}, + answer:(index,cursor='next',status=200)=>requests[index].resolve(new Response(status===204?null:new Uint8Array([1,2]),{status,headers:{'X-Planning-Scene-Cursor':cursor}}))}; +} + +test('one pending fetch, native cadence, no-op response and cancellation',async()=>{ + const f=fixture();assert.equal(f.requests.length,1); + assert.equal(f.timers.size,1); // deadline only: no overlapping polling interval + f.setClock(35);f.answer(0);await settle(); + assert.equal(f.applied.length,1);assert.equal([...f.timers.values()][0].ms,65); + f.next();assert.match(f.requests[1].url,/cursor=next/); + f.answer(1,'next',204);await settle();assert.equal(f.applied.length,1); + f.next();f.stop();assert.equal(f.requests[2].init.signal.aborted,true); + f.answer(2);await settle();assert.equal(f.applied.length,1);assert.equal(f.timers.size,0); +}); + +test('a changed mode or ended run discards the in-flight response and rebases',async()=>{ + for(const next of [{active:true,revision:2,options:{mode:'top'}},{active:false,revision:2,options:{mode:'3d'}}]){ + const f=fixture();f.setState(next);f.answer(0);await settle(); + assert.equal(f.applied.length,0);f.next();assert.match(f.requests[1].url,/base=true/); + f.answer(1);await settle();assert.equal(f.applied.length,1);f.stop(); + } +}); + +test('failure hides stale evidence, repairs geometry but retains admitted camera cursor',async()=>{ + const f=fixture();f.answer(0);await settle();f.next(); + f.requests[1].reject(new Error('offline'));await settle(); + assert.equal(f.errors.length,1);assert.equal([...f.timers.values()][0].ms,500); + f.next();assert.match(f.requests[2].url,/base=true/);assert.match(f.requests[2].url,/cursor=next/); + f.stop();f.answer(2);await settle();assert.equal(f.applied.length,1); +}); + +test('display changes and discarded responses retain camera identity; reset intent reaches server',async()=>{ + const f=fixture();f.answer(0,'admitted-camera');await settle();f.next(); + f.setState({active:true,revision:1,options:{mode:'3d',ceiling_m:3,reset:0}}); + f.answer(1,'discarded-camera');await settle(); + assert.equal(f.applied.length,1);f.next(); + assert.match(f.requests[2].url,/cursor=admitted-camera/); + assert.match(f.requests[2].url,/base=true/); + assert.match(f.requests[2].url,/reset=0/); + f.answer(2,'clipped');await settle(); + f.setState({active:true,revision:1,options:{mode:'3d',ceiling_m:3,reset:1}}); + f.next();assert.match(f.requests[3].url,/reset=1/); + f.answer(3,'reset');await settle();f.stop(); +}); + +test('unchanged terminal views stop transfer but notice a later revision',async()=>{ + const f=fixture();f.setState({active:false,revision:2,options:{mode:'3d'}}); + f.answer(0);await settle();f.next();f.answer(1);await settle();f.next(); + assert.equal(f.requests.length,2); + f.setState({active:false,revision:3,options:{mode:'3d'}});f.next(); + assert.equal(f.requests.length,3);f.stop();f.answer(2);await settle(); +}); + +test('cloud or fit expiring during delivery cannot revive green',async()=>{ + for(const [cloud,fit] of [['1.8','1'],['.1','7.8'],['invalid','1']]){ + const f=fixture();f.setClock(300); + f.requests[0].resolve(new Response(new Uint8Array([1]),{headers:{ + 'X-Planning-Scene-Cursor':'next','X-Planning-Presentation':'live', + 'X-Planning-Cloud-Age':cloud,'X-Planning-Fit-Age':fit}})); + await settle();assert.equal(f.applied.length,0);assert.equal(f.errors.length,1);f.stop(); + } +}); + +test('a live response waits for bounded browser delivery and passes its exact receipt identity',async()=>{ + let release,delivery; + let sequence=0,state={active:true,revision:1,options:{mode:'3d'}},clock=0; + const timers=new Map(),requests=[]; + const stop=start({url:'/scene',snapshot:()=>state,now:()=>clock, + schedule:(fn,ms)=>{const id=++sequence;timers.set(id,{fn,ms});return id;},cancel:id=>timers.delete(id), + fetcher:(url,init)=>new Promise(resolve=>requests.push({url,init,resolve})), + apply:(_bytes,value)=>{delivery=value;return new Promise(resolve=>{release=resolve;});},error:assert.fail, + }); + clock=40; + requests[0].resolve(new Response(new Uint8Array([1]),{headers:{ + 'X-Planning-Scene-Cursor':'next','X-Planning-Presentation':'live', + 'X-Planning-Cloud-Age':'.2','X-Planning-Fit-Age':'.1', + 'X-Planning-Cloud-Revision':'3','X-Planning-Cloud-Sequence':'4', + 'X-Planning-Display-Epoch':'5d8c5d13-dfde-4ab6-9da0-475ab1f22c12', + 'X-Planning-Height-Min':'-1.2','X-Planning-Height-Max':'51.5', + }})); + await settle(); + assert.deepEqual(delivery,{presentation:'live',cloudAgeMs:200,fitAgeMs:100,requestMs:40, + cloudRevision:3,cloudSequence:4,displayEpoch:'5d8c5d13-dfde-4ab6-9da0-475ab1f22c12', + heightMinM:-1.2,heightMaxM:51.5}); + assert.equal(timers.size,1); + assert.equal([...timers.values()][0].ms,2500); + release();await settle(); + assert.equal([...timers.values()][0].ms,60); + stop(); +}); + +test('profile scene can expand after native capture ends and retains Escape',()=>{ + const source=readFileSync(new URL('../src/workspaces/spatial/SpatialWorkspace.tsx',import.meta.url),'utf8'); + assert.match(source,/pointCloudVisible && \(visualProfile \|\| sourceUrl.trim\(\)\)/); + assert.match(source,/{ + server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}}); + ({PlanningSceneToolWindow:Tool}=await server.ssrLoadModule('/src/components/missions/PlanningSceneToolWindow.tsx')); + ({SpatialToolbarActions:Actions}=await server.ssrLoadModule(new URL('../../../packages/spatial-ui/src/SpatialToolbarActions.tsx',import.meta.url).pathname)); +}); +after(async()=>{await server?.close();}); + +test('scene tools use the bounded modeless window with move, resize and expand controls',()=>{ + const html=renderToStaticMarkup(createElement(Tool,{boundsRef:{current:null},title:'Слои',onClose:()=>{}},'CONTROLS')); + assert.match(html,/nodedc-workspace-window/); + assert.match(html,/aria-modal="false"/); + assert.match(html,/Переместить инструмент/); + assert.match(html,/Изменить размер инструмента/); + assert.match(html,/Развернуть инструмент/); + assert.doesNotMatch(html,/nodedc-overlay/); +}); + +test('spatial toolbar omits source and duplicate planning navigation',()=>{ + const html=renderToStaticMarkup(createElement(Actions,{openLayers:()=>{},openDisplay:()=>{},activeTool:'layers'})); + assert.match(html,/Слои/);assert.match(html,/Отображение/); + assert.match(html,/aria-pressed="true"/);assert.doesNotMatch(html,/Движок|Планирование/); + const workspace=readFileSync(new URL('../src/workspaces/missions/PlanningSpatialWorkspace.tsx',import.meta.url),'utf8'); + assert.doesNotMatch(workspace,/tool&& id), - ["lab-archive", "simulations", "observatory", "local-device", "spatial-scene"], + ["lab-archive", "simulations", "observatory", "local-device", "spatial-scene", "mission-planner"], ); assert.equal( workspacesForRoot("system").some(({ id }) => id === "polygon-run"), diff --git a/apps/control-station/test/productShellContract.test.mjs b/apps/control-station/test/productShellContract.test.mjs index e49e4e7..08a4a1e 100644 --- a/apps/control-station/test/productShellContract.test.mjs +++ b/apps/control-station/test/productShellContract.test.mjs @@ -29,7 +29,7 @@ test("top navigation has no Center and Park owns contour health first", () => { assert.equal(productModel.workspaceById("contour-health")?.root, "fleet"); assert.deepEqual( productModel.workspacesForRoot("polygon").map(({ id }) => id), - ["lab-archive", "simulations", "observatory", "local-device", "spatial-scene"], + ["lab-archive", "simulations", "observatory", "local-device", "spatial-scene", "mission-planner"], ); assert.equal(productModel.workspaceById("spatial-scene")?.root, "polygon"); assert.equal(productModel.workspacesForRoot("observation").some(({ id }) => id === "spatial-scene"), false); diff --git a/apps/control-station/test/recordedCameraBuffering.test.mjs b/apps/control-station/test/recordedCameraBuffering.test.mjs index 3d5cb45..a7f91fd 100644 --- a/apps/control-station/test/recordedCameraBuffering.test.mjs +++ b/apps/control-station/test/recordedCameraBuffering.test.mjs @@ -295,7 +295,7 @@ test("loading and error overlays fully conceal recorded camera pixels", async () test("point-cloud fullscreen keeps the admitted recorded camera worker mounted", async () => { const source = await readFile( - new URL("../src/workspaces/Workspaces.tsx", import.meta.url), + new URL("../src/workspaces/spatial/SpatialWorkspace.tsx", import.meta.url), "utf8", ); assert.match(source, /\{visibleMediaSources\.map\(\(source, index\) => \(/); diff --git a/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs b/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs index 919fd3e..e84225a 100644 --- a/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs +++ b/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs @@ -320,7 +320,7 @@ test("one live document owns one native Rerun receiver", async () => { test("raw replay exercises the same streaming receiver lifecycle as a live scan", async () => { const source = await readFile( - new URL("../src/workspaces/Workspaces.tsx", import.meta.url), + new URL("../src/workspaces/spatial/SpatialWorkspace.tsx", import.meta.url), "utf8", ); assert.match( @@ -343,7 +343,7 @@ test("raw replay exercises the same streaming receiver lifecycle as a live scan" ); assert.match( source, - /const recordedSource = Boolean\(recordedReplay\) \|\| \/\\\.rrd/, + /const recordedSource = !visualProfile && \(Boolean\(recordedReplay\) \|\| \/\\\.rrd/, ); assert.doesNotMatch( source, @@ -353,7 +353,7 @@ test("raw replay exercises the same streaming receiver lifecycle as a live scan" test("pending K1 STOP keeps the live Rerun source mounted until local capture ends", async () => { const source = await readFile( - new URL("../src/workspaces/Workspaces.tsx", import.meta.url), + new URL("../src/workspaces/spatial/SpatialWorkspace.tsx", import.meta.url), "utf8", ); assert.match( diff --git a/apps/control-station/test/sessionOverview.test.mjs b/apps/control-station/test/sessionOverview.test.mjs new file mode 100644 index 0000000..7c446e3 --- /dev/null +++ b/apps/control-station/test/sessionOverview.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { before, after, test } from 'node:test'; +import { createServer } from 'vite'; +import { readFile } from 'node:fs/promises'; +let server, api; +before(async () => { + server = await createServer({ appType: 'custom', logLevel: 'silent', server: { middlewareMode: true } }); + api = await server.ssrLoadModule('/src/core/observation/sessionOverview.ts'); +}); +after(async () => { await server?.close(); }); +test('height slice delegates endpoint labels and unit-bearing value to the shared range', async () => { + const source = await readFile(new URL('../src/components/observation/SessionOverviewScene.tsx', import.meta.url), 'utf8'); + assert.match(source, /RangeControl orientation="vertical" limitSide="left" label="Срез"/); + assert.match(source, /formatLimit=\{value => value\.toFixed\(1\)\.replace\('\.', ','\)\}/); + assert.match(source, /formatValue=\{value => `\$\{value\.toFixed\(1\)\.replace\('\.', ','\)\} м`\}/); + assert.doesNotMatch(source, /\{(?:high|low)\.toFixed\(1\)\} м<\/span>/); +}); +test('interval chart retains the largest pause and rejects invalid values', () => { + const result = api.overviewChartPoints([[0, .1], [30, 1.2], [60, .1], [NaN, 5]], 100, 50); + assert.equal(result.xmax, 60); + assert.equal(result.ymax, 1.32); + assert.equal(result.points.split(' ').length, 3); + assert.doesNotMatch(result.points, /NaN/); +}); +test('session overview rejects a result belonging to a different session', async () => { + const original = globalThis.fetch; + globalThis.fetch = async () => ({ ok: true, json: async () => ({ schema_version: 'missioncore.session-overview/v1', state: 'ready', session: { session_id: 'other' } }) }); + try { await assert.rejects(api.fetchSessionOverview('selected', new AbortController().signal), /некорректные/); } + finally { globalThis.fetch = original; } +}); diff --git a/apps/control-station/test/spatialSceneLayout.test.mjs b/apps/control-station/test/spatialSceneLayout.test.mjs new file mode 100644 index 0000000..e7c66ab --- /dev/null +++ b/apps/control-station/test/spatialSceneLayout.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { before, after, test } from 'node:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { createServer } from 'vite'; + +let server, SpatialScene; +before(async () => { + server = await createServer({ appType: 'custom', logLevel: 'silent', server: { middlewareMode: true } }); + ({ SpatialScene } = await server.ssrLoadModule(new URL('../../../packages/spatial-ui/src/SpatialScene.tsx', import.meta.url).pathname)); +}); +after(async () => { await server?.close(); }); + +const render = (focused = false) => renderToStaticMarkup(createElement(SpatialScene, { + viewportRef: { current: null }, primaryFocused: focused, toolbar: null, renderer: null, + sourceControls: createElement('div', { className: focused ? 'scene-focus-exit' : 'scene-source-controls' }, 'SOURCE_CONTROLS'), + status: { label: 'Накопление данных', tone: 'neutral', message: 'Сканер неподвижен.' }, + metrics: createElement('div', null, 'METRICS'), +})); + +test('scene tools, visual engine and metrics share one top-left flow in that order', () => { + const markup = render(); + const stack = markup.indexOf('class="scene-information"'); + const controls = markup.indexOf('SOURCE_CONTROLS'); + const status = markup.indexOf('ВИЗУАЛЬНЫЙ ДВИЖОК'); + const metrics = markup.indexOf('METRICS'); + assert.ok(stack < controls && controls < status && status < metrics); + assert.equal((markup.match(/SOURCE_CONTROLS/g) ?? []).length, 1); + assert.doesNotMatch(markup, /scene-status--top-left/); +}); + +test('focus exit remains viewport-owned outside the hidden information stack', () => { + const markup = render(true); + assert.ok(markup.indexOf('scene-focus-exit') < markup.indexOf('scene-information')); + assert.match(markup, /class="scene-information" aria-hidden="true"/); + assert.equal((markup.match(/SOURCE_CONTROLS/g) ?? []).length, 1); +}); + +test('scene layout uses flow, retains compact metrics and removes only the calibration perimeter', async () => { + const css = await readFile(new URL('../../../packages/spatial-ui/src/spatial.css', import.meta.url), 'utf8'); + const responsive = await readFile(new URL('../src/styles/responsive.css', import.meta.url), 'utf8'); + const calibration = await readFile(new URL('../../../plugins/xgrids-k1/frontend/src/components/K1SpatialSession.css', import.meta.url), 'utf8'); + assert.match(css, /\.scene-information \{[^}]*position: absolute;[^}]*display: grid;/); + assert.match(css, /\.scene-information > \.scene-source-controls \{ position: static; pointer-events: auto;/); + assert.match(css, /\.scene-information\[aria-hidden="true"\] \{ display: none;/); + assert.doesNotMatch(css.match(/\.scene-metrics \{[^}]*\}/)?.[0] ?? '', /top:|right:|position: absolute/); + assert.doesNotMatch(responsive, /\.scene-metrics\s*\{\s*display: none/); + assert.match(calibration, /\.xgrids-k1-spatial-controls \{[^}]*border: 0;/); +}); diff --git a/docs/25_MISSION_PLANNER_RECORDED_ZONE_PLAN.md b/docs/25_MISSION_PLANNER_RECORDED_ZONE_PLAN.md new file mode 100644 index 0000000..5101606 --- /dev/null +++ b/docs/25_MISSION_PLANNER_RECORDED_ZONE_PLAN.md @@ -0,0 +1,118 @@ +# Планировщик: зона и маршрут из сохранённой записи + +Текущее решение владельца от 2026-09-11: исследование перенесено в **LAB → Планировщик**, декоративный аппарат убран. «Тестирование» открывает подключение K1 с сохраняемым профилем планирования; новый проход сопоставляется с фиксированным эталоном в пространственной сцене. Сравнение сохранённых записей доступно отдельно. Реализация, ограничения и актуальный сценарий: [профиль планирования](audits/2026-09-11-planning-live-profile.md). Ниже сохранён исходный поэтапный план; прежнее размещение в «Миссиях» и последовательность запуска офлайн-проверки заменены этим решением. + +Дата: 2026-09-11. Статус: P1 реализован на каноническом 8000; выбор интервала/направления и просмотр позиции из P2/P3 работают. Произвольные правки точек, автоматическое воспроизведение и расчёт локализации ещё не реализованы. Карточка реализации — [MISSIONCOR-81](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-81). Продуктовая область и три узла заданы владельцем. Исследовательская основа — [MISSIONCOR-79](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-79). Готовый обзор записей — [MISSIONCOR-80](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-80). + +## Решение + +Развивать существующее окно «Миссии → Планировщик». Первый результат — сохранённый черновик с зоной из каталога данных и редактируемым маршрутом. Аппарат остаётся неназначенным и не блокирует работу. Сохранение черновика не зависит от подключения контроллера, роутера или шасси. + +Интерфейс и эксперимент развиваются небольшими законченными этапами. Предпросмотр маршрута проверяет геометрию и работу редактора. Совмещение независимых записей проверяет определение места. Это разные результаты, и их статусы отображаются отдельно. + +## Что подтверждено в текущем коде + +- Статический `MissionWorkspace` заменён отдельным `workspaces/missions/MissionPlannerWorkspace.tsx`. Три блока, общий каталог, выбор участка в метрах, направление, просмотр и серверное сохранение/повторное открытие работают. Данные находятся в `data_dir/missions/mission-drafts.sqlite3`, подготовленная траектория — в `data_dir/planning-sources`. Подробности: `docs/audits/2026-09-11-mission-planner.md`. +- `productModel.ts`: существующий workspace `mission-planner` внутри `missions`; отдельный новый корневой раздел не требуется. +- `web/session_api.py`: общий каталог записей, detail, cursor pagination, scope all/source/laboratory. Использовать этот каталог, а не отдельный список импортированных вручную файлов. +- Готовый `SessionOverviewService` выдаёт ограниченный RRD, статистику, кэш и проверяет соответствие исходнику. Его можно использовать для просмотра зоны. Обзорная выборка не является картой локализации. +- Rerun 0.36.3 в установленном `@rerun-io/web-viewer/index.d.ts` описывает `selection_change`, entity/instance и необязательную `position`. Это основание для отдельной небольшой проверки выбора точек; готового редактора маршрута из этого не следует. + +## Продуктовая композиция + +Оператор открывает черновик, выбирает обследованную зону, намечает путь и сохраняет результат для проверки. Основная сущность — версия черновика миссии. В центре располагается облако с траекторией; слева — три узла; справа — параметры выбранного узла. Границы панелей изменяются, в узком окне параметры переходят в канонический inspector. + +| Узел | Первый прототип | Действия | +| --- | --- | --- | +| Аппарат | «Не назначен», информационная строка | Подключение и команды не добавляются; узел не входит в обязательную готовность черновика | +| Зона | Именованная область с выбранной версией источника | «Из сессий и записей», просмотр облака, замена источника с явным пересмотром маршрута | +| Маршрут | Упорядоченная полилиния в системе координат зоны | Создать из траектории, выбрать начало/конец и направление, редактировать точки, сохранить | + +Узлы «Наблюдение» и «Завершение» убираются из этой композиции. Также убираются макетные 0/5 и «Интерфейс готов». Верхние действия: название черновика, сохранить; действия маршрута: «Из траектории», «Предпросмотр», пауза и позиция на линии. Настоящий запуск аппарата в этот этап не входит. + +Выбранная композиция — редактор в существующем планировщике с пространственным представлением. Альтернатива — создавать миссию непосредственно в разделе данных: она смешивает неизменяемые записи с редактируемыми заданиями. Второй вариант — сразу универсальный граф произвольных узлов: он добавляет редактор соединений, не решая первый сценарий из трёх известных узлов. Первый прототип использует фиксированные зависимости «зона → маршрут» без свободного графа. + +Это изменение состава существующего workspace по прямому запросу владельца. Используются `ApplicationPanel`, `GlassSurface`, `Button`, `Select`/`Dropdown`, `SegmentedControl`, `SplitPane`, `Inspector`, `RangeControl`, `LoadingRegion` и канонические иконки. Новые навигационные корни не нужны. Не добавлять локальные кнопки, переключатели или копии компонентов; потенциальные пробелы сначала проверять в Design Guideline. + +## Зона и происхождение данных + +1. «Из сессий и записей» открывает общий каталог с именем, датой, длительностью и доступными каналами. Все категории можно просмотреть; каталог имеет пагинацию и поиск по загруженным страницам либо серверный поиск, без ограничения первыми 100 строками. +2. Источники без метрического облака видны, но не могут стать облаком зоны; причина указана в строке. Наличие видео или общей отметки replayable само по себе недостаточно. +3. Первый вариант зоны ссылается на одну завершённую исходную сессию. Производные LAB-результаты допускаются только после проверки их собственных координат и происхождения, без неявного наследования родительской геометрии. Несколько произвольных сессий автоматически не склеиваются. +4. В черновике сохраняются ID и версия источника, система координат/единицы и версия подготовки. Облако не копируется при каждом сохранении. Изменившийся или удалённый источник переводит связь в «Источник недоступен»; сам черновик и точки маршрута сохраняются. +5. По умолчанию зона охватывает выбранную запись. Ограничение геометрической областью — отдельное поле ROI; для первой проверки достаточно явно выбранного короткого участка маршрута. Не считать bbox дальних точек границей разрешённого движения. +6. «Сверху», «3D» и визуальный срез переиспользуются. Высотный срез остаётся настройкой просмотра: он не превращается автоматически в маску локализации, удаления препятствий или разрешённую высоту проезда. + +У K1 здесь локальные метрические координаты. Назначать географическую позицию на Cesium по умолчанию нельзя. Для привязки к карте потребуется отдельное подтверждённое преобразование; наличие приблизительной точки Arnavi этого не заменяет. + +## Маршрут + +Записанная траектория сканера — источник черновика пути, а не подтверждённый путь центра шасси. Хранить оригинальные poses отдельно от редактируемой полилинии. Для пути аппарата позднее понадобятся монтажное преобразование, опорная точка и габариты. + +Первый полный сценарий: + +1. Выбрать зону, нажать «Из траектории». +2. Выбрать интервал исходной траектории по времени/номеру позиции; увидеть начало и конец. Для прохода туда–обратно выбирать нужную ветвь по порядку записи, а не по ближайшей точке в пространстве. +3. Получить ограниченный набор редактируемых точек с сохранённой связью с исходными индексами. Прореживание имеет явно заданную максимальную геометрическую ошибку; допуски выбираются на первой пробе, а не скрываются в коде. +4. Выбирать точку, исправлять координаты, вставлять/удалять, менять направление, отменять правку. Исходную траекторию показывать более тонкой линией. +5. Проверить порядок, конечность координат, совпадение frame, нулевые сегменты, выход из заданного ROI и скачки. Сохранить черновик. Отсутствие аппарата не блокирует сохранение. +6. «Предпросмотр» перемещает условный маркер по выбранной линии. Если используется записанное время, это явно просмотр записи; если задана скорость маркера, это кинематический просмотр, не симуляция сцепления или объезда. + +Для выбора/добавления точек в Rerun сначала проверить `selection_change` на известной синтетической геометрии: координаты, instance ID, преобразование view → zone, переключение видов и срез. Клик по облаку может попасть в дерево или стену; такие XYZ нельзя молча считать поверхностью дороги. Первое редактирование доступно также через список и поля координат. Полноценное перетаскивание и рисование по свободному месту добавляются после проверки проекции и явного определения плоскости редактирования; обходить canvas DOM-хаками не следует. + +## Минимальные данные и API + +Предлагаемые контракты, ещё не реализованные: + +| Сущность | Содержание | +| --- | --- | +| MissionDraft | ID, имя, revision, vehicle=null, zoneRef, routeRef, время сохранения | +| ZoneRevision | ID/version, sourceRef, frame/units, необязательный ROI, состояние доступности производных | +| RouteRevision | ID/version, zoneRevision, ordered points/segments, start/end/direction, происхождение и правки | +| PlanningSource | ограниченное облако для просмотра, индекс поз с временем и исходными frame IDs, доступные действия | +| LocalizationRun | фиксированные map/query версии, диапазоны, начальная гипотеза, параметры, результаты и ограничения | + +Общая библиотека записей остаётся источником. API подготовки зоны/траектории получает ID записи, не произвольный путь файловой системы. Новый индекс поз извлекается из исходных данных один раз и кэшируется: обзорный RRD с прореженными позами не объявляется полным исходником маршрута. + +Хранилище черновиков — на backend с атомарным сохранением и проверкой revision (конфликт двух редакторов возвращается явно). LocalStorage подходит только для расположения панелей. На текущем шаге данные доступны тому Mission Core, который обслуживает существующий каталог; перенос автономного пакета на борт будет отдельной операцией. Будущий пакет содержит фиксированные версии карты/маршрута/индексов и может работать без NAS во время поездки. + +Модули: `core/missions`, `components/missions`, `workspaces/missions`, отдельные backend contracts/store/router. `Workspaces.tsx` только делегирует новому workspace, `App` содержит минимальное подключение. Runtime совмещения не исполняется в React. Не расширять центральный файл макетов логикой нового редактора. + +## Порядок реализации и проверки + +| Этап | Работающий результат | Проверка | +| --- | --- | --- | +| P1. Черновик и зона | Три узла, сохранение без аппарата, общий каталог, выбранная сессия в центре | Выбрать разные записи, сохранить, перезагрузить; тот же источник и состояние; отсутствующий источник не уничтожает черновик | +| P2. Маршрут | Из траектории → интервал/направление → точки → редактирование → сохранение | Отдельно прямая/обратная ветвь, отмена, повторное открытие, другая зона, конфликт версий | +| P3. Предпросмотр | Маркер, пауза, позиция, выделенный текущий сегмент | Предсказуемый порядок на петле и развороте, отсутствие обращения к управляющим API | +| R1. Первая регистрация | Ограниченная пара подкарта/запрос и отчёт о совмещении | Синтетическое известное преобразование; затем разные части одной реальной записи, результат помечен внутренней диагностикой | +| R2. Независимый проход | Оценка места новой сессии в фиксированном эталоне | Позиция/курс, ложные совпадения, неоднозначные места, устаревшие данные, запуск не из начала | +| P4. Подключение результата | В планировщике видны источник теста и оценённый маркер/отказ | Статус локализации относится к конкретным map/run версиям, не к факту открытия красивого облака | + +P1–P3 и R1 используют общий контракт ZoneRevision/RouteRevision. Их можно разрабатывать по очереди, не ожидая готовности железа. Полный навигационный стек, объезд, энергетика и команды ходовой части не являются зависимостями этих этапов. + +## Самый короткий полезный эксперимент + +Для уже собранной записи A выбрать 20–30 м с выраженной статичной геометрией и отдельный сложный участок. Сохранить номера/время кадров. Эталон строить только из выбранных кадров прямого прохода, запрос — из других кадров обратного. Общая система SLAM и возможная коррекция K1 связывают эти части: даже хороший результат остаётся внутренним тестом, а не доказательством независимой локализации. + +Сначала проверить единицы, оси и преобразования на синтетических точках; не применять pose повторно к точкам, уже находящимся в системе K1. Потом проверить близкую начальную гипотезу, небольшой искусственный сдвиг и неверный участок. Не начинать с обещания «сам найдётся в 20 м». + +CPU baseline — GICP/VGICP с заранее подготовленными индексами участков карты; [small_gicp](https://github.com/koide3/small_gicp) предоставляет эти методы и отдельную подготовку/повторное использование индексов. Ограничить точки и число потоков, выполнять один короткий прогон за раз. Точные voxel/окно/порог соответствий задаются в manifest опыта; не использовать визуальную выборку или срез как скрытые параметры расчёта. + +Измерять преобразование, остаточную ошибку и покрытие, число соответствий, согласованность нескольких окон, ложное принятие неверного места, возраст входа, время расчёта и RSS. Флаг converged не равен правильному месту. Для независимого испытания нужен новый проект B; эталон A фиксируется. На момент t алгоритму доступно только прошлое/текущее окно B. Использование K1-поз B допускается для локального накопления, но готовое совмещение A↔B или будущие кадры не подаются как подсказка. + +Локальная регистрация и поиск без начального положения — отдельные задачи. [Open3D](https://www.open3d.org/docs/release/tutorial/pipelines/global_registration.html) показывает грубую глобальную инициализацию с дальнейшим локальным уточнением. Для первого испытания оператор выбирает область старта и направление. Позже проверяются ошибки начального положения 1/3/5/10/20 м и разные курсы; искусственный сдвиг гипотезы не заменяет физический старт в новом месте. + +После появления метрического LAS из исходного проекта A сравнить две карты — подготовленную из записанного потока и обработанную LAS — на одном неизменяемом B. Проверить преобразование LAS↔траектория, единицы и версии. Цвет необязателен для первого геометрического baseline, Gaussian не нужен. + +## Критерии и ограничения следующего этапа + +- P1 принимается по восстановлению черновика и правильному происхождению зоны, P2 — по сохранению и редактированию пути, P3 — по воспроизводимому просмотру. Это можно доказать сейчас без шасси. +- Для R1 принимается только корректность обработки и ограниченного совмещения. Для R2 заранее фиксируется набор независимых контрольных мест и точность их измерения. Без внешнего reference нельзя заявлять сантиметровую точность. +- При нескольких правдоподобных местах или недостаточной геометрии результат «Требуется уточнение» предпочтительнее принудительного выбора. Начало маршрута и начало координат карты не обязаны совпадать. После локализации выбирается разрешённый вход в конкретный сегмент; автоматический подъезд из произвольного места не предполагается. +- Один успешный прогон не доказывает достаточность Mini. Хранение, cold/warm подготовка, peak RSS и p95 времени совмещения измеряются отдельно; работа вместе с живыми камерами — на целевом борту после появления конфигурации. Локальный Mac не используется для нагрузочного теста. +- В режиме среза скрытое дерево остаётся в исходных данных. Облако не доказывает свободное пространство, а маршрут ручного сканера — проходимость гусеницы. Проверка препятствий и кинематики остаётся последующим этапом. + +## Следующее конкретное действие + +Проверить сохранённый 30-метровый участок через действующий планировщик. Затем выполнить R1: синтетическое известное преобразование, ограниченная проба совмещения и проверка неверного участка; сохранить отдельный воспроизводимый отчёт. Дальше — независимый проход B в новом проекте K1 на том же 20–30-метровом участке. Сначала сравнение A/B по записи, затем такой же расчёт на живом потоке. Текущая кнопка «Проверить маршрут» проверяет исходные файлы и последовательность пути; она не определяет положение сканера. diff --git a/docs/audits/2026-09-05-rerun-customization-inventory.md b/docs/audits/2026-09-05-rerun-customization-inventory.md index 5e74c6f..4bb7196 100644 --- a/docs/audits/2026-09-05-rerun-customization-inventory.md +++ b/docs/audits/2026-09-05-rerun-customization-inventory.md @@ -1,5 +1,9 @@ # Полная карта интеграции и кастомизации Rerun +Дополнение от 21 сентября: [профиль планирования, сохранение камеры, окна +инструментов и открытая регрессия выделения сетки](2026-09-21-rerun-planning-customizations.md). +Оно дополняет, а не заменяет исторический аудит ниже. + Снимок кода `b2a1b23131642ac496829e467c20c8c069915be9`, 2026-09-05. Ревизия подготовлена для карточки MISSION CORE #74 «Additional Core · Переносимая кастомизация Rerun». Изменения в runtime в рамках аудита не выполнялись. ## Зафиксированное состояние · 5 сентября 2026 diff --git a/docs/audits/2026-09-11-mission-planner.md b/docs/audits/2026-09-11-mission-planner.md new file mode 100644 index 0000000..6c5e6e7 --- /dev/null +++ b/docs/audits/2026-09-11-mission-planner.md @@ -0,0 +1,34 @@ +# Mission planner: recorded zone and route draft + +Date: 2026-09-11. Ops: MISSIONCOR-81. Canonical service: `http://127.0.0.1:8000`. + +## Delivered boundary + +The existing Missions/Planner workspace now selects a source from the shared observation-session catalog, reads its complete scanner trajectory, selects a contiguous interval in travelled metres and either direction, and persists a versioned draft without a vehicle. The original disabled five-step mock and its placeholder readiness badge are removed. The three blocks are Apparatus, Zone and Route. Parameters stay inside their blocks for this increment; the wider three-panel inspector composition remains a later refinement. + +The cloud view reuses the existing bounded Rerun overview, top/3D and visual height clip. The route view uses an equal-axis XY schematic and a position slider over the selected original pose indices. This is inspection of a recorded scanner path, not measured localization or a certified chassis path. Selected interval and direction are editable; arbitrary waypoints, direct Rerun drawing and timed playback are not implemented. + +## Source and persistence contracts + +- `missions/sources.py`: immutable generation from validated source identity, approved-prefix staging, input digests before/after preparation and source-bound cache. One preparation at a time, maximum 100,000 poses / 30 MiB derived JSON, parser time/message bounds. +- The plugin's `planning_source.py` preserves all pose indices, message ordinals, metric positions, elapsed arrival times when available, and cumulative distance. No second pose transform is applied. One decoded nonempty point-cloud frame confirms spatial evidence. Other cloud messages are counted without decoding every point again; this is not a full point-cloud integrity/accuracy audit. Full overview diagnostics remain separate. +- `missions/drafts.py`: SQLite transactions and compare-and-swap revisions, frozen source identity, selected source indices and resolved points. Changing/missing source never removes a saved draft. +- `web/mission_planner_api.py`: bounded request models; no vehicle ID or execution parameter is accepted. Data checks bind an exact saved revision, reverify source digests, measure length and largest position step, and retain an immutable report. A concurrent draft change rejects publication of a stale report. +- `core/missions`, `components/missions`, `workspaces/missions`: separated domain contracts, reusable renderers and workspace composition. Existing Design Guideline Select, RangeControl, TextField, Window and actions are reused. +- Raw captures, cached poses, SQLite drafts/checks and real scene media remain runtime data outside Git. Nothing is installed on the board. + +## Validation + +- 846 frontend tests passed before final bounded UX refinements. The subsequent metre-input change passed the five focused planner tests plus four architecture tests. Final production build includes TypeScript checking. +- 16 focused backend tests passed (planner + existing overview); 10 planner tests passed again after removing unnecessary cloud decoding. +- First real source preparation after the optimization: HTTP 200 in 2.35 seconds for 4,959 poses and a 487.61 m recorded path. This is a local functional measurement, not a board or localization benchmark. +- Browser on the actual 8000: selected the existing road session, obtained the initial 30.01 m / 578-pose interval, saved a named draft without a vehicle, ran its data check (matching source digests; largest step about 0.10 m), reloaded the client and reopened it. Reverse direction starts at the selected final source pose. Exact interval entry uses metres, not hidden frame indices. +- Another source named `35` loaded independently with 283 poses / 0.089 m, proving the selector is not bound to the road session. Unsupported derived LAB entries remain visible with a reason. +- UI QA used the actual narrow in-app browser surface and expanded window, including Escape. Desktop-width rendering is not separately qualified here. +- All checks/builds/browser work ran sequentially. No duplicate backend or port 8765 was introduced. Canonical 8000 remains durable operator state. + +## Next experiment + +No new scan is required to inspect the saved draft. Before claiming live testing, implement a bounded geometric-registration runner, prove known synthetic transforms and reject a wrong reference segment. Then acquire an independent B recording in a new K1 project over the same 20–30 m section, retaining both Mission Core session and native capture. Freeze reference A. Compare B windows against A without future B frames, report failures/ambiguity, timing and memory. Only after offline evidence passes should the same pipeline feed a live testing viewport. + +The current “Проверить маршрут” action does not compare clouds, estimate the scanner's location, determine passability, detect obstacles or send commands. The starting-position tolerance and target-board capacity remain experimental questions. diff --git a/docs/audits/2026-09-11-planning-live-profile.md b/docs/audits/2026-09-11-planning-live-profile.md new file mode 100644 index 0000000..12fca63 --- /dev/null +++ b/docs/audits/2026-09-11-planning-live-profile.md @@ -0,0 +1,91 @@ +# LAB planning profile — 2026-09-11 + +Owner decision: move the research planner to LAB, remove the decorative vehicle +step, and make Testing lead through the existing K1 connection/naming/start +workflow into a planning-specific spatial scene. The ordinary recorded-pair +comparison remains a separate action. This is an operator diagnostic instrument, +not a mission executor or a new root/navigation system. + +## Operator workflow + +1. LAB → Planner; open a saved reference draft. A remains immutable. Select the + reference interval in trajectory metres and its travel direction; save. +2. Testing prepares the reference once and opens the canonical Window containing + the existing DeviceWorkspace/K1 connection flow. The server owns the frozen + draft/revision; its planning profile survives navigation and browser reload. +3. Create a **new K1 project** and a separate capture B at the physical entry of + the chosen reference interval. Press the existing acquisition/connect action. + Its existing host callback opens Spatial Scene with the planning profile. +4. Walk 20–30 m once, approximately the same scanner height and direction. Initial + position is the operator's entry hypothesis; first ≥3 m displacement supplies + yaw. It is not global place recognition or a guarantee of arbitrary-start + recovery. A remains separate from B; no LAS or Gaussian processing is needed. +5. Gray shows reference geometry. New query geometry uses a blue-to-magenta + height palette. Only accepted per-point nearest-reference distances ≤0.5 m + become green. GICP candidate rejection gates remain fixed v1. Live green is + removed after 8 s sample age, stopped input, changed identity, or termination. +6. Device and recording controls retain the existing K1 stop/finalization path. + Finishing the *research* only releases its derived-data lease; it cannot stop + capture or a vehicle. The last completed calculation is retained for review. + +## Architecture and resource bounds + +- `sessions/live_planning.py` is the normalized read-only input contract. K1's + `planning_live.py` adapts its **existing committed-evidence ingress**; no second + MQTT receiver, device command, network scan, or raw-capture owner is introduced. + Both verified current and legacy protocol decoders are reused. Published K1 + map-space points are not transformed by pose a second time. +- Planning occupies the existing exclusive derived-data consumer lease. An AI + worker already owning it yields an explicit busy response; neither profile + silently steals the other's lease. No inference service is started or stopped. +- `missions/live_tests.py` captures baseline generation while idle, requires a + new session ID/generation, rejects reuse of A/prior capture, and fences every + sample/result. A changed producer session terminates the research. Independent + project/SLAM reset remains an operator requirement (`slam_reset_verified=false`). +- Reference preparation uses existing immutable source validation and submap + extraction (≤40 m, ≤100,000 points, ≤90 s). Native GICP uses the same isolated + short-lived worker and one shared compute lock as offline comparison. +- Live preview samples at most 2 cloud frames/s, requires a preceding pose no + older than 0.5 s, crops within 20 m and relative height −3…+6 m, voxelizes at + 0.25 m, retains 40 frames × ≤4,000 points, then bounds the fit to 40,000 points. + Original recording is unaffected by preview thinning or queue overflow. +- Fit requests are at least 5 s apart, one active numerical child, 30 s deadline. + Capture ingestion continues while fitting; no future query frames enter a job. + Research is capped at 40 m or 300 s after session admission, waiting at 30 min. + Local synthetic checks are bounded functionality tests, **not load tests** or + board resource acceptance. +- Rerun uses one isolated viewer/channel. Static reference is sent once; + subsequent entity updates replace the query without resetting the camera. + Expand/restore and unmount dispose the isolated WASM realm. +- Private `data_dir/missions/live-tests/` contains frozen draft, reference, + per-step NPZ numerical inputs, result JSON, source sequences/host receipt clocks, + query path and artifact SHA-256. Ingress before/after counters are retained. + `active.json` supports refresh; interrupted runs are marked on server restart, + never silently rebound. Raw B remains in the ordinary observation catalog. +- Pre-existing immutable offline RRDs are not rewritten. Their legend explicitly + identifies the old all-query green format; new reports carry versioned + per-correspondence coloring. + +## Validation and limits + +23 focused Python tests (live profile, registration, planner) pass: causal pose +window, bounded thinning, per-point green, no green on rejection, existing ingress +adapter, exclusive profile lease, fresh identity, frozen draft revision, stale +producer rejection, cancellation/release, persisted report, native Rerun stream, +known rigid transform recovery and negative registration cases. Frontend planner +and architecture tests pass; typecheck/build pass. Browser on canonical 8000: +LAB placement, absence of Vehicle step, saved 30 m draft, Testing modal, preparation +of real A without capture, gray reference in native Rerun, profile retained across +navigation. Final production build `app-C4fpKRYf.js` is served on canonical +`127.0.0.1:8000`; 15 frontend checks pass. The browser trial was cancelled with +`query_session_id=null`, then the managed local server was restarted and checked. +No K1 connection, capture or stream command was issued during this acceptance. +Mission Core Ops cards 81 and 79 contain the updated workflow and open hardware +acceptance items; both updates were independently read back. + +Independent B and live hardware acceptance are **not yet performed**. No claim of +navigation accuracy, a 20 m start radius, robust global relocalization, obstacle +avoidance, or adequate Mac mini compute follows from these checks. The next useful +result is B from a new project, followed by analysis of accepted/rejected windows, +receipt gaps and wrong-location controls. The entry hypothesis can mislead in +repeated geometry; green is geometric consistency, never autonomous authority. diff --git a/docs/audits/2026-09-11-planning-project-browser.md b/docs/audits/2026-09-11-planning-project-browser.md new file mode 100644 index 0000000..9322d2b --- /dev/null +++ b/docs/audits/2026-09-11-planning-project-browser.md @@ -0,0 +1,43 @@ +# LAB planning project browser — 2026-09-11 + +## Owner-approved job and composition + +The LAB planner compares point-cloud passages before any potential mission-planning use. Its two entry paths are opening an existing experiment and creating a new project through **+**. The owner explicitly requested a large scene with an overlaid, movable/resizable inspector. No primary navigation or shared visual entity was added. + +The former fixed editing column and separate comparison modal split one operator job across draft and result selectors. They are replaced with one header catalog **Совмещённые маршруты**, create, refresh and settings actions. A saved run opens its own immutable comparison immediately. New-project name, source, bounds, direction and query acquisition are inside the inspector. Settings open by default for creation/preparation; saved results open in review mode with settings available on demand. Closing settings clears the header action's pressed state without changing the selected scene. + +Canonical Design Guideline exports: ApplicationPanel headerTools, Select, IconButton, WorkspaceWindow, Inspector/InspectorSelectField, TextField, RangeControl, SegmentedControl and LoadingRegion. WorkspaceWindow is the bounded scene tool admitted by WINDOWS_AND_LAYERS.md: it owns pointer/keyboard drag, resize, maximize and reclamping. Application CSS only arranges the domain content. The previous comparison Window component is removed. + +## Project identity and evidence + +`missions/projects.py` projects existing stores; it creates no second project database. Each recorded registration or physical live experiment retains a separate `kind:run_id` identity, timestamp, frozen draft revision, sources, outcome and available evidence. Unstarted legacy drafts remain identifiable as preparation. Preparation-only cancelled live probes without a query are not presented as passage experiments. + +Catalog and detail GETs never select an active live test, start acquisition, change a draft or rerun registration. Recorded scenes are hash-checked against the persisted report on every open. Large per-point correspondence arrays are omitted from the UI detail. Live historical views use the exact committed step's hash-bound input, source sequence, query path and fitted transform, with a separate derived-view cache. The terminal unregistered preview is never substituted for a saved fit. Missing result geometry remains unavailable. + +The new project form requires a name and saved reference. **Новый проход** uses the existing planning-profile connection workflow. **Из записи** selects an already captured repeat and interval in the same inspector. Starting saves the setup, freezes it into a new run and selects that run. Viewing an existing finished project is read-only. Selection persists as optional session UI state; report authority stays on the server. Changing selection does not reorder the catalog. + +## Renderer correctness and retained limitations + +The saved renderer applies `T_reference_query` once to both query cloud and query trajectory; the reference is unchanged. A synthetic nonzero translation test verifies this convention. Existing source clouds, transforms, thresholds and result reports were not modified or recomputed during this UI task. Double surfaces visible after registration remain evidence of geometric residuals or source-map effects; the UI does not hide them or claim they were eliminated. + +The real retained B result is `820571ba-6076-482b-be34-29ceb5328c80`, JA-SADOVAYA-001 → JA-SADOVAYA-002, 2026-09-11 17:24:04 Moscow. Its report SHA-256 remains `97236f89d73f63745a77558bf585dcac5ead5ffaa8191c27afb73b52c6271d59`. The older failed live run remains a separate **Без результата совмещения** entry. Its stopped-time message is shown as historical context, not a fresh instruction to rescan. Prior all-green query rendering is explicitly labelled as the early format rather than per-point acceptance. + +## Code ownership + +- Core/backend: `missions/projects.py`, `web/mission_registration_api.py` and router composition. Read-only catalog/detail and verified scene delivery. +- UI core: `planningProjects.ts`, `usePlanningProjects.ts`, `useMissionPlanner.ts`, `useRegistrationTest.ts`, `PlanningTestContext.tsx`. +- Domain components: `PlanningProjectSettings.tsx`, `PlanningProjectResult.tsx`, existing `RegistrationScene.tsx`. Removed `MissionRegistrationWindow.tsx` and its redundant UI polling/history path. +- Workspace: `MissionPlannerWorkspace.tsx`, feature CSS, typed header portal host through App/WorkspaceRenderer. API and project behavior remain outside App. + +## Verification + +- 30 focused Python tests passed: frozen-project identity, separate run entries, preparation and failed-live states, scene digest rejection, read-only archive, committed-live-step rendering/cache, one-time query transform plus registration/planner/live regressions. +- 850 frontend tests passed. After final catalog-order/polling cleanup, 12 focused architecture/planner tests passed again. Full TypeScript check and production build passed; final build includes the small historical-message wording adjustment. +- Actual canonical 8000 browser: automatic saved-result opening, exact project selection, old recorded result, failed live entry, + with blank name and mandatory reference, query selection from saved A/B, launch disabled until valid fields, unsaved-form confirmation, settings close/reopen, keyboard movement and resizing, maximize/restore/Escape, ordinary/expanded panel, selection across page reload. +- Geometry check: inspector moved 20 px and resized from 390×600 to 400×610; stage remained 628×839.906 px. One Rerun iframe stayed mounted during settings interaction. Pointer drag/resize uses the existing canonical WorkspaceWindow; the new integration's measured geometry check used its keyboard controls. +- The temporary UI form was discarded. No new real registration, capture or device command was submitted. Three recorded reports and two live reports remained in the private stores; five visible projects were projected. No independent localization/false-positive/live-camera acceptance is implied. +- Canonical LaunchAgent was restarted to load the additive backend routes. A single backend remains on 127.0.0.1:8000; no listener on 8765. Docker backend/VM was not started. Tests/builds and browser QA ran sequentially; numerical workers were not launched for this UI change. + +## Next stage + +Use the retained A/B evidence to test successive bounded windows and wrong-region controls. The candidate remains geometric agreement, not proven robot-pose accuracy. Hardware capture, obstacle policy and vehicle control remain separate acceptance work. diff --git a/docs/audits/2026-09-11-planning-scene-recovery.md b/docs/audits/2026-09-11-planning-scene-recovery.md new file mode 100644 index 0000000..e41820c --- /dev/null +++ b/docs/audits/2026-09-11-planning-scene-recovery.md @@ -0,0 +1,88 @@ +# Planning scene and independent pass B — repair audit + +User scope: retain the ordinary spatial scene toolbar, camera, recording controls +and Planning profile; eliminate the exit action that loses the research; unify +the planner toolbar and Inspector geometry. No new capture or hardware command. + +## Retained evidence + +JA-SADOVAYA-002 was captured on 2026-09-11, 13:43:55–13:47:03 UTC. The archive +catalog marks its point-cloud and trajectory sources ready: 959 cloud messages, +960 poses, 37,812,938 raw bytes. Raw SHA-256: +`fe1366a177c20204f26e709649521611f0fc3aae138cb857bcabe84a5809268a`. +The device's last distance report is 62.532 m; sum of received pose steps is +62.974 m, including gaps, and is not independent ground truth. + +The research failed before its first registration result: 2.776 m of accumulated +movement and 14,101 preview points were retained in its status. Received pose 161 +moves 10.407 m after a 9.973 s receipt gap. The first implementation rejected any +step above 3 m without considering elapsed time. Therefore no green was produced; +this is an ingestion failure, not an experiment disproving localization. + +The selected frozen draft was the prior internal reverse-direction comparison +of the first 30 m of A, not the forward 30 m draft. This source selection is +preserved, not silently corrected. Start/direction must be checked before using +B to claim a registration result. + +B has no archived camera frames. Its camera epoch sealed with zero media segments +and `stale-activation-commit`. This is separate from the missing floating camera +UI. Camera authority fences are retained; no unsafe retry or physical command is +introduced by this patch. An unavailable recording cannot be reconstructed as +video. The next actual camera run still requires hardware acceptance. + +## Implementation + +SpatialWorkspace is extracted unchanged in ownership from the workspace hub to +`workspaces/spatial`. A typed visual-profile slot supplies the research renderer, +status, metrics and toolbar; the existing K1 controls, source picker and floating +camera transport remain owned by the shared scene. The profile no longer puts +stop controls behind an extra modal or exposes a button clearing the research. +Terminal errors outrank generic waiting/staleness. Saved research selection +restores its frozen reference and verified preview across navigation/restart, +without running a new capture or fit. Future previews are persisted with hashes. + +Receipt gaps above 2 s clear the bounded fit window and invalidate old accepted +samples. A step is rejected above max(3 m, 3 m/s × min(receipt gap, 30 s)); this +is a diagnostic plausibility bound, not odometry acceptance. Jobs carry the +segment identity and cannot commit across a gap. Slow results remain recorded +as historical evidence; the existing 8 s green freshness rule is unchanged. + +The planner uses canonical Inspector/InspectorSelectField with full-width +stacked actions. One right-aligned host toolbar owns cloud/route, top/3D and +expand actions; no nested host title. For the pinned single-view Rerun runtime, +its measured 54 CSS px canvas chrome (28 px top allocation + 26 px view strip) +is allocated outside the clipped content viewport. The Inspector scrolls independently +inside the available panel height. Scene content height and native pointer handling are preserved. + +## Validation + +Focused Python registration/planner/live tests pass (24 cases), including the +10 s receipt-gap regression, rejection of a fast coordinate jump, bounded sample +reset, persisted preview restoration and no restored live-green authority. +Frontend architecture and planner checks pass. Full frontend validation and +canonical browser smoke are recorded at completion below. + +The original failing report and raw session are retained. No localization success, +new camera capture, autonomy, control or navigation acceptance is claimed. + +## Completed acceptance on canonical 8000 + +Production typecheck/build passed. The complete frontend run executed 849 cases +(840 passed; nine obsolete architecture/source-location expectations failed). +After updating those expectations for LAB placement/shared scene extraction and +removing generic-shell vendor labels, focused reruns passed; the final remaining +four affected files passed all 50 cases. No failing assertions remain from that run. + +Browser inspection on the built localhost service verified: equal action widths +(315.2 CSS px in the observed Inspector), split canonical selects, independent +Inspector scrolling, one aligned toolbar, no visible Rerun title/help strip, +real A cloud, expand/restore/Escape, preserved forward draft, and reopening the +actual failed research with its frozen reference and explicit failure. The old +failed run has no persisted query preview; its 14,101 status points are not +reconstructed or presented as a successful result. Raw B remains intact. + +The shared source picker shows K1 cloud and both camera channels; cameras are +unconfirmed/disabled while capture is stopped. Camera/recording controls retain +the ordinary scene owners, but a new physical camera run was not performed. +The next action is analysis of existing A/B in the correct forward draft, not +an immediate repeat scan. This UI repair is not hardware-camera acceptance. diff --git a/docs/audits/2026-09-11-recorded-registration.md b/docs/audits/2026-09-11-recorded-registration.md new file mode 100644 index 0000000..f6524c0 --- /dev/null +++ b/docs/audits/2026-09-11-recorded-registration.md @@ -0,0 +1,76 @@ +# Recorded passage registration — 2026-09-11 + +The mission planner now compares a selected saved reference route with a bounded interval of another saved recording. The operator opens **Тестирование**, selects the query recording and interval, and starts **Сопоставить проходы**. The report, inputs and 3D evidence remain available after the window closes or the service restarts. This is a recorded-data experiment; no live localization or vehicle commands are produced. + +## Initial hypothesis and scope + +The selected query entry is assumed to be near the selected reference route entry, with matching travel direction. Initial translation maps those entry poses together; initial yaw aligns the first displacement of at least 3 m. Roll/pitch initially agree with the sessions' vertical axes. GICP refines all six degrees of freedom. The hypothesis is explicitly shown in the UI. This is not global place recognition and does not establish a 20 m acquisition radius. + +Both intervals must be 3–40 m. The reference comes from the immutable saved draft revision. Different recording IDs do not, by themselves, prove independent K1 projects. For B the operator must start a new K1 project and a separate Mission Core recording. Overlapping intervals of the same recording are rejected. Disjoint A outbound/return intervals are permitted as an explicitly labelled internal diagnostic, retaining shared SLAM limitations. + +## Implementation + +- `missions/registration.py`: pinned small_gicp 1.0.1 CPU GICP; source-to-reference rigid transform; finite/bounded input validation; origin-independent centering; nearest-surface evaluation and candidate rejection policy. +- `missions/registration_worker.py`: one short-lived numerical process, one computation thread, 30 s timeout. The process exits after each result. No permanent worker or second backend. +- `missions/registration_runs.py`: single admitted background job, submitted draft snapshot, generation and SHA-256 source binding, immutable per-run JSON/NPZ/RRD artifacts in the existing private data directory. Interrupted queued/running reports are marked as errors on restart. No implicit retry that would duplicate a run. +- Plugin-owned K1 extraction reads only cloud messages inside the selected pose-message interval. It selects at most 120 frames, records original message indices and receipts, crops within 20 m of the preceding recorded scanner position and between −3/+6 m relative height, then keeps the first point per 0.25 m voxel. It never applies the scanner pose a second time to K1 map-space points. No future query frames beyond the selected end enter the cloud. +- Extraction is bounded by 90 s, 2 million raw points, 1 million cropped observations and 100,000 final points. Missing real receipt timing remains null. Immutable source digests are checked before and after prefix staging/extraction. +- `web/mission_registration_api.py`: separate run/history/report/scene API. `components/missions/` and `core/missions/` own the UI. Existing route data checks retain their distinct purpose. The background cloud viewer is unmounted while the test window is open; one result Rerun realm is active, with expand/restore and teardown. + +Install only in the repository environment: `uv sync --extra localization --inexact`. Core remains usable without the optional numeric module; a calculation failure is retained as an error report. Upstream method: [small_gicp](https://github.com/koide3/small_gicp), version [1.0.1](https://pypi.org/project/small-gicp/1.0.1/). The local/global registration distinction is also described in [Open3D's registration tutorial](https://www.open3d.org/docs/release/tutorial/pipelines/global_registration.html). + +## Fixed candidate policy v1 + +0.25 m preprocessing; 1.5 m correspondence search; 40 iterations. Required: convergence, ≥55% of query points within 0.5 m of the reference, inlier point-to-point RMSE ≤0.25 m, correction at the patch center ≤3 m and rotation ≤30°. Shape covariance and diagonal-normalized information eigenvalue ratios must exceed 0.002 and 0.0001. These are experimental rejection gates fixed before the real probe, not validated safety thresholds. A candidate is not an accepted robot pose. `localization_confirmed=false`, `vehicle_control=false` on every report. + +## Measurements + +One bounded functional probe on the local arm64 Mac; no load/stress test and no Mini resource qualification. Raw source A digests remained unchanged. Display-only overview sampling was not used as registration geometry. + +| Probe | Result | Query within 0.5 m | Inlier RMSE | Registration | +|---|---|---:|---:|---:| +| Known synthetic rigid transform, 0.7/−0.4/0.2 m and 5.73° yaw | Candidate; transform recovered within 1 cm / 0.1° assertions | 100% | 0.065 m | about 0.03 s | +| A outbound 30 m vs final return 30 m; initial hypothesis perturbed by 0.7/−0.4/0.2 m and 6° | Candidate | 90.70% | 0.188 m | 0.23 s | +| A different road section, 130–155 m, deliberately seeded at reference entry | Rejected: non-convergence and residual | 59.38% | 0.274 m | 0.57 s | +| Same clouds with initial offset 1 km on each axis | Rejected: no overlap/information | 0% | unavailable | 3.27 s | +| Coincident synthetic plane | Rejected: insufficient 3D geometry | — | — | unit test | + +The A return fit differs from the original common frame by about 0.716 m / 0.906°. This is a fitting correction, not localization ground-truth error. Independent survey measurements are absent. Only one wrong-region example was checked; false-positive frequency and repeated-scene robustness remain unmeasured. + +The real server/UI comparison used a reversed 30 m reference and a 20.06 m inbound query with the route-entry/travel-heading hypothesis. Result: 89.32%, 0.194 m inlier RMSE; correction 2.94 m / 3.5°. It completed in 35.0 s initially, of which 1.60 s was registration; most time was bounded Python data extraction. A repeat with the isolated numerical process completed in 31.2 s (1.46 s registration). The first run appeared stalled during interactive inspection but did finish; a native-library deadlock was not established. Timeout isolation was retained as containment, not presented as proof of that diagnosis. + +## Validation and retained limitations + +23 focused backend tests passed (registration, planner, overview), including known-transform recovery, no-overlap/plane rejection, input limits, no future-frame inclusion, no second pose transform, immutable report/revision behavior and one-job admission. Nine planner/architecture frontend tests and typecheck/build passed. Optional localization tests require the localization extra. Real 8000 browser flow, result display, history reopening after server restart, expanded 3D and Escape restore were checked on the available narrow in-app viewport. Full desktop layout and a clean deployment host are not qualified by this check. + +Not implemented: independent B proof, sequential causal tracking, consensus over several windows, arbitrary-start/global relocalization, ground-truth accuracy, hardware timing, LAS comparison, obstacle policy or chassis control. The test consumes an already saved interval; it does not run while the operator walks. + +## Next acquisition + +A is already available. Record only B: new K1 project + separate Mission Core session, start near A's first physical start with the same travel direction, hold the scanner at roughly the same height, walk the first 20–30 m once, finish and save. Keep original A unchanged. Choose the forward 30 m mission draft, then B in Testing; the first comparison uses B's first 20 m. If acquisition starts elsewhere, record the location/direction and select the corresponding reference entry instead of silently treating it as the first start. LAS and Gaussian generation are unnecessary for this probe. + +## Independent B recording — saved comparison at 14:24 UTC + +The acquisition instruction above is now complete: JA-SADOVAYA-002 is saved. No new field recording is needed for the next analysis. One bounded comparison was executed through the canonical server on port 8000, without source changes, a restart, hardware commands or a new capture. + +- Run `820571ba-6076-482b-be34-29ceb5328c80`, 2026-09-11T14:24:04.314Z–14:24:33.127Z; started monotonic ns `514814164352083`. +- Forward draft `6159d3fa-dda4-4223-acdf-ceeceedefaad`, revision 1, **JA-SADOVAYA · проверка 30 м**. A: `20260911T085226Z_viewer_live`, poses 0–577, 30.012 m. +- B: `20260911T134352Z_viewer_live`, poses 0–251, 20.078 m, 39.530 s. Its whole recorded trajectory is approximately 62.974 m. Evidence relation is `different_recordings`; K1 SLAM reset is not independently verified by this label. +- GICP candidate, converged in 23 iterations: **91.212%** of 23,135 query evaluation points within **0.5 m** of the 30,280-point reference; inlier surface RMSE **0.1763 m**. These are geometric consistency measures, not scanner/rover pose accuracy. +- Correction from the entry/direction hypothesis: **2.940 m / 6.920°**. Translation is close to the fixed 3 m rejection gate; this run does not establish a reliable startup radius. Gates were unchanged. No arbitrary-location search was performed. +- Numerical registration **1.048 s**, complete preparation/evaluation/persistence **28.810 s** on the local Darwin arm64 Python 3.12.13 environment. This is one recorded-data probe, not a live-rate or onboard-computer qualification. +- `localization_confirmed=false`, `vehicle_control=false`. No independent ground truth or sequential-window/false-positive qualification for B yet. + +Report and artifacts are retained under private `data_dir/missions/registration-runs/820571ba-6076-482b-be34-29ceb5328c80/`. SHA-256 verification passed for all four declared artifacts: + +| Artifact | SHA-256 | +|---|---| +| report.json | `97236f89d73f63745a77558bf585dcac5ead5ffaa8191c27afb73b52c6271d59` | +| clouds.npz | `174e60a37685c83553a97500bbe89e0a7da5ec39543629c03fff1691e0c09717` | +| scene.rrd | `2824f1d3f0064d5d62dbe6823febd6c6013c162ad08ecfc57281abfe7c9069a4` | +| registration-input.npz | `5eedfda3e792bf291bab16c723646b2dfb4e8a1ca3b117a3c7e26dd15bfd4a52` | +| registration-result.json | `1c0faf1ed677391a126e8fbb5ee6d7bacdf48343581ad0c503f15f9a5d2fd52e` | + +The saved result was opened in the actual browser comparison history at **17:24:04 Moscow time** and its expanded Rerun view inspected: gray reference, colored B and green distance-qualified correspondences rendered. The short-lived numerical worker exited; port 8000 still returned HTTP 200. Docker backend/VM was not running during this probe. No new permanent process was introduced. + +Next planned experiment: replay successive bounded B windows against fixed A, retain causal evidence and assess discontinuities; add wrong-region controls before claiming stable localization. New scanning can wait for those results. The previous failed live report remains unchanged and is not reclassified by this recorded result. Missing archived B camera frames are not restored by point-cloud registration. diff --git a/docs/audits/2026-09-11-session-overview-ops-draft.md b/docs/audits/2026-09-11-session-overview-ops-draft.md new file mode 100644 index 0000000..afbf32b --- /dev/null +++ b/docs/audits/2026-09-11-session-overview-ops-draft.md @@ -0,0 +1,22 @@ +# Черновик карточки Mission Core в Ops + +Статус: после явного разрешения владельца от 2026-09-11 краткий отчёт создан и проверен в [MISSIONCOR-80](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-80), состояние Done. Предыдущие попытки были отклонены автоматической проверкой; подробные локальные идентификаторы и хеши в эту карточку не переносились. + +## Название + +Обзор сохранённой сессии: виды и верхний срез + +## Результат + +Окно информации содержит пространственный обзор, таблицу сведений и график интервалов кадров. Панели изменяют размер. Кнопки «Сверху» и «3D» переключают ракурс. Вертикальный ползунок слева скрывает точки выше выбранной высоты. Верхнее положение восстанавливает весь обзор. Срез сохраняет ручной ракурс и не изменяет исходную запись. + +## Проверка + +В браузере проверены переключение видов, ползунок мышью и клавиатурой, восстановление облака, вращение, изменение размеров панелей и возврат из информации. Проверки кода и итоговая сборка прошли. Подробный инженерный отчёт сохранён локально. + +## Приёмка + +- [x] Виды сверху и 3D работают. +- [x] Верхний срез обратим и сохраняет ракурс. +- [x] Обычный и развёрнутый размеры проверены. +- [x] Проверки и сборка прошли. diff --git a/docs/audits/2026-09-11-session-overview.md b/docs/audits/2026-09-11-session-overview.md new file mode 100644 index 0000000..c7a8b6c --- /dev/null +++ b/docs/audits/2026-09-11-session-overview.md @@ -0,0 +1,21 @@ +# Saved-session overview + +Owner request: 2026-09-11. The Info utility action opens an overview of the selected saved session. This is a view mode in Data → Sessions, not a new navigation root. The owner specified the composition: orbital point cloud and trajectory upper left, facts upper right, receive-interval chart below; both dividers resize. + +The operator checks capture size, route, modalities and delivery continuity. The selected catalog identity owns the result. Opening is observation-only and never starts acquisition. Known, missing, preparing and failed data are separate states; missing metrics remain unavailable rather than zero. Return via Info or Escape retains the selected session. + +An overview mode was selected over a modal: it provides room for spatial inspection and keeps the saved-session selector available. A narrow side inspector was rejected because it cannot accommodate the requested three resizable views. Composition uses existing ApplicationPanel.utilityActions, IconButton, GlassSurface, SplitPane and LoadingRegion. The owner-requested info icon is admitted in the Design Guideline first. + +The service prepares only the requested session, serially and off the HTTP request, retaining a generation-bound result. Catalog listing never decodes all sessions. The native source remains immutable; limited spatial samples are display derivatives. Vendor decoding belongs to the observation plugin; the host owns identity, confinement, queueing and delivery. Unsupported source representations show catalog facts without fabricated geometry. Derived LAB sessions do not silently inherit their parent's unbounded capture. + +The owner additionally requested top/3D presets and a vertical upper-height cutoff. The Design Guideline RangeControl gained the explicitly requested vertical orientation before consumption. Camera presets use Rerun EyeControls3D; clipping replaces only the sampled Points3D in the same recording identity. The pinned Rerun 0.36.3 RrdReader reads only our bounded generated RRD, never arbitrary user RRDs or the source archive. One geometry entry is retained in memory; slider requests are debounced and stale responses aborted. A cutoff update does not replace the blueprint, preserving the current orbit. Top view is a downward-looking 3D camera preset, not an orthographic export. Z is the local recording height. Trajectory and recorded metrics remain intact. + +References: [Rerun Spatial3DView / EyeControls3D](https://rerun.io/docs/reference/types/views/spatial3d_view), [Rerun blueprint/data separation](https://rerun.io/docs/concepts/visualization/blueprints). This is a visual overview and height filter, not a localization accuracy measurement or semantic tree segmentation. + +Validation completed: 842 frontend tests, full typecheck and production build; backend source/cache/plugin tests; Design Guideline registry, icon, range and split-pane contracts, package/catalog typecheck/build. Real JA-SADOVAYA-001 matches the separate audit (4,951 cloud frames; 16,475,293 point observations; 487.610554 m trajectory). Second session 35 independently yields 268 cloud frames and 697,917 observations. Original native data hashes remain unchanged. Both dividers worked by pointer and keyboard; normal and expanded sizes were inspected; Escape returned to the same selected session. Browser QA exposed and fixed Rerun relative-URL interpretation and narrow-header overflow. First preparation is proportional to archive size and can take minutes; subsequent opens use the generation-bound cache. + +Preset fitting uses the survey's dominant horizontal direction and current viewport aspect ratio. A conservative perspective fit retains a margin around the full overview bounds. Height-only updates retain the operator's orbit. Returning the slider to its upper limit clears the cutoff despite native input floating-point rounding. Compact interval charts reduce tick density to avoid label overlap. + +Final spatial acceptance passed in the canonical browser: top/3D switching; pointer and keyboard cutoff; restoration of all 128,732 JA overview points; 58,350 visible points at Z ≤ 2 m; 23,515 near Z ≤ 0.707 m; manual orbit retained on a subsequent cutoff update. Normal and expanded panel layouts remain usable. Six focused backend overview tests passed, including immutable/reversible clipping and projected preset containment for elongated surveys. The final frontend typecheck and production build passed. Service remains available on 127.0.0.1:8000 with no backend on 8765. These counts describe the display sample, not unique survey points or the complete source. + +Owner refinement, 2026-09-11: the canonical vertical RangeControl width is halved from 46 px to 23 px (measured in browser DOM). Height and range behavior remain intact. Registry/range contracts, 842 frontend tests, typecheck and production build passed. Short report accepted in MISSIONCOR-80 after explicit owner permission; next planner prototype is scoped in MISSIONCOR-81 and docs/25_MISSION_PLANNER_RECORDED_ZONE_PLAN.md. diff --git a/docs/audits/2026-09-19-causal-planning-replay.md b/docs/audits/2026-09-19-causal-planning-replay.md new file mode 100644 index 0000000..d6ad4b9 --- /dev/null +++ b/docs/audits/2026-09-19-causal-planning-replay.md @@ -0,0 +1,160 @@ +# Causal planning replay — protocol frozen before execution + +Owner scope: teach a known route manually, then repeat it. Approximately 30 s of +initial preparation is acceptable; preparation latency and tracking latency are +separate measurements. No new field capture is required for this increment. + +## Inputs and boundaries + +Reference: the immutable forward 30.012 m A submap from saved run +`820571ba-6076-482b-be34-29ceb5328c80`, including its source hashes. Only its +reference cloud and reference trajectory may be reused, never its query cloud, +fitted transform, correspondence mask or final B heading. Query: original B raw +transport and real monotonic receipt metadata, verified against archived hashes. +Reference preparation precedes the playback clock and is reported separately. + +One bounded functional replay at a time on the operator Mac, original 1× receipt +cadence, at most 120 s / 40 m, one single-thread numeric child, at most 24 fits, +30 s worker timeout, existing bounded live cloud accumulator. This is not a load +test or onboard timing qualification. No capture ingress, hardware command, +controller, API server restart or product UI changes are needed. + +## Fixed comparisons + +1. Baseline: existing route-entry / first 3 m travel heading for every fit. +2. Tracking candidate: after an accepted fresh fit, initialize the next fit from + that transform in the same K1 coordinate frame. GICP acceptance policy v1 is + unchanged. Reject a continuation changing the current scanner's mapped + position by more than 0.5 m or orientation by more than 5 degrees. Require + three consecutive compatible fresh candidates before lab state `tracking`. + These are predeclared experimental gates, not vehicle safety thresholds. + +Both modes keep >=5 s between fit requests; only already received query events +enter a snapshot. Results refer to the snapshot's last input timestamp, not to +completion time. A result older than 8 s cannot establish current tracking. +Receipt gaps >2 s clear the cloud window and all tracking authority; a fit from +the prior segment cannot be accepted after the gap. No final offline fit is used +for initialization. End of input removes current tracking authority. + +Measure first available heading, first candidate, first sustained tracking, +per-step extraction/snapshot/worker time, input-to-result age, lateness relative +to original receipts, accepted/rejected windows, correction discontinuities, +receipt gaps and retained input sequence/hash lineage. Do not call residual or +agreement with another fit ground-truth pose accuracy. + +## Negative controls and decision + +Use a deliberately wrong reference road patch selected by a fixed disjoint +trajectory interval and a synthetic far-offset initial hypothesis. Exercise +stale result, gap fencing, repeated inconsistent candidates and prefix-only +causality with small deterministic fixtures. Retain rejected reports too. + +Advance to a short physical test only if the replay explains acquisition, +tracking and loss without future data or persistent false green. Failure yields +a diagnosed next engineering step, not relaxed gates. The current first-3-m +heading still requires movement: stationary acquisition and arbitrary-start +recognition remain separate unqualified work. + +## Executed functional probes + +Two sequential 1× replays completed on the local Darwin arm64 / Python 3.12.13 +environment. Each consumed 974 pose/cloud events over approximately 63.12 s and +stopped at the fixed 40 m bound (40.038 m using the live accumulator's 5 cm pose +thinning). This distance differs slightly from the full archived pose sum and +is not ground truth. The last two windows extend beyond the selected reference +trajectory's 30 m end; they are coverage-limit diagnostics, not in-route acceptance. +No K1 command, new capture, application server restart, Docker startup or inference +was used. The source and old result checksums were unchanged. + +| Measurement | Entry-hint baseline | Previous-accepted-result mode | +|---|---:|---:| +| Numerical windows | 6 | 6 | +| Accepted candidates | 0 | 0 | +| First eligible heading/window | 30.812 s | 30.812 s | +| GICP calculation range | 0.063–0.351 s | 0.072–0.333 s | +| Latest input to completed result | 0.290–0.863 s | 0.288–0.863 s | +| Maximum replay delivery lateness | 0.154 s | 0.145 s | +| Sustained tracking | Not established | Not established | + +The second mode never reached its previous-result branch: no initial candidate +passed the existing acceptance policy. These are two functional traces, not a +throughput benchmark, full live-capture/UI qualification or Mini capacity claim. +Loading the already prepared, hash-verified reference took 0.023–0.028 s; this +does not replace the earlier measurement of preparing a reference from raw data. + +## Cause of rejection + +B has a 9.973 s receipt gap ending at 30.803 s, with 10.407 m between adjacent +received poses. The live accumulator correctly clears its query window and +starts a new segment. The first usable direction appears only after that gap: +there was no demonstrated stationary startup or stable acquisition in 30 s. + +All six windows exceeded the fixed 3 m **correction at the observed patch center**: +3.146, 3.258, 3.469, 3.638, 3.770 and 4.105 m. The fourth also failed convergence. +The corresponding heading correction is approximately 6.95–6.98 degrees. The +3 m policy is not a measured capture radius or the distance from the real rover +to the reference start. + +Despite rejection, nearest-surface coverage was 92.62–98.72%, with inlier RMSE +0.148–0.179 m. A post-run diagnostic of the rejected transforms found entry-point +corrections around 2.745–2.759 m; the moving patch-center correction grows with +distance under the initial yaw error. Consecutive rejected transforms change the +current scanner's mapped position by at most 0.016 m and orientation by at most +0.143 degrees. This is consistency of rejected geometric fits, not measured +pose accuracy, accepted tracking, or grounds for silently widening the gate. +Rejected results never initialize the tracking mode. + +## Negative controls + +The fixed third causal snapshot (22.046 m of thinned path, requested at 42.015 s) +was reused in two separate controls. Neither uses the final offline B fit. + +- Hint translated +1000 m on all axes: rejected, 0% overlap, residual unavailable, + insufficient geometric information; calculation 1.928 s. +- Different A road patch at cumulative trajectory 130–155 m, seeded at that + patch's entry using only the snapshot's preceding path: rejected by residual + 0.279 m, overlap 56.16%; calculation 0.125 s. This narrowly passing overlap + reinforces why green cannot depend on overlap alone. One wrong region is not + a false-positive rate estimate. + +## Implementation and verification + +- `device_plugins/xgrids_k1/planning_live.py`: extracted the existing decoder so + committed live input and archive replay use the same point/pose interpretation. +- `planning_replay.py`: lazy recorded events, mandatory aligned monotonic receipt + metadata, no synthetic clock or hardware transport. +- `missions/causal_replay.py`: receipt-paced bounded replay, same live accumulator, + one isolated fit worker, causal snapshots, per-step provenance and hashes. +- `missions/causal_tracking.py`: separate experimental freshness/consistency + evaluator. It is not activated in the product live controller by this work. +- `scripts/replay_planning_registration.py` and + `scripts/check_planning_replay_controls.py`: explicit private inputs, checksum + validation before/after, immutable experiment outputs. +- `tests/test_causal_planning_replay.py`: prefix-only geometry, stale and + inconsistent fits, three-candidate qualification, gap fencing, mandatory real + clock, and late completion cannot restore authority after input end. + +All 32 focused causal/live/registration/native-replay tests passed; targeted Ruff +checks passed. A subsequent terminal-state ordering fix clears authority at input +end before waiting for the final child; a regression asserts this ordering. In +both real traces all jobs completed before input end, so that fix does not alter +their results. No frontend code changed or build was needed. + +Private evidence is under `data_dir/missions/causal-replays/`: +`20260919-baseline-001`, `20260919-tracking-001`, `20260919-controls-001` and +`20260919-executed-source-001`. The latter seals the executed source before +post-run formatting plus all three report hashes. Raw geometry stays out of Git. + +## Decision and next bounded experiment + +Numerical latency is not the observed blocker at this bounded cloud size. Robust +initial acquisition is. The owner-accepted startup wait gives time to evaluate +several **predeclared** entry-position/heading hypotheses and reject ambiguity; +it does not justify accepting a failed local fit. A new acquisition experiment +must keep this failed baseline, distinguish the entry search area from the local +tracking correction limit, and require consistent subsequent causal windows. +Repeat wrong-region controls with that initializer before admitting field work. +Do not use the known final B transformation as its seed or truth. The same saved +recordings remain sufficient for the next engineering iteration. Stationary +start, recovery after already-established tracking, broader locations and actual +onboard compute remain unqualified. diff --git a/docs/audits/2026-09-19-entry-acquisition.md b/docs/audits/2026-09-19-entry-acquisition.md new file mode 100644 index 0000000..dedccb4 --- /dev/null +++ b/docs/audits/2026-09-19-entry-acquisition.md @@ -0,0 +1,169 @@ +# Entry acquisition — protocol fixed before execution + +This increment implements a bounded initial search separately from local tracking. +It preserves the failed causal baseline and GICP policy v1. The owner accepts +roughly 30 s of preparation, but a stale calculation cannot confer current +localization. There are no hardware commands or autonomous-control authority. + +## Frozen experiment v1 + +- Existing reference A forward 30.012 m, original B receipt-paced 1× input. The + old fitted B transform, correspondence mask and future B poses are forbidden. +- Initial heading still comes from the already observed first >=3 m displacement. + Standing-start heading recognition is not added in this increment. +- 27 initial hypotheses: along/across-entry offsets {-3,0,3} m in the reference + route heading basis, times yaw offsets {-15,0,15} degrees. Yaw is applied around + the query entry, not the arbitrary map origin. No vertical search. +- Each seed runs unchanged local GICP v1 (including its <=3 m patch-center + correction gate). A final acquisition must also put the query entry inside a + 5 m horizontal radius, within 1 m vertically, and within 30 degrees of the + route-entry/travel-heading hypothesis. This bounds initial search separately + from each local optimization; it does not establish a measured capture radius. +- Group accepted solutions with pairwise entry-position distance <=0.5 m and + rotation <=5 degrees. Rank by overlap, then residual. Require >=3 agreeing + hypotheses from >=2 distinct translation grid positions. Multiple seeds are + robustness checks, not independent observations or a probability of correctness. +- If any other solution group has overlap within 5 percentage points and RMSE + within 0.03 m of the best solution, reject as ambiguous, even if it has fewer + seeds. Never accept a partial/timed-out search as unambiguous. +- One numeric child / CPU thread, 27 seeds per initial search, total internal + search deadline 25 s and external child deadline 30 s. Maximum two acquisition + attempts per continuous receipt segment, separated by >=10 s on rejection. + Replay remains bounded by 120 s / 40 m and existing point limits. +- A fresh accepted search is only candidate #1. Tracking still needs three fresh + compatible causal windows at >=5 s cadence, with the unchanged 8 s freshness, + 0.5 m / 5 degree continuity checks. Following fits use the previous fresh + candidate. Gap, stale result, ambiguity, end of input or an incompatible fit + removes current tracking. Failed results never seed subsequent tracking. + +## Acceptance and controls + +First prove anchor-preserving seeds, known synthetic transform recovery, +ambiguous repeated geometry rejection, outside-search-bound rejection, budget +exhaustion and no partial-search acceptance in focused tests. Then run one real +positive causal trace and negative searches on the already frozen wrong-region +snapshot A 130–155 m / B causal step 3 and on a query displaced 1000 m with an +unchanged entry hypothesis. Preserve all attempts and checksum inputs/outputs. +The far-seed control represents this displacement by adding 1000 m to each +translation component of the initial transform, with the actual query unchanged. +All compute probes run sequentially and are bounded functional checks, not load +tests or qualification of the future onboard computer. No new field capture. + +Record first search, first accepted candidate, first 3-window tracking, time spent +tracking, losses, worker latency/input age, search clusters, replay lateness and +integrity. Wrong-region acceptance, unexplained position jumps or ambiguity block +advancement. Success on this pair does not establish pose accuracy or robustness +across locations. No parameter adjustment after a failing probe without a new, +explicitly versioned protocol and retained old result. + +## Executed evidence + +Reference A: JA-SADOVAYA-001, session `20260911T085226Z_viewer_live`, forward +30.012 m from the immutable run `820571ba-6076-482b-be34-29ceb5328c80`. +Query B: JA-SADOVAYA-002, session `20260911T134352Z_viewer_live`, raw MQTT and +original monotonic receipt metadata. Only the reference cloud/path were read +from the offline artifact; its fitted B transform and query cloud were not used. + +Positive run `20260919-acquisition-001`: 2026-09-19T10:54:19.671Z through +10:55:22.797Z, start monotonic ns 832936970922333. Darwin arm64, Python 3.12.13, +small_gicp 1.0.1, one numerical child and one CPU thread; 974 causal pose/cloud +deliveries in 63.126 s, stopped at 40.038 m. Fixed reference preparation took +0.033 s from the existing artifact and is not raw-map preparation performance. + +- First available heading/window: 30.812 s. The original receipt stream has a + 9.973 s gap and a 10.407 m pose displacement; it was preserved and fenced. + Less than 3 m displacement had been available before this gap. +- Initial search evaluated all 27 hypotheses in 1.984 s inside the worker + (2.208 s full worker wall time). Six accepted seeds formed one solution + cluster; no alternative eligible cluster. First fresh candidate at 33.033 s, + input age 2.221 s. The search used no previously known fitted transform. +- Three-window consistency first passed at 42.362 s, at the window whose query + path length was 22.046 m. Tracking stayed qualified until input ended at + 63.126 s, when it was explicitly cleared. There was no post-lock real gap in + this trace, so recovery after loss was not physically demonstrated. + +| Window | Query distance, m | Input time, s | Overlap | Inlier RMSE, m | Input age at completion, s | Temporal state | +| --- | ---: | ---: | ---: | ---: | ---: | --- | +| 1, initial search | 13.268 | 30.812 | 98.72% | 0.146 | 2.221 | acquiring | +| 2 | 17.470 | 35.950 | 98.86% | 0.154 | 0.837 | acquiring | +| 3 | 22.046 | 42.015 | 98.68% | 0.153 | 0.346 | tracking | +| 4 | 26.826 | 47.786 | 98.33% | 0.156 | 0.323 | tracking | +| 5, beyond reference path end | 31.455 | 52.940 | 97.59% | 0.161 | 0.486 | tracking | +| 6, beyond reference path end | 36.019 | 58.873 | 92.97% | 0.177 | 0.522 | tracking | + +Tracking fits took 0.068–0.117 s numerically, 0.263–0.457 s including the worker. +Maximum replay delivery lateness was 0.184 s; maximum buffer ingestion 0.00332 s. +Successive accepted transforms differed at the current scanner position by at +most 0.01494 m and 0.161 degrees. These are consistency measurements, not ground +truth pose errors. Overlap counts points within 0.5 m; it is not a correctness +probability. The last two windows test cloud coverage beyond the reference path +end and must not extend route acceptance. Only windows 3 and 4 demonstrate the +qualified tracking state while still within the selected 30 m path. + +## Negative controls + +`20260919-acquisition-controls-001`, 2026-09-19T10:55:45.043Z through +10:56:19.476Z, used the previously frozen causal step-003 query. + +- Wrong region A 130–155 m: all 27 hypotheses evaluated in 7.294 s, no eligible + solution cluster, `no-admissible-entry`. No false acceptance on this control. +- Far initial translation (+1000 m on all axes): 13 hypotheses in 26.801 s; + internal soft budget expired between seeds. `incomplete-search`, no candidate. + This proves rejection on budget exhaustion, not full search of all 27 seeds. + +The original input digests, all 31 positive and 6 control artifacts, and the +executed source digests were checked again after completion. Private evidence +is under `data_dir/missions/causal-replays/`; no geometry entered Git. Exact +sources and the later live-integration source are copied to +`20260919-acquisition-executed-source-001` with a manifest. Report SHA-256: + +- positive: `bf1a8c44e2a9960d06748d7061c19a3ae47e945242896359c57462bc402d3235` +- controls: `77cb3ca147105439439560649ad4564266f0a53fac4b280a660525e4fac511b2` + +## Implementation and validation + +`entry_acquisition.py` separates bounded entry search, complete-link solution +clustering, ambiguity checks and support gates from unchanged local GICP. +`entry_acquisition_worker.py` isolates the search with a 30 s hard child deadline. +The acquisition mode in `causal_replay.py` uses it until a fresh candidate exists, +then seeds local refinement from that candidate. No rejected fit seeds tracking. + +The existing `PlanningLiveTests` profile now uses the same entry search and +`CausalTracking` evaluator. It retains session/generation ownership, bounds the +attempt count per receipt segment, and records seed and policy provenance. +Green correspondences require three fresh consistent windows. Gaps, stale input, +rejection and input end remove green qualification; a final historical worker +cannot restore current tracking. It still has no scanner or vehicle commands. + +49 focused tests passed: entry/causal/live/projects/registration/viewer replay. +These include synthetic known-transform recovery, ambiguity with unequal support, +complete-link clustering, anchor-preserving yaw, region/height/angle limits, +partial-search rejection, prefix-only input, stale/gap fencing and live controller +integration (one initializer, then two local fits; green only on the third; +silence expires the result and releases the consumer lease). Ruff passed on new +and replay files; `git diff --check` passed. One existing Starlette/httpx +deprecation warning remains. No frontend code or layout changed this increment. + +The canonical service was restarted only after verifying idle source, no physical +scan and no camera recording. On 2026-09-19T11:04:37.611Z the new backend started +on 127.0.0.1:8000 (PID 60284); `/` and `/api/health` return 200. Nothing listens on +8765; no numerical children or replay jobs remain. Docker VM was not started. +The engineering summary, fixed protocol, controls, limitations and acceptance +checker were saved in MISSIONCOR-81 (34 preserved/appended structured blocks). + +## Decision and next stage + +The bounded initial search resolves the observed entry-acquisition failure on +this A/B pair while preserving the previous local-refinement policy. It supports +continuing the teach-and-repeat research without another capture for this step. +It does not establish arbitrary-start localization, stationary calibration, +independent metric accuracy, a reliable 5 m capture radius, or onboard capacity. +The 8 s freshness and 5 s cadence are diagnostic settings, not navigation limits. + +Next isolate recovery and initialization from travel: test deliberate receipt +loss after tracking and wrong-start/repeated-geometry cases; then develop a +stationary accumulation/heading hypothesis protocol. Preserve this successful +trace as a fixed regression. A fresh physical short pass, concurrent camera/UI +acceptance and qualification on the actual onboard computer follow separately. +No need for the operator to repeat the route now. Autonomous steering, collision +avoidance and vehicle-control authority remain outside this increment. diff --git a/docs/audits/2026-09-19-live-stationary-integration.md b/docs/audits/2026-09-19-live-stationary-integration.md new file mode 100644 index 0000000..6cb349a --- /dev/null +++ b/docs/audits/2026-09-19-live-stationary-integration.md @@ -0,0 +1,124 @@ +# Stationary bootstrap in the existing LAB planning profile + +The owner approved extending the scanner's existing preparation sequence with +reference alignment, then testing it by hand. The LAB planner remains the entry: +new named project → saved reference and section → new scanner pass or recorded +pass. This increment changes the new scanner pass. Recorded comparison retains +its existing algorithm and opening an archived result does not recalculate it. + +## Runtime and presentation + +`PlanningLiveTests` now delegates live ingestion to `stationary_live.py`, using +the already qualified `stationary-fresh-bootstrap/v1` state machine. The initial +ten seconds start at the first usable cloud with a recent matching pose, not at +session opening or during hardware calibration. Original session/generation, +receipt times, continuity and exclusive derived-consumer ownership are retained. +The numerical policy, thresholds, single prior trial and no-fallback rule are +unchanged. Capture and physical START/STOP remain owned by the K1 plugin. + +The provisional initialization is stored separately from current results. It +does not publish a tracking transform or green points. Three consistent fits on +disjoint, fresh receipt windows are required. Cancellation, source end/change, +gap, stale input, failed or expired fitting removes live authority. Numerical +work is bounded, serialized and joined before the exclusive lease is released. + +The shared plugin SDK accepts optional session-bound presentation through +`SpatialActivityPresentation`. `SpatialWorkspace` passes it into the existing +device controls. K1 shows it only while acquiring authoritative data for that +exact capture session; calibration, stopping, recovery and cleanup take priority. +The existing `K1SpatialSession` and canonical activity indicator present: + +1. Hardware calibration, unchanged. +2. «Накопление данных» — stationary input collection. +3. «Привязка к эталону» — bounded initial search. +4. «Подтверждение привязки» — new observations and temporal checks. +5. «Сопровождение» — three current consistent fits; the operator may begin the + diagnostic walk. +6. «Привязка потеряна» / completed / error — no readiness claim. + +The presentation no longer promotes one geometric candidate to success. Stale +tracking also removes the old invitation to begin walking. Camera ownership, +scene toolbar, device buttons, layout and saved-project selection remain in the +shared spatial workspace. The planner inspector and connection window both +explain waiting at the selected entry until «Сопровождение». + +## Integrated archive qualification + +Private evidence: `data_dir/missions/causal-replays/20260919-live-bootstrap-001`. +`scripts/check_planning_live_bootstrap.py` freezes protocol, exact source copies +and hashes before running the actual `PlanningLiveTests` service in isolated +storage. A read-only archive adapter supplies B at its original 1× receipt +intervals, rebased to the current monotonic clock; original receipt clocks and +epoch timestamps remain in evidence. No HTTP source replacement, synthetic +device authority, new capture, hardware commands or second server is involved. + +Fixed A is the predecessor's forward 39.926109 m / 52,965 point reference; B is +the independent saved pass. Prior fitted B transforms or future B headings are +not supplied. The original 9.972693 s / 10.406525 m receipt gap is retained. + +Run `a30fc78f-6d41-4272-9ebd-5da8528feb02`: + +- 2026-09-19 13:04:41.226–13:05:44.543 UTC; replay monotonic origin + `840758859762625`. 974 delivered events, maximum delivery delay 0.164782 s. +- Initial search starts after ten seconds. Provisional seed at 31.486786 s; + worker wall 21.464897 s. Its 21.802489 s old input is never accepted as live. +- Six fresh fits; the third establishes tracking at 44.613764 s. + Fresh worker wall 0.202423–0.329659 s; accepted source ages 0.241667–1.530939 s. + All fit receipt IDs are disjoint and strictly after their fresh-window floors. +- The real live-scene method exports a valid RRD while tracking. At the distance + bound, state is completed/lost and accepted live geometry is cleared. The + periodic UI distance field last published 39.232500 m; the terminal pose crosses + the 40 m bound. That field is not a higher-frequency trajectory measurement. +- Initialization is only a prior; input/code hashes, cleanup, temporal admission + and every one of the 72 sealed evidence files pass verification. + +SHA-256: + +- manifest: `114e823dced5d5f6b2075706b8fc12c1b2fff29969be1d2d290397da52d9b1d1` +- summary: `359f2285290215d14fe08c6e813c0f9b995db6d56d117f26acd7663c396013c2` +- seal: `d0f2485680f0d593e2b5662c2be7d0f51ff8fbc5c808870ad29a6efa9c201bc8` + +This qualification does not replace the predecessor's wrong-region controls. +The algorithm is unchanged and its existing negative/unit cases remain tested. +The archived B moved while its initialization was running. A physical scanner +held stationary until full confirmation is the next experiment, not a proved +result. Startup time is one measurement, not a fixed countdown. Geometric +agreement is not an independent measurement of navigation accuracy. + +## Validation and operator acceptance + +58 focused backend tests passed in 4.14 s: live orchestration, stationary prior, +recovery, causal replay, entry acquisition, project archive and registration. +New orchestration tests cover a long hardware-calibration period before the +first cloud, continued input during search, separate prior/current results, +three fresh confirmations, stale tracking, identity change and cancellation +during a worker. Existing Starlette/httpx deprecation warning remains. + +Frontend architecture: 4 checks; full typecheck; 852 sequential unit tests; +production build. Added presentation coverage checks exact capture identity, +calibration/stop/cleanup precedence, no one-candidate success, stale instruction +removal, recorded/end-state isolation. No new visual primitive, color, CSS rule, +native control or page composition was introduced. Ruff on the three changed +Python implementation/qualification files and `git diff --check` pass. + +In-app browser on canonical 8000: saved A/B result renders; normal and expanded +application panels retain the cloud; new project opens the existing inspector; +new/recorded pass selection changes the appropriate fields and disabled action; +Escape closes the inspector and resets its toolbar toggle. Live phase rendering +is covered by component tests, not claimed as a physical K1 browser test. +The application panel's expansion is toggled by its button; Escape acceptance +here refers to the inspector. + +All tests, replay, builds and browser QA run sequentially on Darwin arm64. No +Docker VM or second backend is started. Before restart, K1 is idle, with no +active acquisition, and the old live study is terminal. The canonical LaunchAgent +is restarted to load the handler; backend PID changes from 60284 to 68323. +Port 8765 remains unused. Raw geometry and RRD evidence stay outside Git. + +Next physical check: create a LAB project with A's first 30 m, select «Новый +проход», use a new K1 project name, start near that section's entry, keep the +scanner stationary through both hardware preparation and reference confirmation, +then walk 20–30 m slowly after «Сопровождение». Finish with the existing K1 +device/recording stop control. If confirmation fails or tracking is lost, stop +the walk and retain the recording for diagnosis. Arbitrary starts, measured +position accuracy, actual onboard compute and autonomous motion remain unaccepted. diff --git a/docs/audits/2026-09-19-physical-repeat-006-results.md b/docs/audits/2026-09-19-physical-repeat-006-results.md new file mode 100644 index 0000000..5479c59 --- /dev/null +++ b/docs/audits/2026-09-19-physical-repeat-006-results.md @@ -0,0 +1,140 @@ +# Physical repeat: 35.30 m and slow planning cloud + +Date: 2026-09-19. Read-only diagnosis requested after the operator reported a +successful approximately 35 m walk, stable video, and an unusually slow new cloud. +No product code, device configuration, runtime, or Ops card was changed. The +registration-stabilization changes remain loaded in the canonical 8000 service. + +## Identity and integrity + +The new project is also named `ja-sun-006`; it must not be confused with the +earlier failed run under that same name. + +- Run: `27a20878-9697-4735-a86e-763de28e4b67`. +- Query: `20260919T185447Z_viewer_live`, generation 1. +- Reference: `20260911T085226Z_viewer_live`; route 0–577, map 0–785, + `route-context-map/v1`, 70,870 points. +- All 83 run artifacts matched their stored hashes. Raw MQTT and metadata hashes + also matched the capture summary. Raw SHA-256: + `0723c51b7555740e802db669597e42e3f21f4438ef909e1d6ee4e89284119a88`. +- Browser diagnostics identify the deployed build `app-D2qqIbEW.js`. + +## Registration result + +The complete 108-hypothesis stationary search finished at 18:55:38.734 UTC. +After three independent fresh checks, tracking began at 18:55:51.852 UTC, +approximately 38.47 s after the first pose. The first six calculation windows +were stationary or nearly stationary; the operator moved after tracking began. + +All 15 fresh-validation results were accepted. There was no registration rejection +or receipt-gap transition during the walk. The final distance was 35.300 m +(scanner's own reported distance 35.215 m). During moving windows, overlap was +96.14–98.14%; final stationary checks were 99.37% and 99.16%. Final inlier RMSE +was 0.14384 m. This is surface agreement, not independent position accuracy. + +Accepted transform yaw ranged approximately −3.028° to −2.831°, a span of +0.197°. These are map-frame alignment angles, not an absolute heading error. +The large apparent rotation/fallback observed in the earlier run is not present +in these saved transforms. Fresh fit worker wall times were 0.246–0.517 s; the +latest input age at result admission was 0.311–1.845 s. Fits remain scheduled +approximately every five seconds, not at native pose rate. + +## End-of-run chronology + +| Event | UTC | +| --- | --- | +| Last substantial pose increment (>3 cm between adjacent receipts) | 18:56:43.810 | +| Last accepted fit, distance 35.300 m | 18:56:53.617 | +| Correlated physical STOP response | 18:56:54.625 | +| Last raw point cloud | 18:56:54.747 | +| Registration freshness expired | 18:57:00.643 | +| Camera archive sealed | 18:57:15.245 | +| Input/session closed | 18:57:44.599 | + +The final `lost/stale` is after confirmed STOP and cessation of raw cloud input, +not a loss while walking. The final report ends `input-ended`. Cleanup took longer +than physical STOP; those are separate events and must not be conflated. Current +acquisition is completed, receiver stopped, device STOP protocol-confirmed, +source/control idle, physical command inactive. No STOP command was sent by this +diagnostic work. + +## Why the cloud remains slow + +The implementation still has separate ordinary-live and planning display paths. +The previous change improved snapshot/poll cadence and alignment ownership, but +did not turn the planning preview into a native-rate streaming view. + +| Layer | Evidence | +| --- | --- | +| Raw K1 receipts | 1,013 poses and 1,013 point-cloud frames; both approximately 9.984 Hz | +| Raw cloud spacing | median 87.58 ms; p95 194.01 ms; maximum 1.001 s | +| Existing planning buffer applied offline to this raw recording | 169 cloud frames, 1.663 Hz; median spacing 0.560 s, p95 0.935 s, maximum 1.207 s | +| Fresh map-fit/evidence layer | roughly one update per five seconds | +| Browser planning requests | next request starts 500 ms after the previous fetch/send finishes; this is not guaranteed 2 FPS | + +The offline buffer count is deterministic sampling analysis, not a measurement of +the physical browser's rendering rate. The actual planning ingress consumed +1,000/1,013 cloud events (13 overflow), all 1,013 poses (zero overflow), and +941/942 camera frames (one overflow). These derived-queue counts do not indicate +loss of the raw recording. + +Source trace: + +- `missions/live_buffer.py:43–64`: accepts cloud no more often than every 500 ms; + 0.25 m voxel, up to 4,000 points/frame, 40 accumulated frames, 40,000 points + in a snapshot. At the end, these 40 selected frames cover 23.61 s of history. + This is historical accumulation, not a 23.61 s delay before adding a frame. +- `missions/stationary_live.py:233–244`: snapshots that same buffer on a separate + 500 ms gate. Changing the poll interval does not remove the upstream sampling. +- `missions/live_scene.py:25–43`: clears/replaces the entire query/path and + validation entities for each response. Green points belong to the previous + accepted fit window; they intentionally do not follow every new cloud. +- `PlanningLiveScene.tsx:25–42`: sequential HTTP download, RRD handoff, then a + 500 ms wait. No recorded per-frame fetch/decode/present acknowledgement exists + for this physical pass, so exact browser delay and its dominant cost are unknown. +- Ordinary `viewer/rerun_bridge.py:218–249,358–381` publishes each admitted + point-cloud envelope to its live Rerun stream. That is not the planning + accumulated-snapshot transport. + +The ordinary receiver recorded zero decode errors and receipt-to-publish p95 +85.212 ms. That metric belongs to the ordinary bridge, not planning's browser. +The raw stream contains approximately one-second receipt stalls, so it should not +be described as perfectly uniform. Nonetheless it supplies much more frequent +data than the planning preview admits. + +Conclusion: the complaint has a concrete implementation basis. There is no +evidence that K1 SLAM itself needs multiple seconds per new cloud in this pass. +The planning display remains deliberately downsampled and snapshot-driven. +Exact user-visible latency is not reconstructible from the retained evidence. + +## Camera + +Complete archive: 942 segments, 75,970,859 bytes. Init, index, all segment hashes, +and stream digest verified. Browser diagnostics for the recording interval show +successful playback without a decode-error/restart event. Single-threaded +offline FFmpeg decoding completed with exit 0 and no corrupt-frame/macroblock +diagnostic. It emitted 51 non-monotonic DTS lines plus two repeat notices at the +null-output muxer. Therefore decoded pictures are not showing the earlier 005 +corruption pattern, but timestamp cleanliness is not fully accepted. Exit 0 +alone is not treated as proof of a flawless video path. + +## Decision and next step + +Accept this as a successful short physical registration repeat, not as autonomous +navigation acceptance. The slow view should not be dismissed as irrelevant: +operators need a current view, and a future controller needs explicitly bounded +pose freshness rather than the age of a five-second fit or a painted cloud. + +Recommended implementation next: feed a bounded fresh cloud/pose display from the +existing ingress independently of the numerical accumulation buffer; reuse the +admitted live transport where compatible; apply the last accepted alignment to +fresh data, keeping fit evidence separate. Preserve identity, segment, freshness, +loss and no-authority fences. Do not simply increase ICP frequency or expand its +buffer. Instrument receive → sample → export → browser-present and frame sequence +so a physical display delay can actually be measured. Qualify this on the current +saved recording before requesting another field walk. No such change was made +in this diagnostic turn. + +Runtime remained `snapshot-runtime-564d78ca9bd1eb86e13bdce9b4b5be27`, PID 99603, +healthy on 8000. Temporary offline analyses and FFmpeg completed; no service, +camera, replay publisher, or device-control job was started. Ops was read only. diff --git a/docs/audits/2026-09-19-physical-start-005.md b/docs/audits/2026-09-19-physical-start-005.md new file mode 100644 index 0000000..d7971b4 --- /dev/null +++ b/docs/audits/2026-09-19-physical-start-005.md @@ -0,0 +1,120 @@ +# Physical stationary start 005: rejected acceptance + +Date: 2026-09-19. Related cards: MISSIONCOR-81 and canonical K1 integration +MISSIONCOR-3. This is a diagnosis, not a deployed correction or live acceptance. + +## Decision + +The new physical attempt failed. The previous archive-only qualification is not +sufficient evidence that planning works alongside the physical camera, acquisition +publisher and browser. Do not request another field walk until the combined path +has been qualified. No scanner command, network reconfiguration, runtime restart, +threshold relaxation or product code change was performed during this diagnosis. + +## Exact evidence + +Private sealed directory, outside Git: +`NODEDC_MISSION_CORE/.runtime/mission-core/missions/diagnostics/20260919-physical-start-005`. +It retains run/state snapshots, calculation input/output, raw MQTT with receipt +metadata, camera init/index/segments, selected browser/server diagnostics, offline +calculation outputs and the camera decode log. The root manifest hashes each file. + +Physical run `47dc5323-5d2c-4743-a13f-b4f837cf9fea`, query +`20260919T150053Z_viewer_live` (`ja-sun-005`), reference +`20260911T085226Z_viewer_live` (`JA-SADOVAYA-001`), first 30.0117 m. +The reference selection is supported; the user did not have to choose reference002. +Frozen registration input SHA-256: +`b72e1f202a04c5455aa10d2907a3912bfb3fbf76b84a079746b23aaa0cc0f9eb`. + +## Localization failure + +The stationary ten-second prefix was valid: 200 source events, 9.9745 s span, +maximum scanner movement 0.003334 m; 43,854 reference and 7,994 query points. +Search started at 15:01:33.389 UTC; the initial-failure transition occurred at +15:02:00.336 UTC. The running code used the prepared-reference v2 fix. + +Only 54 of the required 108 seeds completed within the 25-second search budget +(25.4795 s). One supported candidate had 96.3758% geometric overlap, 0.151095 m +inlier RMSE and five admitted seeds. These are geometric fit statistics, not a +localization accuracy guarantee. An incomplete search cannot rule out a competing +place hypothesis and must not be promoted to tracking. + +The failed initialization is one-shot (`maximum_initializations=1`). No fresh +validation or tracking occurred. Waiting another two minutes could not restart +this run. The displayed initial-failure status was backed by backend state, but +its truncated action text failed to explain that waiting could no longer help. + +Two sequential bounded offline checks of the exact saved input succeeded without +changing seeds, fit thresholds or deadlines: + +| Check | Seeds | Search time | Result | +| --- | ---: | ---: | --- | +| Physical combined run | 54/108 | 25.4795 s | incomplete / rejected | +| Isolated direct calculation | 108/108 | approximately 16.06 s | one candidate cluster | +| Production child worker, original thread limits | 108/108 | 16.0428 s | same candidate cluster | + +The production worker wall time was 16.2001 s. The selected overlap/RMSE and five +supporting seeds match the physical partial result. This proves a large execution +slowdown in the combined physical run, not the exact source of contention. +Neither isolated calculation supplies the three fresh validation windows required +to authorize the product's tracking state. + +## Camera failure and stable reference + +Compared against `docs/lab/010_K1_MISSION_CORE_INTERNAL_LIVE_BASELINE_20260822.redacted.md`, +`docs/audits/2026-09-06-k1-live-reference-camera-root.md`, ADR0007 and current +MISSIONCOR-3. The established path is acquisition-owned RTSP/TCP → FFmpeg H.264 +copy/remux → durable fMP4 → disposable browser MSE reader. The per-lineage camera +fast path and verified physical-ledger snapshot cache are present in current code. +There is no evidence here that those fixes were simply removed. + +In 005, a single archive epoch completed with 696 media segments / 42,239,662 +valid container bytes, and no archive failure code. "Complete" describes durable +container recording; it does not prove decodable H.264 content. + +Browser diagnostics show 24 append/restart failures with video error code 3 +(`MEDIA_ERR_DECODE`) and one late slow-reader retirement. Offline decoding of the +retained init plus all media segments in recorded index order confirms errors in +the archive itself: 166 `corrupt decoded frame` warnings, 207 macroblock decode +errors, plus 21 non-monotonic-DTS warnings. FFmpeg exited zero despite these errors; +its exit status alone must not be used as acceptance. Counts are diagnostic log +occurrences, not a claim of 166 distinct lost source frames. + +The known baseline B had no H.264 corruption, zero browser restarts/slow-reader +retirements and a 23 ms camera activation. Current activation took 2,377 ms, +including 2,061 ms authority wait and 312 ms FFmpeg preparation; first-PCL admission +was 448 ms versus baseline 65 ms. That earlier regression was caused by repeated +ledger work delaying both MQTT publication and FFmpeg stdout. The present symptoms +are consistent with contention, but no active-run CPU/lock/pipe profile was retained +to identify the current blocking call. Source transport/encoded data corruption +cannot be excluded from the existing archive alone. + +The final planning-derived queue snapshot counted 446 lidar, 523 camera-frame and +two pose overflow evictions. Those are derived-consumer counters, not proof of raw +capture loss. Public metrics reported 1,775 preview drops and zero point decode +errors. The distinction must remain explicit. + +## Next correction and acceptance + +1. Instrument bounded per-stage CPU/wall, scheduling/lock waits, raw-commit and + FFmpeg drain latency. Preserve the canonical camera ownership, exact command + lineage, copy/remux quality and durable recording. Identify the blocking path + before changing camera retries, queue sizes or fit deadlines. +2. Qualify planning with the ordinary acquisition publisher, camera/archive and + UI consumption together. Use device-free retained input; synthetic load belongs + on Worker006 per AGENTS, not the operator Mac. Already-corrupt 005 media is an + error-handling fixture, never a clean input for claiming decoder recovery. +3. Keep full entry coverage, ambiguity rejection and three disjoint fresh checks. + Add the 005 frozen geometry to private regression evidence, together with wrong + region/ambiguous/stale/changed-session controls. A fast isolated fit is not enough. +4. Present distinct operator instructions without clipping: pending means remain + stationary; confirmed means start the walk; terminal initial failure means the + search has stopped and the device/recording must be ended before a new attempt. + Do not introduce an unannounced automatic device START/STOP or retry. +5. Only then schedule a physical stationary start with camera continuity, accepted + fresh tracking, operator-visible next action and authoritative STOP/archive. + +Local memory pressure was healthy for the sequential offline checks (49% reported +free; swap stable at 4,816.81 MiB); no Docker VM or temporary worker was left running. +Canonical8000 remained available. The unresolved physical STOP/recovery snapshot +was retained; it was not overridden or converted into a successful device stop. diff --git a/docs/audits/2026-09-19-physical-start-006-diagnosis.md b/docs/audits/2026-09-19-physical-start-006-diagnosis.md new file mode 100644 index 0000000..b0a739a --- /dev/null +++ b/docs/audits/2026-09-19-physical-start-006-diagnosis.md @@ -0,0 +1,193 @@ +# JA-SUN-006: read-only diagnosis of rotation, update cadence and mounting + +Date: 2026-09-19. No product source changes, runtime restart, device commands, +Ops writes or capture modifications were performed in this diagnostic pass. +Temporary numeric checks ran sequentially against saved evidence, without +publishing replay into live ingress. Canonical service remains on port 8000. + +## Evidence and scope + +- Project label: `ja-sun-006`. +- Run: `ebee2e65-5d74-49f2-9489-1b2fecf30a76`. +- Query session: `20260919T174634Z_viewer_live`, generation 1. +- Reference: `JA-SADOVAYA-001`, `20260911T085226Z_viewer_live`. +- Frozen reference generation: + `8527e0de1995c93c835ee7223488ff851442792551e217d4ab43457974f798a1`. +- Selected reference pose interval: 0–577, route length 30.011684 m. +- All 82 report-bound artifact hashes verified. Original reference raw capture + SHA-256 verified: + `61ae4631e6d59b57cd32235871153760976f18706607c7759b8dc28060a56e51`. +- Current and related architecture context: MISSIONCOR-81, with linked + MISSIONCOR-79/80. The handheld scanner trace is not an accepted chassis route. +- User cannot establish whether the visible rotation happened during tracking + or after loss/completion; no screen recording is available. Do not present the + particular screen moment or a physical 10-degree scanner drift as proven. + +## Actual progression + +All times UTC; add three hours for the operator's Moscow clock. + +| Event | Time | Evidence | +|---|---|---| +| Usable cloud / collecting | 17:47:00.338 | Stationary prefix begins | +| Initial search | 17:47:10.350 | 108-seed bounded search | +| Initial candidate ready | 17:47:24.739 | Yaw -1.003°, overlap 98.05%, inlier RMSE 0.144 m | +| Three-window tracking established | 17:47:37.370 | Accepted fresh validation; not just point color | +| Scanner lifted and carried | approximately 17:47:47 onward | Local z rises about 1 m; quaternion changes with body turn | +| Last accepted fit | 17:48:33.310 | Distance 33.959 m, yaw -0.679°, overlap 81.0%, RMSE 0.215 m | +| Fit rejected | 17:48:39.335 | Distance 38.100 m, overlap 49.36%, RMSE 0.266 m, nonconvergence | +| Lab test ends | 17:48:41.069 | Last published distance 39.486 m; loop has a 40 m cap | + +Accepted moving fits retain yaw between approximately -1.018° and -0.661°. +After the lift/turn, overlap stays approximately 97–98% for much of the walk. +This evidence does not support blaming the scanner's approximately one-metre +height change or changed carrying orientation for a 10-degree registration turn. +It does not qualify arbitrary mounting heights/orientations either. + +`input-ended` in the final report is not proof that scanner input stopped: +`stationary_live.py` uses the same terminal reason after all normal loop exits, +including the distance cap. Raw pose/cloud continue until approximately 17:49:00. +The saved acquisition reports a later protocol-confirmed device stop. + +## Confirmed defect: live view falls back to a different transform + +`PlanningLiveTests.scene()` uses `accepted_sample` only while the source is +active, the run is running and the accepted sample is sufficiently fresh. +Otherwise it supplies the global buffer sample with no accepted result. +`live_scene.scene_bytes()` then uses `sample['hint']`. + +That hint is computed by `registration.path_hint()` from the two trajectories' +first >=3 m horizontal displacement. It assumes their early travel headings +should agree; it is not geometric localization. The stationary bootstrap +explicitly disables this fallback for localization, but the renderer still uses it. + +A bounded test called the existing `PlanningLiveTests.scene()` selection logic +on saved 006 inputs, with only clock/source/render sink substituted: + +| Display selection | Applied yaw | Path size | +|---|---:|---:| +| Last accepted tracking window | -0.678689° | 50 points | +| After rejected fit | -21.929674° | 530 points | +| After lab completion | -21.929674° | 530 points | + +Green is correctly removed in the fallback, but geometry changes by about +21.25° without a new accepted localization. This is a reproducible presentation +defect and a plausible explanation of the operator's observation. Screen-right +and perceived angle depend on camera viewpoint; the exact observed event is unknown. + +The archived project viewer uses a separate path: `projects.live_scene()` +selects the committed last calculation, including a rejected result, with its +own input window. It does not apply this preview hint. Live completion and +archive inspection must therefore not be conflated. + +## Confirmed submap coverage limitation: counterfactual control + +The run uses a single fixed reference submap extracted from the selected 30 m +route interval, while allowing a new walk of about 40 m. Reference cloud points +extend beyond the route endpoint (up to 48.49 m along the route chord), but +sparse distant visibility is not equivalent to mapping the later section. + +Control: read the same original reference capture and extract its later +10.003–49.009 m interval, poses 339–775. This respects the existing <=40 m +extraction-window bound; 120 frames yielded 52,159 retained voxel points in +2.172 seconds. No original artifact, route or runtime configuration was changed. +Use exactly the same step-016 query and the same last-accepted transform: + +| Reference data | Raw-query overlap within 0.5 m | Inlier RMSE | +|---|---:|---:| +| Original frozen submap | 50.8236% | 0.2581 m | +| Later reference interval | 98.0735% | 0.1572 m | + +This comparison changes reference coverage only, not query pose or alignment. +A subsequent single bounded GICP control on the later reference also converged: +97.6789% preprocessed overlap, RMSE 0.157108 m, five iterations, 0.0431 m +centre correction, 0.4348° rotation correction, native calculation 0.0281 s. +Its transform yaw is approximately -0.789°. + +Thus loss of overlap at the end of 006 is explained by the selected reference +coverage rather than requiring a failed scanner SLAM or changed mount hypothesis. +This retrospective control is not a new successful live run and does not grant +localization/control authority. A route endpoint and a localization map boundary +need separate handling; thresholds should not simply be relaxed. + +## Cadence: sensor arrival, calculation and display are different + +Raw capture contains 1,200 consecutive pose sequence numbers and 1,200 cloud +messages over approximately 120 seconds. Average receipt frequency is 10 Hz +for each. During the lab interval: + +| Stream | Median interarrival | p95 | Maximum | +|---|---:|---:|---:| +| Pose | 0.0892 s | 0.1735 s | 1.0285 s | +| Cloud | 0.0867 s | 0.1776 s | 1.0577 s | + +These are host receipt intervals, not calibrated sensor-to-actuator latency. +No >=2 s pose/cloud receipt gap appears, but the approximately one-second pauses +are real evidence against assuming a hard real-time guarantee. The planning +ingress snapshot also contains 14 lidar overflow drops out of 1,009 publications; +the raw recording and derived-consumer accounting are distinct. + +- `LiveCloudBuffer.ingest`: cloud selection limited to at most 2 Hz. +- `StationaryBootstrap.validation`: independent fresh fits approximately every 5 s. +- `PlanningLiveTests.scene`: displays the accepted calculation's frozen window, + rather than latest pose/cloud under the accepted transform. +- `PlanningLiveScene.tsx`: next fetch starts 2 s after the previous fetch/render + submission. This is at best 0.5 Hz polling, not a guaranteed display rate. +- Fresh fit worker wall time is approximately 0.198–0.466 s; newest input age at + completion approximately 0.246–0.850 s. A five-second collection window also + contains older observations, so newest age is not the age of every point. + +Slow drawing is not proof of a 2-second scanner sampling rate. Nevertheless, +the current LAB is not a proven rover perception/control loop. It has no vehicle +authority and admits tracking evidence up to 8 s old. Fast local pose/perception, +slower global map correction, and operator rendering must be separately timed, +bounded and measured; simply refreshing the browser faster is insufficient. + +## Mounting and route semantics + +Cloud-to-cloud registration aligns the scanner's SLAM session map with the +reference map in six degrees of freedom. A different scanner height/orientation +does not inherently require rebuilding that map or a separate SLAM algorithm. +006 already provides limited positive evidence for lift and turn after startup. + +Knowing the scanner pose is different from knowing a rover's chassis/control +point pose. Ground geometry alone does not identify an unknown chassis's forward +direction, wheel axle/control point, scanner lever arm or footprint. The missing +scanner-to-chassis transform must be known or estimated and validated at runtime. +It need not be a manually entered per-rover SLAM profile: a keyed standardized +dock or an independently qualified calibration using chassis motion/odometry +are candidate product approaches. Chassis geometry and kinematics remain real +inputs even when the sensor/computer box is transferable. + +Current planning decoding retains position but discards the already decoded K1 +orientation. This suffices to draw a scanner trajectory and register map points, +not to expose a complete chassis pose for control. Do not equate a handheld +scanner path, including lift/start manoeuvres, with the ground route to drive. + +Primary references checked: + +- [XGRIDS K1 FAQ](https://docs.xgrids.com/en-us/02-lingguang-k/01-lingguang-k1/v2.4.0/09-faq.html): stationary initialization followed by lifting is an intended scanning workflow. +- [ROS REP-105](https://github.com/ros-infrastructure/rep/blob/master/rep-0105.rst): separate continuous local odometry, global map correction and robot base frame. +- [ROS REP-103](https://github.com/ros-infrastructure/rep/blob/master/rep-0103.rst): body and optical coordinate conventions. + +## Recommended next implementation, not performed + +1. Never replace a validated transform with the travel-heading hint when tracking + expires or the test ends. Preserve clearly stale historical alignment or hide + unlocalized geometry; do not represent it as current localization. Make live + completion and archive transform selection explicit and consistent. +2. Decouple reference-map coverage from selected route length. For a short test, + constrain the evaluation to the covered corridor; later qualify rolling or + route-ahead submaps with their own evidence and ambiguity guards. +3. Separate latest scanner pose/cloud, accepted map transform and fit evidence. + Measure arrival/queue/compute/display ages independently, preserve orientation, + and define loss/stale behavior before enabling any rover consumption. +4. Qualify the scanner localization contract first, then the transferable-box + mounting/chassis contract and the sensor-path-to-drivable-route conversion. +5. First regress these changes against saved 006, existing independent passes + and wrong-place controls. Only then request a new short physical walk inside + the covered reference corridor; do not repeat the same ambiguous field test now. + +Numeric diagnostic scripts are retained under +`/private/tmp/mission-core-006.AWd3FO/`; original evidence remains under the +canonical runtime data root, outside normal Git. diff --git a/docs/audits/2026-09-19-physical-start-diagnosis.md b/docs/audits/2026-09-19-physical-start-diagnosis.md new file mode 100644 index 0000000..ade871c --- /dev/null +++ b/docs/audits/2026-09-19-physical-start-diagnosis.md @@ -0,0 +1,84 @@ +# Stationary physical start: initial acquisition did not complete + +Read-only investigation of the operator's stationary attempt on 19 September +2026. No replay, numerical recalculation, threshold change, application restart, +scanner command or new capture was performed during this review. + +## Evidence and method + +The completed live report, input arrays, per-hypothesis registration results, +receipt metadata and raw transport were inspected together with the current +entry-search and presentation code. A private immutable copy is retained at +`data_dir/missions/diagnostics/20260919-physical-start-001`: 37 files, 26,806,716 +bytes, all SHA-256 verified. Manifest SHA-256: +`bf1f5810cf9052fe4766d862c230e323ea1d66b5e66293a31eac2a0d6ea84b20`. +Its UTC/monotonic creation time, source identifiers and operator account are +retained privately. The code copy records the inspected dirty working tree, +not independent attestation of the original running process's build. + +The selected reference was the previously recorded second pass, first 30.050 m, +43,886 reference points. It differs from the first-pass reference used by the +earlier successful archived qualification. The reference trajectory contains +the already known 10.407 m step; this review does not establish that the step +caused the initial-search rejection. + +## Observed sequence (UTC) + +- 13:33:14.066: collection of the stationary prefix. +- 13:33:24.366: bounded entry search. +- 13:33:51.695: `lost / initialization-rejected`. +- 13:37:46.709: input ended; no tracking had been established. + +The ten-second prefix contained 188 derived source events, 6,098 query points +and maximum K1-reported movement of 0.006441 m. The stationary gate passed. This +movement is a scanner estimate, not an independent physical measurement. + +Search completed 47 of the required 108 hypotheses in 25.234 s against a 25 s +limit. The outer worker finished normally after 27.221 s; its 30 s process +timeout did not fire. The persisted rejection is `incomplete-search`, with no +selected candidate and no confirmation on fresh windows. + +All 47 hypotheses were rejected. Two converged fits had overlap of 96.749% and +96.603%, with inlier RMSE of approximately 0.165 m. Both were rejected solely by +the local correction bound: 3.087 m and 4.901 m against 3 m. These corrections +are measured at the observed patch centre relative to each artificial seed; +they are not the operator's physical error relative to the recorded start. +Overlap counts query points within 0.5 m of a reference point. It is not +independent localization accuracy or proof of unique place recognition. + +Seed iteration is lexicographic over translation and yaw. Indices 0–46 were +evaluated. The central translation (0,0), beginning at index 48, was not reached. +No accepted cluster existed at termination. Raising the time limit alone is +not evidence that the full search would pass support, ambiguity and fresh-data +checks. The final report's zero overlap is the rejected-result fallback, not +evidence that the actual clouds had zero overlap. + +## Additional findings + +The bootstrap performs one initial search. Its rejection terminates acquisition; +waiting longer does not retry it. Both backend and frontend presentation map +this initial failure to the generic lost-binding wording. The observed message +incorrectly suggests that previously established tracking was lost. + +The derived planning ingress recorded 102 overflow drops out of 851 published +LiDAR events and two out of 889 pose events. An empty receipt-gap list applies +only to delivered pose-window gaps; it does not establish lossless ingestion. +These counters do not establish raw-recorder loss, and they have not been +isolated as the cause of this rejection. They require a separate bounded check. + +## Decision and next stage + +The physical start has not passed acceptance. It failed in initial acquisition, +before tracking or movement, and is not evidence of localization loss during a +walk. Preserve the passing stationary observation and the failed result. + +Next, use the frozen input to qualify a complete, bounded search, examine seed +ordering and repeated preprocessing cost, and retain the multistart support, +ambiguity and fresh-confirmation gates. Compare against the previous positive +and wrong-reference controls. Distinguish unsuccessful initial binding from +loss of established tracking in the shared product status. Verify derived +ingress under the same bounded scenario before another physical walk. Do not +weaken acceptance thresholds merely to turn this example green. + +Only this diagnostic report and private evidence were added. Product code and +running device state were unchanged; no new algorithmic acceptance is claimed. diff --git a/docs/audits/2026-09-19-planning-fast-display.md b/docs/audits/2026-09-19-planning-fast-display.md new file mode 100644 index 0000000..773ff50 --- /dev/null +++ b/docs/audits/2026-09-19-planning-fast-display.md @@ -0,0 +1,158 @@ +# Planning: native receipts, bounded scene deltas, unchanged five-second fits + +Owner request: keep the expensive map check at its existing cadence; separate +fresh cloud/pose presentation from registration. Assess whether the retained +35 m physical result supports eventual supervised rover trials. No new device +command, capture, network mutation, extrinsic profile or motion authority. + +## Sources and decision + +The physical source is run `27a20878-9697-4735-a86e-763de28e4b67`, also named +`ja-sun-006`, query `20260919T185447Z_viewer_live`. Do not confuse it with the +earlier failed run bearing the same label. See the separate +`2026-09-19-physical-repeat-006-results.md` audit for its 83 verified artifacts, +camera evidence, accepted fits and STOP chronology. + +The old display reused `LiveCloudBuffer`: 169 of 1,013 native cloud packets were +selected at the numerical half-second cadence. Each browser request then sent +the entire accumulated query, followed by another 500 ms wait. This was a +presentation bottleneck, not evidence of a two-second native pose rate. + +## Implementation + +- `live_display_buffer.py`: presentation-only tap after input identity and pose + continuity validation. No 500 ms packet gate. Finite/range/causal-pose filters, + 0.25 m visual voxel, half-second chunks updated on every accepted packet, + 2,000 points per chunk, 40 fixed slots and 40,000 total displayed points. + Old slot contents are immutable; eviction carries revisioned tombstones. + This is a bounded rolling visual cloud, not a replacement for full raw capture. +- `stationary_live.py`, `live_tests.py`: attach that tap without changing the + numerical buffer, bootstrap, GICP settings, five-second fresh-check cadence, + or three-independent-window gate. Fast points/pose advance only under the last + accepted transform, in the same segment and while cloud/fit remain fresh. + Loss freezes historical geometry. Saving/restoring still binds the retained + projection to verified artifacts. Raw pose remains scanner telemetry, not a + chassis pose or navigation contract. +- `live_scene_delta.py`, `live_scene.py`: native Rerun static entities. Send + changed chunk slots, pose/path, transform or validated window only. Keep cloud + coordinates in their source frame under one accepted parent transform; do not + apply the pose twice. Green indices stay attached to their own fitted input. + Reference/blueprint is resent on initial load or display-setting changes. +- `planning_live_api.py`: read-only same-origin delta GET with bounded cursor, + run/view identity, no-store, 204 for unchanged data, and server-relative ages. + No socket, auxiliary backend, extra sensor consumer, or per-client queue. +- `planningSceneStream.ts`, `PlanningLiveScene.tsx`: one outstanding request, + 100 ms target start-to-start cadence, bounded request deadline, cursor reset + after error, mode/end-state response fencing, disposal abort, and no transfer + for unchanged completed views. Reject cloud/fit ages that expire during the + full request duration. Transport failure hides stale evidence. A delayed + `recording_open` event cannot restore a failed scene. + +Rerun's static entity replacement is intentional: its viewer can physically +discard superseded static values; fixed slot names avoid unlimited entity growth. +This is not a claim of measured browser heap stability over long durations. +[Upstream static-data semantics](https://rerun.io/docs/concepts/logging-and-ingestion/static). + +## Validation evidence + +`check_planning_live_bootstrap.py --fast-scene --queue-ingress` replays original +receipt intervals through the real decoder, bounded queues, planning service, +registration subprocess and production ASGI delta route. TestClient is in-process; +it opens no second server and cannot send commands to K1. All input/code digests +and executed sources are retained under the existing private causal-replay root. + +First positive qualification: `20260919-fast-display-006-positive-001`, isolated +run `7037b8d7-260c-4312-b4fa-21f552f0a15f`. + +- All 11 acceptance checks passed; complete 108-hypothesis search, three fresh + windows before tracking, subsequent accepted checks, distance 35.300 m. +- Final overlap 99.1581%, inlier RMSE 0.14398 m. These are surface-registration + statistics, not independent position error. +- All 1,013 lidar and 1,013 pose receipts consumed, zero queue overflow; + 1,007 packets admitted to the bounded visual accumulator. Not every receipt + must produce a visual frame: causal freshness and filtering remain active. +- 931 scene requests. During accepted live display: 668 requests over 73.183 s, + 497 distinct latest cloud revisions (about 6.8 updates/s at this polling phase, + with intermediate packets accumulated/coalesced rather than queued). +- Delta export through ASGI: median 1.94 ms, p95 25.07 ms, max 60.29 ms. + Latest receipt age at snapshot: median 75.17 ms, p95 518.40 ms, max 1.122 s. + Typical reply 43.5 KB, p95 50.3 KB; full initial projection up to 1.63 MB. +- These are receipt-to-server-snapshot/export measurements. They do not measure + network-to-browser delay, native Rerun ingestion completion or GPU presentation. + +Wrong-region qualification: `20260919-fast-display-006-negative-001`, same +independent query but reference route 130–160 m with its own surrounding context. +All 11 acceptance checks passed. Search ended without accepted tracking; the +fast path did not invent an alignment or green confirmation. + +The final delivery-age guard and preservation of stored display diagnostics were +added after these first two replay seals. They do not change the numerical inputs +or registration algorithm. Final-source validation is recorded below at handoff. + +Final-source replay: `20260919-fast-display-006-positive-002`, isolated run +`0a4d0d53-1877-473c-be94-926291dc415e`. All 11 checks passed, all 2,026 input +receipts delivered, 1,007 visual packets, no queue overflow. 917 scene requests; +accepted display 659 requests / 73.264 s and 494 distinct latest cloud revisions +(6.74/s). Snapshot age median 73.47 ms, p95 521.19 ms, maximum 1.040 s. Export +median 1.98 ms, p95 25.72 ms, maximum 62.20 ms. This final seal includes the age +headers/guard and executed scene sources; it is still not a browser frame-rate test. + +Final focused Python run: 103 passed. Architecture and stream tests, TypeScript +typecheck and full frontend suite: 860 passed after the fullscreen correction. +Production build passed. Ruff passed on eight changed Python files; git diff +whitespace check passed. Known warnings: existing Vite large chunks and installed +Starlette/httpx TestClient deprecation (no package installation attempted). + +The browser check found that the existing fullscreen button depended on a live +gRPC URL even for an independently rendered planning profile. The condition now +admits that profile after capture ends; the existing expand/restore actions use +canonical IconButton and retain the existing Escape handler. No new layout or +fullscreen mechanism was added. + +Controlled reload used the versioned LaunchAgent plan/apply path, unchanged plist +SHA-256 `5d7a58122c016f8021fede27d1b21891ee20238fc38c281e5b33574ff8204e3f`, +after fresh checks of idle control/source/camera, completed acquisition, resolved +physical command and protocol-confirmed STOP. New canonical PID 7662 accepted +health/readiness; 8765 had no listener. It restored the original physical run as +completed/historical, not as live localization. The final frontend-only rebuild +does not require another backend restart. + +In-app browser QA on the final `app-BRD3dS1N.js`: actual retained physical run +rendered through the new delta endpoint in normal 3D and top view. Expand, +Escape restore, query-layer off (only gray reference remains) and on all passed. +Historical status remained explicit; no live camera/green localization was +invented. The real 3D case was left open for owner review, with one viewer only. +No final-source browser live cadence or simultaneous camera+scene run is claimed; +that is the next physical acceptance on the same known route. + +Ops MISSIONCOR-81 updated through the direct Tasker MCP: 12 titled report/checker +blocks appended, all previous 96 blocks preserved (108 total). Physical fast-view +acceptance and independent navigation/failure-stop acceptance remain unchecked. + +## Product and authority boundaries + +The product-UI skill retained the existing Rerun host, scene modes, canonical +controls, expand/restore and error surfaces. No new shared component, navigation +root or LAB layout was introduced. Engineering diagnostics stay in evidence/Ops. + +This change qualifies faster presentation, not obstacle perception or a control +loop. A five-second map fit can coexist with fresh native pose, but the laboratory +8 s fit-age allowance is not a safe vehicle reaction/stop budget. That requires a +separate speed-dependent contract and measured failure response. + +## Assessment for supervised motion + +The physical 35.3 m walk is genuinely encouraging: all 15 fresh fits accepted, +moving overlap about 96–98%, accepted transform yaw span about 0.197°, and no +loss while walking. Terminal stale occurred after confirmed device STOP. +This supports progressing toward a slow, bounded, supervised test on the known +route; it does not establish a universal positional accuracy or safe clearance. + +Missing evidence: surveyed/cross-measured route position and heading errors, +repeat starts with varied offsets/orientations/heights, ambiguous/wrong locations, +occlusion and receipt loss, and verified stopping before an unsafe distance is +covered. High overlap and low fit RMSE cannot substitute for those tests. The +previous 25 cm clearance idea is not validated by a 14 cm surface-fit RMSE. + +Retain `vehicle_control=false` and `localization_confirmed=false`. No rover was +moved and no K1 start/stop/calibration command was issued by this implementation. diff --git a/docs/audits/2026-09-19-planning-runtime-boundaries.md b/docs/audits/2026-09-19-planning-runtime-boundaries.md new file mode 100644 index 0000000..6887cd7 --- /dev/null +++ b/docs/audits/2026-09-19-planning-runtime-boundaries.md @@ -0,0 +1,151 @@ +# Planning: startup ownership, mixed ingress and onboard dependency closure + +Date: 2026-09-19. Ops: MISSIONCOR-81; related architecture: MISSIONCOR-76/79. + +## Scope and source identity + +Implemented after the owner's approval of the second source-review findings. +Checkout: `NODEDC_MISSION_CORE_m5_observatory`, branch `main`, HEAD `be58d58`. +The working tree already contained substantial uncommitted implementation; +unrelated changes were preserved. HEAD alone does not identify this patch. + +No scanner command, new acquisition, vehicle command, network reconfiguration, +onboard installation, Docker workload or physical replay was performed. +The running canonical8000 process was not restarted for these source tests. + +## Changes + +- `src/k1link/missions/live_tests.py`: startup owns a rollback stack until the + planning thread actually starts. A busy source, report error, thread creation + error or thread-start error cannot strand the shared compute lock. A successfully + opened derived consumer is closed on startup failure. The response is prepared + before ownership transfer, so response serialization cannot release a live + worker's resources. Failed starts remain terminal in memory; report persistence + is best-effort during storage failure. Executor construction is now inside the + worker's protected lifetime. Shutdown/consumer-close/report failures still pass + through compute-lock release and clear accepted live geometry. +- `src/k1link/device_plugins/xgrids_k1/planning_live.py` and + `src/k1link/sessions/live_planning.py`: one take returns one receipt. Auxiliary + camera modalities become payload-free `kind="ignored"` events retaining their + identity, sequence and receipt timestamps. None is reserved for no receipt. + The stationary loop already skips non-geometric kinds, so it cannot interpret + a camera receipt as a drained prefix queue. There is no internal draining loop + that could delay cancellation or freshness checks under camera traffic. +- `plugins/xgrids-k1/packaging/runtime-files.json`: includes the newly required + stdlib-only `sessions/live_planning.py` ABI module. No mission algorithm, + local planning adapter or local LAB composition was added to the onboard payload. +- Tests: new `test_planning_failure_boundaries.py`, new + `test_planning_recording_ingress.py`, and additional boundary assertions in + `test_k1_installer.py`. + +No search radius, point budget, GICP threshold, ambiguity gate, wall deadline, +fresh-window rule, camera codec/queue policy or device-authority fence was relaxed. + +## Regression evidence + +The initial ten-case failure suite had nine failing cases against the old code. +It now passes; two subsequent cases cover partial report replacement/recovery +and the shared recorded ingress, giving twelve new cases in total. + +Checks include failed persist, Thread construction/start, source open, executor +construction, worker persistence and consumer close; resource release and retry; +terminal restoration after a partially committed report; both camera modalities; +and the ten-second stationary prefix with two seconds of processing backlog. +The backlog fixture uses the production adapter to produce the camera receipt +and the production stationary loop, with a fake clock and no native search. + +The shared-ingress test uses real MQTT and camera writers, real facade generation +checks/observers, the same LivePerceptionIngress, the production decoder and a +PlanningLiveTests owner. It proves raw-first MQTT delivery, committed camera +fragments before derived delivery, preserved event order, rejection of old MQTT +producer/camera epochs, and continued append-only recording after planning cancel. +Producer admission is explicitly a fixture, not physical proof; camera box bytes +are synthetic and are not a video-quality acceptance. + +The isolated onboard test copies only the declared runtime payload/resources, +imports NodeBridge outside the Core checkout, reads idle state and closes it. +It verifies the ABI is available and the local planning adapter/mission worker +are not imported. No .deb release or board installation was made. + +## Validation + +- 184 focused backend tests passed, 0 failures/errors/skips, 22.615 seconds. + Suites: new boundary/shared-ingress tests, planning live, stationary bootstrap, + causal replay, entry acquisition, mission registration, LaunchAgent, + K1 installer excluding private-release build, camera gateway/archive and MQTT + capture. XML: `/private/tmp/mission-core-source-review.TfGl1a/implementation-tests.xml`. +- Five additional acquisition-lifecycle tests passed: first authoritative cloud + before camera activation, stale published cloud rejection, later-frame retry, + STOP priority during initial camera startup, and session-clock sealing order. +- Ruff check/format passed for changed Python owners/tests; git diff --check passed. +- Canonical8000 remained operational, plugin runtime1/1. Read-only K1 state had + no acquisition, idle camera with recording.active=false, no connection intent + or control session. The existing Interactive LaunchAgent plan has identical + current/desired SHA256 `5d7a58122c016f8021fede27d1b21891ee20238fc38c281e5b33574ff8204e3f`. +- Local tests ran sequentially. Memory-pressure readout was39% free; swap readout + was4783.88MiB used, lower than the earlier4831.88MiB snapshot. No Docker workload + was started; the existing privileged vmnetd helper is not a running Docker VM. + +## Corrected source fingerprints (SHA256) + +| File | SHA256 | +| --- | --- | +| `src/k1link/missions/live_tests.py` | `58d44e7d7f0582feb2cdfdd62ed72b6d86f772da25ea3eb7d4a1c196ab9bf6e6` | +| `src/k1link/device_plugins/xgrids_k1/planning_live.py` | `82c5cf912502604479d4e5329a5d20130e37e6243d3f20645a280dd01f598610` | +| `src/k1link/sessions/live_planning.py` | `c242d4e6b42e6e59b81deafb5edfd25d9e3f301cba7376d0fa196ec4b3189dd1` | +| `plugins/xgrids-k1/packaging/runtime-files.json` | `39770adc8cdb7ff26cc7780a47024baf528508e8f2624fc98fafb02a4ce375dc` | + +## Decision and remaining acceptance + +The three source-review defects are corrected and covered. This is not proof +that they caused physical005; its damaged H.264 and unresolved STOP evidence +remain unchanged. Existing scheduling evidence is separate from this patch. + +The owner subsequently confirmed K1 was off and recording had ended. The controlled +reload and runtime acceptance below supersede the source-only handoff. This owner +confirmation does not fabricate a physical STOP receipt for the earlier005 run. + +The tests cover the relevant boundaries compositionally. They do not replace a +single full acquisition/publisher/RTSP/browser-MSE acceptance or a physical start +with clean video, complete search and three fresh validation windows. A successful +synthetic cancellation does not constitute an authoritative physical STOP. + +The accepted-entry policy remains5m, not a proven10m capture radius. Arbitrary +route-position relocalization, full chassis pose/mount calibration, route following, +new obstacles and vehicle control remain separate stages. + +## Canonical8000 reload and bounded field-test handoff + +Before reload: PID83156; K1 phase/source_mode idle, producer_generation0, +acquisition null, camera recording false and producer absent. The active planning +record remained the completed historical005 run +`47dc5323-5d2c-4743-a13f-b4f837cf9fea`; no calculation was in progress. + +The versioned `scripts/manage_mission_core_launch_agent.py` applied the freshly +reviewed, SHA-gated plan for the same WorkingDirectory. Current and desired plist +hashes were identical (`5d7a58122c016f8021fede27d1b21891ee20238fc38c281e5b33574ff8204e3f`): +no configuration, environment, scheduling class or source directory changed. +The script retained its private plist backup and accepted health after reload. + +New PID91643, runtime start `2026-09-19T17:38:39.562133Z`, runtime identity +`snapshot-runtime-c5019daa9e912b67c31aa28f49453dd2`. Health operational, plugin1/1, +one listener on8000 and none on8765. K1/camera remained idle with no recording. +The four corrected source fingerprints above still matched. This is a fresh +interpreter started from the verified corrected working tree, not a hot-patch claim. + +Actual browser check: LAB → Планировщик opens the saved +«JA-SADOVAYA · проверка 30 м» result, renders the Rerun point clouds and inspector; +reference JA-SADOVAYA-001, historical query JA-SADOVAYA-002, reference length30.01m. +Opening it did not start capture, fitting or a new planning profile. The historical +result is not presented as fresh tracking. No UI/build changes were made. + +The owner can now travel to the reference location for a **short controlled +physical verification**, not an accepted autonomous mission. Arm the new planning +profile only on arrival, close to the start of the selected reference segment; +do not promise10m acquisition or an arbitrary route-position start. Use a new +recording identity. First remain stationary until fresh tracking is established +and camera video is healthy. Only then make a short manually controlled pass. +If initialization stops, tracking is lost or video is corrupt, remain/stop on site, +finish recording through the normal controls and retain the evidence; do not wait +for an automatic second search or repeatedly restart capture. Full physical +camera/localization acceptance and authoritative stop/archive remain uncompleted. diff --git a/docs/audits/2026-09-19-recovery-stationary.md b/docs/audits/2026-09-19-recovery-stationary.md new file mode 100644 index 0000000..84cb1c1 --- /dev/null +++ b/docs/audits/2026-09-19-recovery-stationary.md @@ -0,0 +1,183 @@ +# Recovery and stationary entry — frozen protocol v1 + +This follow-up uses immutable A/B recordings only. No hardware commands, new +capture, vehicle authority or UI changes. All numerical jobs are sequential, +one child / CPU thread. Prior failed and successful traces remain unchanged. + +## Receipt recovery + +Prepare one fixed forward A submap from its first pose through the last pose +whose recorded path length is <=40 m. Same extraction v1 and 120-frame budget; +no B data used to select or build this map. Replay B at 1x, <=120 s / 40 m. +First run unchanged acquisition v1 on this reference. Then suppress all B +pose/cloud events with original relative receipt time in [44,47) seconds. +Do not shift timestamps, add poses, replay stale frames or change surviving +payloads. Save identities/times of every suppressed event. This is an injected +transport loss on real recorded geometry, not a new physical experiment. + +Acceptance: qualified tracking must exist before 44 s; the resumed receipt gap +must clear old windows and qualification. Reacquisition must use a fresh bounded +search, never retain the pre-gap matrix as current, followed by three fresh +consistent windows from the new segment. Record the exact loss/recovery times, +seed, latency and in-route distances. Failure or insufficient time is retained, +not repaired by loosening thresholds or copying an old fitted transform. +The existing 8 s result freshness policy remains unchanged for this baseline; +a <8 s silence may retain the last fresh solution until the receipt gap is +observed. This diagnostic delay is not a navigation safety policy. + +## Stationary prefix experiment + +B's first 10 s contain approximately 0.005 m maximum recorded displacement. Freeze only +that prefix, rejecting it if displacement exceeds 0.10 m. Use the existing +bounded accumulator and fixed A40 reference. No B heading from later motion, +offline B registration, or future points. Positions establish only the prefix's +map-frame entry; no independent physical stillness/accuracy is claimed. + +New stationary-entry/v1: nine offsets {-3,0,3} m along/across the reference +heading, and twelve yaw seeds 0..330 degrees at 30-degree intervals = 108 seeds. +Yaw rotates about query entry. Full yaw is searched because the K1 map-frame +heading is not assumed to match the taught route. Initial translation places +the prefix entry at the selected reference entry. Final translation bounds +remain 5 m XY / 1 m Z. No vertical or arbitrary-site search. Every seed uses +unchanged local GICP v1, including its 3 m /30-degree local correction limits. + +Reuse complete-link solution clustering, support and ambiguity gates; at least +3 supporting seeds and 2 translation positions. Require all 108 hypotheses; +25 s soft/30 s hard deadline. Incomplete search is rejected. Run the same prefix +against the already fixed wrong A130–155 m region as a negative control. If the +correct prefix is ambiguous or rejected, record that limitation. Stationary +snapshot acceptance alone is not temporal tracking or operational readiness. + +## Validation and decision gate + +Focused fixtures cover fault boundaries and unchanged event identity/time, +post-gap three-window qualification, no old segment authority, prefix-only +selection, motion rejection, yaw-anchor preservation and full-search rejection. +Record UTC/monotonic times, input/code/output hashes, all seeds and clusters. +Do not install the stationary initializer into the product merely because one +snapshot looks good. Decide the next increment from positive and negative +evidence. No new operator scan is needed for these bounded experiments. + +## Results: controlled loss and recovery + +Private experiment root: `data_dir/missions/causal-replays/20260919-recovery-stationary-001`. +The fixed A interval is 39.926109 m and 52,965 retained points. A is session +`20260911T085226Z_viewer_live`; B is `20260911T134352Z_viewer_live`. All prior A/B +artifacts and failed/successful traces remain unchanged. The reference derives +solely from A; B geometry does not select reference frames. + +Baseline: 2026-09-19T12:01:13.261Z–12:02:16.501Z; start monotonic ns +836950850212541. 974 delivered events, 63.125 s, 40.038 m. Initial candidate +33.100 s, first tracking 42.343 s. Six accepted windows. Maximum delivery lag +0.1672 s. This reproduces the previous outcome on the explicitly longer A map. + +Loss probe: 2026-09-19T12:02:37.218Z–12:03:40.468Z; start monotonic ns +837034809904750. 60 events suppressed, 914 delivered, 63.123 s / 40.027 m; +maximum delivery lag 0.1525 s. No survivor payload or timestamp was changed. +Original 9.973 s gap remains; the injected interval produces a second observed +pose gap of 3.331 s / 2.569 m displacement. + +| Event | Time from input start, s | State / evidence | +| --- | ---: | --- | +| Three-window qualification | 42.358 | tracking, segment 1 | +| Deliberately absent input | 44–47 | last result still within existing 8 s freshness limit | +| Resumed receipt reveals gap | 47.097 | lost; old matrix/windows cleared, segment 2 | +| New bounded search completes | 49.637 | acquiring, new-segment candidate 1 | +| Next fresh local refinement | 53.178 | acquiring, candidate 2 | +| Third new-segment window | 58.602 | tracking restored | +| Input ends at distance bound | 63.122 | lost; no current result retained | + +The first post-gap candidate used a new 27-seed search, not the old fitted +transform. It completed 2.540 s after observed resumption, at input age 2.572 s. +Post-gap windows were at 26.138, 31.198 and 35.911 m, all within the selected A +path, with overlap 99.09%, 98.55%, 98.02% and RMSE 0.1453, 0.1536, 0.1571 m. +Requalification took 11.505 s from observed resumption. Thus recovery from this +injected receipt loss is demonstrated, with the diagnostic 5 s fit cadence. +This does not qualify a moving vehicle's reaction time. The short silence was +recognized on the next pose; immediate silence detection is not claimed. + +## Results: heading-free stationary prefix + +The prefix contained 168 pose/cloud events through 9.933688 s and 8,227 retained +points. Maximum recorded displacement was **0.0051505 m**. This is K1's own pose +estimate, not an independent physical stillness measurement. No later B points, +direction, fitted transform or correspondence mask was used. The wrong-region +entry and basis came directly from A's saved trajectory, not a previous B seed. + +The two sequential snapshot probes ran 2026-09-19T12:04:04.176Z–12:04:46.942Z; +launcher monotonic interval 837121521919208–837164567013166 ns. The experiment +uses a frozen already-collected prefix; it is not a second wall-paced replay. + +| Control | Hypotheses | Compute wall time, s | Result | +| --- | ---: | ---: | --- | +| Correct A entry | 108/108 | 24.548 | candidate; one eligible cluster, 7 seeds | +| Wrong A130–155 m entry | 108/108 | 16.560 | rejected; no eligible cluster | + +Correct-entry overlap 89.815% within 0.5 m; inlier RMSE 0.17042 m. These are +surface-fit statistics, not localization accuracy. Seven agreeing initial +seeds are not seven independent observations. After both runs, a read-only +comparison with the earlier causal A40 candidate showed 0.10251 m difference +at the entry and 0.78413 degrees rotation. That comparison was not an input or +gate, and is not ground truth. + +An operational implementation would need about 10 s accumulation plus 25 s +search on this host/input, before fresh validation. The successful search was +close to the unchanged 25 s soft budget; a slower/ambiguous search must reject. +Its old prefix already exceeds the current 8 s freshness limit on completion. +Therefore **the snapshot candidate is not activated as live tracking**. It +needs a separate provisional initialization result and fresh-window validation, +followed by the temporal gate. The three-metre-heading requirement remains in +the currently active product profile; stationary entry is an engineering probe. + +## Implementation and verification + +- `replay_faults.py`: explicit lazy time-window filtering, preserving survivor + identity/timestamps and an audit of dropped sequence numbers. +- `stationary_entry.py`: bounded prefix, motion/gap/completeness checks and + `stationary-entry/v1` full-yaw policy. No heading extraction from B motion. +- `entry_acquisition.py`: the existing clustering and search accept an explicit + policy; the default travel-entry constants remain unchanged. Complete-search + cardinality comes from the policy's translation/yaw grid. +- `entry_acquisition_worker.py`: optional stationary mode in the same isolated + worker, retaining one thread and a 30 s hard limit. Existing callers use the + default travel mode. No hardware path or product selector added. +- `check_planning_recovery.py`: fixed-reference preparation, separate baseline, + loss and stationary stages, provenance and before/after digest verification. +- `test_recovery_stationary.py`: fault boundaries, identity/time preservation, + prefix-only geometry, motion rejection, 108 anchor-preserving hypotheses, + partial-search rejection, gap reset and three new-segment candidates. + +54 focused tests passed in 3.12 s, covering entry, causal replay, live profile, +project results, registration and viewer replay; one existing Starlette/httpx +deprecation warning. Ruff and `git diff --check` passed. Numerical jobs ran +sequentially on Darwin arm64 / Python 3.12.13 / small_gicp 1.0.1, one CPU thread. +No load test, Docker VM, new capture or vehicle commands. Frontend unchanged. +All probe processes completed. The existing canonical server was left running; +its `/api/health` returned operational `ok` after the experiments. No second +server was started or product connection restarted. The engineering results and +acceptance checker were saved in MISSIONCOR-81, preserving 40 structured blocks. + +All input digests, reference artifact, executed-source hashes and 31/31/7 +baseline/loss/stationary artifact hashes were rechecked. Exact executed sources +and focused tests are stored privately in `executed-source/`, with `seal.json`. +Report SHA-256: + +- baseline: `0c4b3d4f096f0deb065bfd21e0917f0414772861c8c6c72f44380d3f6e794f5d` +- loss: `2ac13eae870a4c4eac5d0d95d35bdb72ab46c84e4a10bf29285fae593d6bcafb` +- stationary: `1390f4b5ceb3188ff89c9a70ff34fb20cb02140335483f13ba88571a80ff2815` +- seal: `619aec16a6f5921cdfbec2f2e7fda520bd6756c8c4caaf60f26a0f97b7083339` + +## Decision + +Controlled receipt-loss recovery passed on this recording. Stationary geometry +supports a bounded heading-free initialization candidate on this pair, with +the tested wrong region rejected. Neither result establishes arbitrary-site +localization, independent pose accuracy, repeatability across locations or +onboard resource qualification. + +Next increment: make the stationary search result a provisional prior only; +validate against a current cloud before temporal qualification, while the input +continues. Separate startup waiting from maximum age while moving. Reduce +repeated fixed-reference preparation if measured helpful, retaining frozen +regression and ambiguity controls. Then qualify starts/orientations on held-out +data and the physical camera/UI path. No new operator scan is needed yet. diff --git a/docs/audits/2026-09-19-registration-stabilization-006.md b/docs/audits/2026-09-19-registration-stabilization-006.md new file mode 100644 index 0000000..eeea85a --- /dev/null +++ b/docs/audits/2026-09-19-registration-stabilization-006.md @@ -0,0 +1,152 @@ +# Registration stabilization after physical run 006 + +Date: 2026-09-19. Scope: LAB registration, live presentation, and reproducible +qualification. Rover geometry, mounting profiles, obstacle clearance, controller +integration, and autonomous motion are explicitly outside this change. Both +`vehicle_control` and `localization_confirmed` remain false. + +## Evidence and decision + +The preceding diagnosis is in +`2026-09-19-physical-start-006-diagnosis.md`. Physical run `ja-sun-006` +(`ebee2e65-5d74-49f2-9489-1b2fecf30a76`) initially registered successfully. +Its late rejection coincided with inadequate reference-map coverage past the +selected 30.01 m route. The old renderer then selected the travel-heading hint +instead of retaining the accepted transform. Neither the user's uncertain +viewing angle nor this reconstruction establishes exactly when the user noticed +the apparent rotation. The original report and raw recordings are not rewritten. + +The change addresses these demonstrated boundaries, without weakening GICP, +initial-search completeness, freshness, or temporal consistency acceptance. + +## Implemented contracts + +- `missions/reference_map.py`, `sources.py`: a live reference map includes up to + 20 m of recorded trajectory context on each side of the selected route. + `route-context-map/v1` limits extraction to three tiles of at most 40 m, + 0.25 m voxel deduplication, and 100,000 points. Source identity and hashes are + checked by the existing bound/submap path. Invalid distance ordering, excessive + interval length, insufficient continuity, and oversized maps fail closed. + Route geometry and its selected interval are unchanged. Recorded A/B comparison + remains on its existing interval-based submap implementation. +- `missions/live_presentation.py`, `live_tests.py`: latest display data, accepted + alignment, and numerical evidence are separate. A newer same-segment cloud may + advance under the last accepted transform only while its age is at most 2 s + and the accepted fit age is at most 8 s. A rejected result cannot replace that + transform. There is no travel-heading fallback before or after registration. +- `missions/live_scene.py`: newer points use ordinary height colouring. Green + correspondences belong only to the exact accepted fitted window and its own + indices/transform. Loss, expiry, source change, and completion remove live + highlighting. A terminal display is explicitly historical, not current pose. +- New runs save a hashed `aligned-preview.npz`. Restored legacy runs use their + last temporally accepted, hash-verified fit window. For old 006 this is a + historical window, not a newly reconstructed full terminal cloud. Failed or + absent integrity evidence does not authorize a substitute alignment. +- `missions/projects.py`: archive rendering follows the same accepted-alignment + rule; a versioned derived-scene cache prevents reuse of the old renderer output. + Original result metrics remain visible with a note that the scene uses the last + accepted alignment while the metrics describe the last calculation. +- `sessions/live_planning.py`, K1 `planning_live.py`: preserve native pose + orientation in the neutral event ABI. `live_tests.py` receives pose telemetry + independently of the five-second fit cycle. This is K1 session-frame telemetry, + not a chassis pose or a new navigation/control authority. +- `stationary_live.py`: scene snapshots every 0.5 s; separate distance-limit, + time-limit, cancellation, and input-end reasons. The 40 m LAB cap is unchanged. +- Frontend `PlanningLiveScene.tsx`, `PlanningSpatialWorkspace.tsx`, + `PlanningTestContext.tsx`, `planningPresentation.ts`, `planningProjects.ts`, and + `PlanningProjectResult.tsx`: 0.5 s polling with unchanged-terminal revision + suppression; explicit live/historical state and stricter cloud-age display + gating. No new design primitive, control, CSS surface, or alternative viewer. + +## Recorded-input qualification + +The extended `scripts/check_planning_live_bootstrap.py` and +`planning_archive_source.py` replay original receipt intervals at 1x through the +actual ingress queue and K1 decoder. The production reference-map builder is used +before sealing the reference. No physical K1 access, second server, accelerated +load, camera replay, or browser timing simulation was used. A 120 s input budget +allows the service's existing 40 m cap to terminate the run. + +Private evidence lives under the canonical data directory at +`missions/causal-replays/20260919-stabilization-006-positive-001` and +`missions/causal-replays/20260919-stabilization-006-negative-001`. +Each contains input/source hashes, copies of the 34 executed implementation files, +reference provenance, receipt deliveries, decisions, scene measurements, RRDs, +summary and artifact seal. The 82 original 006 artifacts were verified. + +| Check | Correct entry | Wrong reference region | +| --- | --- | --- | +| Complete initial search | 108 hypotheses; ready at 25.405 s | Complete search, not timeout | +| Tracking | Established at 38.356 s | Never established | +| End | Distance limit at 100.691 s | Initial registration rejected | +| Final accepted geometry | 98.232% overlap; inlier RMSE 0.1533 m | No accepted localization | +| Delivered pose/cloud events | 2,014 | 2,016 | +| Maximum replay delivery lag | 0.1679 s | 0.0555 s | +| Qualification checks | 11/11 | 11/11 | + +Positive run: `396d1cd4-e80e-4b87-9a9b-6d1022637404`, +2026-09-19T18:29:55.348Z–18:31:36.318Z. Tracking persisted through the final +accepted fit at 99.758 s, including the former failure region. The reference +contains 70,870 points in two tiles, route indices 0–577, map indices 0–785 +(approximately 0–50 m). The published final distance is 39.486 m; the subsequent +input reaches the 40 m termination gate. Terminal display sequence 1995 retains +alignment sequence 1983, with no live green authority. + +Negative run: `75cad066-9b20-496a-87f0-eda98a783997`; reference route 130–160 m +and context approximately 110–180 m. Rejection at 24.443 s, without tracking. +This is one wrong-region control, not a false-positive-rate estimate. + +The positive replay produced 191 scene exports: server-side export latency +p50 3.04 ms, p95 38.49 ms, maximum 171.82 ms. Maximum observed native pose age +was 0.884 s. Pose queue overflow was zero; lidar overflow was 1 of 1,007 +published frames. Unconsumed terminal queue depths (pose 3, lidar 4) are separate +from overflow. These are private replay/server measurements, not physical +sensor-to-browser or sensor-to-controller latency guarantees. Surface overlap +and inlier RMSE are not independent localization accuracy. + +## Verification and runtime acceptance + +- 99 focused Python cases passed: stabilization, live/project/failure boundaries, + planner, stationary bootstrap, recovery, causal replay, entry, registration, + and recording ingress. +- Architecture 4/4, full frontend 854/854, TypeScript typecheck, then production + build passed sequentially. Existing bundle-size warnings remain. Production + asset: `app-D2qqIbEW.js`. +- Ruff check passed for the nine selected changed/new Python modules and harness + files; this is not a whole-repository lint claim. `git diff --check` passed. +- Browser QA on real 8000: old 006 opens with the historical accepted transform; + the original rejected result remains 49.4% / 0.266 m. Its explanatory note is + readable after inspector scrolling. Planner and spatial normal/expanded modes, + 3D/top views, reset view, inspector maximize, and Escape were exercised. No + browser console errors were reported. Narrow spatial mode retains existing + limited toolbar/overlay space; expanded mode was restored. No live device was + connected and no live end-to-end browser latency is claimed. +- Before restart: source/control idle, completed acquisition, receiver stopped, + physical command inactive. Versioned LaunchAgent plan/apply succeeded with + backup and health acceptance; current and desired plist SHA-256 both + `5d7a58122c016f8021fede27d1b21891ee20238fc38c281e5b33574ff8204e3f`. + No repository/environment migration. PID 91643 → 99603. +- Runtime started 2026-09-19T18:39:44.252799Z, + `snapshot-runtime-564d78ca9bd1eb86e13bdce9b4b5be27`. Health operational, + plugin runtimes 1/1, only canonical 8000 listening and no 8765 listener. + Restored 006 is completed/ended/historical, both authority flags false. +- No Docker workloads were started. Sequential replay/test/build/browser work + respected the 18 GB host; reported free memory was 34–42% around these checks. + Replay publishers and numerical workers terminated and released leases. + +## Next physical acceptance + +Ready for another short manually carried K1 test near the known start, with a +new recording name. Wait stationary for actual tracking, then walk 20–30 m with +the same reference. Do not interpret a fixed wait time, scanner calibration +success alone, or one green cloud as permission to start. If tracking is lost, +stop the walk, retain the evidence, and finish recording through normal controls. +Evaluate startup, motion, height/orientation change, scene freshness, terminal +view, recording closure and replay together. The entire raw/archive/camera path +must still be accepted physically; this qualification did not exercise RTSP/MSE. + +The currently qualified initial search radius remains 5 m. A 10 m start envelope, +arbitrary-route global relocalization, generalized failure recovery, onboard +timing, independent pose accuracy, and autonomous driving are not accepted by +these results. No rover profile or footprint decision is needed to validate this +registration change. diff --git a/docs/audits/2026-09-19-runtime-scheduling-fix.md b/docs/audits/2026-09-19-runtime-scheduling-fix.md new file mode 100644 index 0000000..62cfed9 --- /dev/null +++ b/docs/audits/2026-09-19-runtime-scheduling-fix.md @@ -0,0 +1,127 @@ +# K1 stationary start: runtime scheduling correction + +Date: 2026-09-19. Mission Core Ops: MISSIONCOR-81; camera baseline: MISSIONCOR-3. +Follow-up to `2026-09-19-physical-start-005.md`. The owner confirmed K1 was off +before the canonical local service was restarted. No scanner command was issued. + +## Finding and correction + +The canonical8000 LaunchAgent declared `ProcessType=Background`; launchctl also +reported `spawn type = background (5)` and the Python service had priority4. +The localization child inherits this resource class. Apple's launchd manual +describes Background as applying CPU and I/O resource limits. This is unsuitable +for the application's operator-requested camera ingestion and bounded initial +localization. An HTTP request does not provide the XPC activity needed to promote +an Adaptive job. Source: installed `launchd.plist(5)` and +[Apple's launchd manual](https://github.com/apple-oss-distributions/launchd/blob/main/man/launchd.plist.5). + +Reproducing the *same frozen005 input* under `/usr/sbin/taskpolicy -b`, with no +camera or generated load, again exhausted the unchanged deadline. This identifies +an independently reproducible scheduling cause of the slowdown. It does not prove +the origin of the corrupt H.264 already present in the physical005 archive. + +The versioned `src/k1link/local_service_launchd.py` now declares Interactive, the +ordinary app resource class. It does not set realtime priority, alter system +resource limits, or change algorithms. The pre-apply plist comparison found +exactly one changed key, `ProcessType`; environment, arguments, repository, +watchdog, process-group ownership, data and camera/map configuration are identical. + +`scripts/manage_mission_core_launch_agent.py` applied the hash-gated plan, made a +private backup, reloaded the exact label and accepted `/api/health`. Its automatic +rollback remained available. Previous plist SHA-256: +`6b651aedb54236f76bf062a2319d9223f0b00f7d264451b3bbaccbee0896e065`; +new SHA-256: +`5d7a58122c016f8021fede27d1b21891ee20238fc38c281e5b33574ff8204e3f`. +Post-apply launchctl: `spawn type = interactive (4)`; one Python listener on8000, +none on8765; health operational. UI entry asset: `/assets/app-DKxsidxk.js`. + +## Evidence and bounded combined replay + +Private evidence root, outside Git: +`NODEDC_MISSION_CORE/.runtime/mission-core/missions/diagnostics/20260919-runtime-scheduling-fix`. +It retains the background reproduction, applied plan, runtime acceptance, +test/build logs, UI fixture acceptance and `combined-replay-02`. +The prior005 sealed capture is unchanged. Input identity: +`b72e1f202a04c5455aa10d2907a3912bfb3fbf76b84a079746b23aaa0cc0f9eb`. + +| Execution | Entry seeds | Search wall time | Decision | +| --- | ---: | ---: | --- | +| Physical005, before correction | 54/108 | 25.4795 s | reject incomplete search | +| Exact saved input, Background, no camera | 38/108 | 25.8272 s | reject incomplete search | +| Prior ordinary isolated child | 108/108 | 16.0428 s | complete candidate search | +| Ordinary combined archive replay | 108/108 | 14.1715 s | complete candidate search | + +The combined run replays the first65seconds of physical005 MQTT at original +receipt intervals through `LivePerceptionIngress`, the production K1 planning +decoder, `PlanningLiveTests`, bounded entry child, three disjoint fresh validation +windows and Rerun scene generation every2seconds. Search process CPU time was +14.1691seconds, close to wall time. Full108-seed coverage, five supporting seeds, +ambiguity rejection, original25second search/30second child deadlines and fresh +validation thresholds remain unchanged. Overlap96.3758% and inlier RMSE0.151095m +match the previously observed candidate; these are not measured position accuracy. + +In parallel, the helper `scripts/planning_archive_camera.py` feeds the clean +canonical camera baseline A, session `20260822T105904Z_viewer_live`, through the +real fMP4 parser, per-segment durable archive, bounded preview queue and a local +FFmpeg decoder. The helper has no RTSP or hardware producer. All70 retained media +fragments keep their recorded intervals and exact bytes; no generated load or +accelerated playback is used. The baseline's legacy index does not contain an +init receipt timestamp: init is delivered with the first media receipt, so no +initialization-latency claim is made. + +Camera commits span0.182–24.601seconds and overlap the entry search. All70 media +fragments and init were archived and delivered byte-for-byte;70 frames decoded; +decoder warning/error log empty; no slow-reader retirement. Producer/decoder +children, parser/preview threads and planning leases were released. The helper +adds a camera acceptance report to the existing replay manifest and source seal. + +Fresh validation reached tracking at37.591seconds after archive playback started. +Further fresh results stayed tracking until the65second retained prefix ended; +the service then correctly revoked live authority. No prior, stale sample or +completed run was promoted to live tracking. All combined acceptance checks passed. + +This is stronger than the earlier cloud-only replay, but is **not** full physical +acceptance: the ordinary acquisition authority/publisher, K1 RTSP encoder/network +and browser MSE are not exercised by the replay. Clean baseline video cannot +retroactively repair or certify physical005's corrupt archive. + +## Operator presentation + +Initial unsuccessful binding now says «Поиск остановлен» and explicitly instructs +the operator to stop the device/recording and start a new study for another attempt. +It has no continuing busy indicator. The production K1 status component wraps +the instruction, uses the canonical11px token and keeps it visible below960px. +Device authority, stop precedence, lost-after-established tracking, stale-data +rejection and session identity fences remain unchanged. + +Changed owners: `stationary_live.py`, `planningPresentation.ts`, +`K1SpatialSession.tsx/.css`, the duplicate responsive rule in plugin `styles.css`. +Entry diagnostics now also retain process CPU time in `entry_acquisition.py`; +the decision still uses wall time. + +## Validation and limitations + +- 48 focused backend tests passed: LaunchAgent plan/apply, entry acquisition, + stationary bootstrap and live planning. +- 55 existing camera gateway/archive regression tests passed. +- Architecture4/4, full TypeScript check, frontend853/853 and production build + passed sequentially. Build retains the existing large-chunk warning. +- Ruff passed for changed Python owners and replay helpers. +- Actual8000 UI opened a retained aligned project and its inspector; ordinary + and expanded window states work. Close-button behavior works. The existing + floating inspector did not close on Escape; that unrelated behavior is retained + as a follow-up, not represented as a passed check. +- The terminal status was rendered from the production React component and built + stylesheet in an explicitly labelled offline fixture at1280px and800px. Full + instruction visible; no ellipsis or overflow; desktop scroll height equals + client height45px. This is presentation QA, not a fabricated live session. + The temporary fixture route/tab was removed and viewport override reset. +- No Docker VM or stress test was started. Memory pressure remained healthy + (48–49% reported free), swap did not grow during qualification. Temporary + children exited. The canonical8000 service remains running. + +Next gate: qualify the remaining acquisition/MSE boundaries without changing +scanner state, then perform a short physical stationary start with continuous +camera and fresh tracking. A long field walk and autonomous vehicle authority +are not accepted by this result. Preserve physical005's unresolved STOP evidence; +the owner's report that the scanner is off is not a fabricated READY receipt. diff --git a/docs/audits/2026-09-19-selected-route-distance.md b/docs/audits/2026-09-19-selected-route-distance.md new file mode 100644 index 0000000..d65d9c8 --- /dev/null +++ b/docs/audits/2026-09-19-selected-route-distance.md @@ -0,0 +1,130 @@ +# Selected route owns the live walk distance + +Owner correction: remove arbitrary upper distance limits, not replace 40 m with +100 m. The operator may select 100, 200, 300 m or another available reference +interval. No physical acquisition or movement was requested or initiated here. + +## Contract and implementation + +- `live_limits.py`: new live runs seal the selected draft's actual `length_m` + as `maximum_distance_m`. No upper route length and no overall run timer. + The existing 3 m lower bound belongs to the initial heading basis, not a + maximum walk distance. Nonfinite or invalid lengths still fail admission. +- `live_tests.py`, `stationary_live.py`: use that sealed distance, not a + constant 40 m. Remove both the 300 s active and 1800 s waiting termination. + Completing the selected distance ends the calculation only; it never sends + scanner STOP or stops the independent raw recording. Identity, freshness, + geometric gates and individual native-worker deadlines remain unchanged. +- `reference_map.py`, `sources.py`: cover the complete selected interval plus + the existing 20 m context on either side where the recording permits it. + Prepare as many <=40 m extraction tiles as necessary; 40 m is now a tile + size, not a route cap. No tile-count or whole-map point-count truncation. + Merge/deduplicate one tile at a time at the existing 0.25 m voxel scale. + Cancellation is checked between tiles and after preparation; it releases + leases without starting acquisition. One extractor invocation still has its + own bounded CPU/data contract. +- `reference_window.py`: retain the complete route map for evidence, but for + maps above the existing 100,000-point numerical budget select a local target + around the initialization/accepted pose. Radius includes all current query + points and a 10 m search/correction neighbourhood. Smaller maps retain their + exact target. Local targets are never silently thinned to pass the budget; + missing/too-dense local support fails explicitly. Each calculation seals + its window bounds, counts and target-array SHA-256 with its input artifact. +- `live_buffer.py`: after 2000 retained trajectory vertices, decimate historical + display geometry while retaining the first and latest pose. Previously the + frozen tail could overcount subsequent distance or cause false jump errors. + Raw evidence remains untouched. Display decimation is not a pose reset. +- `PlanningProjectSettings.tsx`, `planner.ts`: reuse existing start/end metre + controls and `Вся траектория`. Live admission has no upper distance cap. + End selection chooses the preceding recorded pose rather than overshooting + the typed endpoint. The actual selected length remains visible. + +The separate one-shot **two saved recordings** comparison is not the live +tracking pipeline. Its existing explicit 3–40 m admission remains unchanged; +extending that batch estimator is not claimed by this live-walk change. +Developer archive probes retain their own explicit wall bounds; these are not +operator run limits. + +## Evidence and tests + +Immutable physical query: `20260919T185447Z_viewer_live`, original live run +`27a20878-9697-4735-a86e-763de28e4b67`, distance 35.300 m. Reference: +`20260911T085226Z_viewer_live`, full recorded path 487.611 m. Existing reports, +raw captures, metadata and code are hashed by `check_planning_live_bootstrap.py`. +Its new `--route-length-m` changes only the isolated probe's reference selection, +not any operator draft or original result. It reuses real receipt queues, +registration workers and in-process production scene GETs, without a listening +server, physical commands or a new capture. + +`20260919-selected-route-006-100m-001`: all 11 checks passed. Reference 141,543 +points / four extraction tiles; numerical targets 37,482–73,356 points. +All 1013 pose and 1013 lidar receipts consumed, zero overflow. All 15 fresh +fits accepted. Last overlap 0.98911, inlier RMSE 0.14794 m. This seal preceded +only incremental map merge/cancellation handling; the full 300 m repeat uses +the later sources. + +`20260919-selected-route-006-300m-001`, run +`02f063a1-48d8-4f9c-9ddc-ae3ae4819686`: all 11 checks passed. Actual selected +distance 299.929 m, map 302,013 points / nine tiles, local numerical targets +37,480–73,355. All 15 fresh fits accepted over the existing 35.300 m query; +final overlap 0.98945 and inlier RMSE 0.14794 m. Pose overflow zero, lidar +overflow **2 of 1013**; do not call this a zero-drop run. No tracking loss before +input ended. This is larger-map regression, not a 300 m independent traversal. + +`20260919-selected-route-006-wrong-001`, run +`97c40331-e1f5-48dc-ab8d-dce9c85f55e8`: all 11 checks passed using the wrong +reference interval starting at 130 m and extending another 100 m. Complete +108-seed search rejected the location; no tracking or accepted transform was +invented. One lidar receipt overflow, no pose overflow. Original inputs and +executed source hashes remained unchanged in both final-source qualifications. + +Focused backend validation: 128 passed. Includes live stop at selected 20, +50, 100, 200 and 300 m; no hidden waiting/active timer; admission at 10 km; +coverage across multiple tiles; cancellation and lease cleanup; local-target +budget failures; path continuation past 2000 vertices; historical display, +stale/identity/ambiguity and failure regressions. Synthetic distances prove +software termination semantics, not physical localization accuracy. + +Frontend architecture 4/4, TypeScript typecheck, complete frontend suite +861/861 and production build passed sequentially. Ruff passed for nine checked +changed/new Python modules/tests; whitespace check passed. Existing Vite +large-chunk and TestClient/httpx deprecation warnings remain. No Docker VM or +second backend; free-memory observation 50–54%, swap stable at 5784.62 MB across +checks. Temporary probe/native/test/build workers exited before the next stage. + +## Runtime and interface acceptance + +Canonical service reloaded through `manage_mission_core_launch_agent.py apply` +after fresh proof: source/control/camera idle, acquisition null, physical command +resolved/inactive, active planning run completed. Current/desired plist hashes +both `5d7a58122c016f8021fede27d1b21891ee20238fc38c281e5b33574ff8204e3f`. +Health accepted; new PID 11459. No data-root migration or device commands. +Production bundle `app-DWoNbjNw.js`. + +In-app browser on the rebuilt app: existing reference JA-SADOVAYA-001; typed +100 m selected 99.912 m, typed 300 m selected 299.929 m, and whole trajectory +selected 487.611 m. Start stayed enabled in all three cases. These are actual +recorded-pose endpoints, not fabricated exact-metre positions. Normal/expanded +settings and Escape passed. The temporary client-only draft was neither saved +nor started. Returned to the original physical ja-sun-006 historical 3D result; +scene expansion and Escape restored correctly. No real-time rate or new +physical-distance accuracy was measured by this UI check. + +Ops MISSIONCOR-81 updated through the direct Tasker MCP: 12 titled report/checker +blocks appended, previous 108 preserved (120 total). Physical long-walk and +navigation acceptance remain unchecked. + +## Boundaries + +Removing a route cap is not proof of unlimited hardware capacity. The complete +reference geometry, preparation time and stored evidence still scale with the +selected interval. This is not a disk-paged city-scale map implementation. +Individual extraction/registration budgets, corruption checks and freshness +gates remain real. No distance-based quality claim or automatic threshold +relaxation is introduced. Surface overlap/RMSE is not independent positional +accuracy. A physical 100/200/300 m repeat remains to be measured. + +The product-UI skill kept the existing inspector, canonical RangeControl/Button +and viewer composition; no new setting, visual primitive or product root. +Only the canonical durable service on 8000 may be reloaded after idle proof; +no duplicate server, Docker workload or rover/K1 command is admitted. diff --git a/docs/audits/2026-09-19-stationary-bootstrap.md b/docs/audits/2026-09-19-stationary-bootstrap.md new file mode 100644 index 0000000..5b600c9 --- /dev/null +++ b/docs/audits/2026-09-19-stationary-bootstrap.md @@ -0,0 +1,157 @@ +# Stationary prior → fresh validation → temporal tracking + +## Objective and frozen protocol + +Connect the previously qualified stationary snapshot initializer to current-data +validation. The owner accepts a startup wait. This increment checks the complete +causal chain on the existing independent A/B recordings, without another physical +scan, vehicle commands, product UI changes or activation in the live profile. + +The immutable pre-execution manifest is in private +`data_dir/missions/causal-replays/20260919-stationary-bootstrap-002`. +Preparation `-001` was not executed: a formatting-only script correction preceded +preparation `-002`. No numerical attempt is omitted. + +Inputs reuse the preceding recovery experiment's A forward 39.926109 m / 52,965 +points, B's raw transport and actual receipt clock, and the frozen negative A +130–155 m geometry. A is `20260911T085226Z_viewer_live`; B is the independent +`20260911T134352Z_viewer_live`. Input hashes are verified before and after each +run. No previous fitted B matrix, overlap mask or later travel heading seeds the +search. Metadata's one pending event supplies only its due time until delivery. + +Policy `stationary-fresh-bootstrap/v1`: + +1. Collect the first 10 s incrementally. Recorded motion must be ≤0.10 m; require + continuity and ≥300 retained points. Each event is checked, including poses + that the spatial buffer would otherwise thin. Prefix provenance is bounded. +2. Freeze only when every due prefix event has arrived. Run the existing + `stationary-entry/v1` search: 108 hypotheses, complete-search/support/ambiguity + gates, 25 s soft / 30 s process deadline, no change to local GICP thresholds. +3. A successful old fit is **only a provisional seed**. It contributes zero + temporal confirmations, no live matrix and no green/tracking authority. Its + source timestamp remains unchanged. Maximum search wall time is 30 s; maximum + prior source age 40 s; there is one initialization attempt and one seed trial. +4. Start a new empty buffer after the search actually completes. Exclude every + receipt at or before completion, including backlog delivered late. Require at + least 2 s of new cloud observations. Trial must complete within the prior's + 10 s lifetime. Input continues at original 1× pace during calculation. +5. Fit current cloud using the provisional seed, then apply unchanged + `causal-consistency/v1`: source age ≤8 s, three consistent candidates, ≤0.5 m + position change and ≤5° rotation change. Subsequent fits are at least 5 s apart. + Observation sequence IDs in successive fit windows are disjoint; the buffer + resets at each submitted sample, while new receipts continue accumulating. +6. Reject bad/partial/expired initialization, failed fresh fit, session/generation + change, source-order regression, fresh-phase receipt gap, silence and input + end. Never retry a failed provisional seed or fall back to future travel + heading. A late worker result cannot reactivate an ended run. + +The original B receipt gap occurs while initialization is running. One gap before +readiness may retain an old same-session transform **as a hypothesis only**, +subject to the separate fresh checks above. It cannot bypass `CausalTracking`'s +old-segment rejection. Any gap after readiness discards the one-shot prior and +tracking. Session/generation identity is enforced; subtle unreported SLAM frame +resets are not verified (`slam_reset_verified=false`). This distinction is an +experimental assumption, not a deployment claim. + +## Implementation + +- `missions/stationary_entry.py`: shared incremental bounded prefix collector; + old snapshot entry point wraps the same collector. +- `missions/stationary_bootstrap.py`: provisional state, source/freshness fences, + one-shot trial, disjoint fresh windows and existing temporal gate. +- `missions/stationary_replay.py`: bounded 1× orchestration, one isolated numerical + worker, concurrent input delivery, stage provenance, historical-only late jobs. +- `scripts/check_stationary_bootstrap.py`: frozen sources/code/expected outcomes, + sequential positive and wrong-region controls, immutable exact source copies. +- `tests/test_stationary_bootstrap.py`: authority, identity, timeout, gap, + partial-search and observation-fence cases. + +No change to the running travel-entry product profile, frontend, capture +ownership, device protocols or old saved experiments. + +## Results + +Both complete 1× probes passed their frozen expectations. Darwin arm64, +Python 3.12.13, small_gicp 1.0.1; single isolated CPU worker, sequential runs. + +Positive: 2026-09-19 12:26:30.527–12:27:33.648 UTC; +monotonic start 838468170195291. Delivered 974 events in 63.122767 s, +40.037509 m; maximum delivery lag 0.154619 s. The original 9.972693 s receipt +gap and 10.406525 m recorded displacement remain present. No event is replayed +early. The selected fit windows lie inside the reference's 39.926109 m extent; +the final pose crosses 40 m and ends the probe without another fit. + +The initial prefix remains 168 events / 8,227 retained points, maximum recorded +motion 0.0051505 m. It is frozen at 10.008045 s without a B travel heading. +All 108 hypotheses complete in 20.980390 s (worker wall 21.158549 s), producing +the same geometric candidate as the preceding snapshot: overlap 89.8145%, +RMSE 0.170424 m. This run's timing is a single measurement, not a guaranteed +startup deadline. At completion the newest included cloud is 21.482298 s old: +its original time is retained and it is not admitted to the live gate. + +| Stage | Seconds from first receipt | Current-data age | Temporal streak | +|---|---:|---:|---:| +| Provisional seed ready | 31.166594 | old prefix; no live admission | 0 | +| First fresh fit | 33.807565 | 0.371100 s | 1 | +| Second fresh fit | 38.757844 | 0.372068 s | 2 | +| Tracking established | 43.823601 | 0.658728 s | 3 | +| Further fresh fits | 48.835069 / 53.928964 / 59.181734 | 0.453103 / 0.475792 / 0.308357 s | 4 / 5 / 6 | +| Input ends, state cleared | 63.122606 | — | 0 | + +Six fresh windows use disjoint observation IDs, all strictly after initial search +completion; each cloud time is ≤request time≤completion time. Their B distances +are 14.624 / 19.049 / 23.296 / 27.295 / 31.905 / 36.019 m. Fit worker wall times +are 0.224275–0.328083 s including process overhead; overlaps 98.198–99.260%, +inlier surface RMSE 0.148757–0.154952 m. These are geometric consistency metrics, +not independent position errors. Disjoint receipt IDs are not independent SLAM +maps; overlapping real surfaces are expected. + +Negative A 130–155 m: 12:27:58.108–12:29:01.231 UTC; +monotonic start 838555753631833. Same 974 B events / 40.037509 m, +maximum delivery lag 0.145273 s. All 108 hypotheses finish in 14.933964 s; +`no-admissible-entry`, rejected at 25.165285 s. No prior, fresh candidate or +tracking appears; no automatic travel-heading fallback. One wrong area is not +an estimate of false-positive frequency across a location catalogue. + +## Validation and retained evidence + +65 focused tests passed in 3.34 s: stationary bootstrap, preceding stationary +prefix and receipt-loss tests, entry acquisition, causal replay, live planning, +projects, registration and archived viewer replay. Ruff and `git diff --check` +passed. Existing Starlette/httpx deprecation warning remains. + +Verified input hashes before/after runs, frozen executed-source hashes, all 36 +positive and 6 negative artifacts, source-time ordering, disjoint fresh windows, +zero authority from the old fit and cleared state at end. The private directory +contains the protocol manifest, exact code, per-step inputs/results/provenance, +receipt delivery logs, test output, summary and seal. Geometry stays outside Git. + +SHA-256: +- Positive report: `d9cbd3059531a1c26d7101a11d0d9a73c7234dfe360eae55d19ae27956a72452` +- Negative report: `723d7654d49a326a783fcc81332bb67cfe0dce5323195ca2a286de634e6ba9c9` +- Seal: `ffd380e20f46b2c70d195702e86c769b592f2259be01e2aca6806e45cdf293ef` + +Memory checks before runs showed free-memory percentage 54% then 51%; swap +4522.31 → 4370.31 MB. Docker VM was not started. Temporary worker processes exited +between runs. The canonical server was left running; no duplicate backend or +product restart was needed. + +## Decision and next boundary + +On this independent recording pair the complete sequence now works causally: +stationary prefix → bounded search → non-authoritative prior → current cloud +confirmation → temporally consistent tracking. Full readiness is about **44 s** +from first receipt in this probe, not the 21 s search duration or 31 s prior time. +The existing B moved while calculation ran; this is not a new physical test of +a rover holding still throughout readiness. + +This research protocol remains uninstalled in the product's live planning +profile; its default is still travel-entry. Next, connect this same bootstrap +state machine to the existing live profile and recording lifecycle, preserve +camera/scene controls, expose preparation/confirmation/readiness through existing +product states, and test teardown/reconnection without another scan. A separate +choice is required for route entry assumptions and trustworthy frame-reset +signalling. Then qualify different initial positions/headings and the actual +board, and perform a physical validation pass. No unrestricted start, navigation +accuracy, obstacle avoidance, permissible speed or autonomous driving is accepted. +The 5 s diagnostic cadence and 8 s freshness bound are not a motion-control loop. diff --git a/docs/audits/2026-09-19-stationary-entry-fix.md b/docs/audits/2026-09-19-stationary-entry-fix.md new file mode 100644 index 0000000..96180b6 --- /dev/null +++ b/docs/audits/2026-09-19-stationary-entry-fix.md @@ -0,0 +1,145 @@ +# Bounded initial-search repair and fresh confirmation + +The owner requested a repair after the stationary physical start failed before +tracking. The failed input and report remain immutable; see +`2026-09-19-physical-start-diagnosis.md`. + +## Changes + +`PreparedReference` builds the invariant reference voxel cloud, covariances and +search tree once per entry-search worker. Each hypothesis retains its original +seed-dependent query voxelization and patch-centre correction calculation. +Single registration still prepares its own target. No point budget, resolution, +correspondence distance, overlap/RMSE threshold or correction bound was relaxed. + +`entry-multistart/v2` and `stationary-entry/v2` visit central translations and +nearby headings first, retaining stable seed identities and the complete set of +108 stationary hypotheses. All hypotheses, cluster support, multiple translation +seeds and ambiguity checks remain mandatory; the deadline remains 25 seconds +inside a 30-second isolated worker. An incomplete search still fails closed. + +`stationary-fresh-bootstrap/v2` accounts for an existing receipt gap straddling +worker completion. Before the first fresh query cloud and before the single +prior trial is consumed, at most one segment boundary relative to the initial +prefix can begin the fresh window. The ready timestamp, ten-second prior +lifetime and strict post-ready receipt fence do not move. A second boundary, +any gap after a fresh query cloud or pending fit, expiry, stale result or loss +of identity still invalidates the prior/tracking. Three disjoint, consistent +fresh windows remain necessary for tracking. + +This boundary was discovered by retaining a failed intermediate positive +control: the faster worker finished just before the recorded ten-second gap +ended. Treating that as loss of already established tracking was incorrect. +The failed control remains in `20260919-entry-fix-positive-001`. + +The shared presentation now records whether tracking was ever established. +An initial failure reads «Привязка не выполнена»; actual loss after tracking +reads «Привязка потеряна». Incomplete initial search has specific operator copy. +Fresh results and phase copy are committed atomically, removing a transient +«Кандидат совмещения» detail during confirmation/tracking. No new control, +layout, styling, navigation or device command was added. + +## Qualification + +The extended `check_planning_live_bootstrap.py` and private +`ReceiptQueueArchiveSource` run the actual `PlanningLiveTests` service against +immutable raw captures at original 1x receipt intervals. Each probe has its own +production `LivePerceptionIngress` and `K1PlanningLiveSource`; it never attaches +to the live scanner or canonical backend. Original timestamps, rebased receipt +mapping, source hashes, exact executed source and decision files are retained. +The production RRD scene method runs every two seconds, matching UI polling. +One numerical worker and one bounded probe run at a time. + +Three final probes passed all source/code integrity, fresh-window disjointness, +complete-search, authority-removal and lease-release checks: + +| Private evidence directory | Full search | First tracking | Fresh fits | Result | +| --- | ---: | ---: | ---: | --- | +| `20260919-physical-fix-live-002` | 10.683 s | 33.981 s | 9 | Last physical stationary capture, original reference and section | +| `20260919-entry-fix-positive-002` | 18.153 s | 44.034 s | 6 | Previous independent pass with its original receipt gap | +| `20260919-entry-fix-negative-001` | 13.191 s | none | 0 | Wrong reference region, no admissible entry | + +Directories are under `data_dir/missions/causal-replays`. The physical replay +ran 2026-09-19 14:03:47.770–14:04:52.857 UTC. All 108 hypotheses completed in +each final probe. Physical-replay fresh fits took 0.252–0.412 seconds; accepted +source ages were 0.269–0.648 seconds. Initial surface overlap was 96.770% and +inlier RMSE 0.166 m, not independent localization accuracy. + +LiDAR and pose overflow counts were zero in all three private queues. Maximum +publisher delays were 0.0246, 0.0694 and 0.0625 seconds respectively. No browser +video decoding, camera producer or concurrent physical recording was replayed; +these checks do not prove absence of overflow under every live workload. + +Verified seals contain 88, 73 and 42 files respectively. Summary SHA-256: + +- Physical: `277da5a0fd3ed9579fb011503de0b28d2b75b17906bb47b53afd994064699306`. +- Positive: `7848e43a2f396d640c8f8e05efa3432e91447552efa5f3743abc2808ceab690b`. +- Negative: `24a48c089ea5287ecd47c3f7c17479cfd970e111c49775e2e20f8f984b8226f7`. + +65 focused backend tests passed, including target-reuse equivalence/isolation, +known-transform recovery, complete seed coverage, partial/ambiguous rejection, +fresh receipts, gaps, expiry, identity changes and cancellation. The strengthened +16-test stationary subset passed again after refining the gap test. + +Frontend acceptance passed sequentially: four architecture tests, full +typecheck, all 853 frontend tests and the production build. The new bundle is +`app-CMB8f3eT.js`. Ruff and `git diff --check` passed. Browser QA on the canonical +8000 endpoint reloaded that build and opened the existing saved A/B project; +the inspector opened, expanded and closed with Escape, leaving the real cloud +visible. New initial-failure versus actual-loss copy is covered by presentation +tests; no new live scanner session was started for UI QA. + +## Runtime handoff + +The frontend build is available on 8000, but the existing backend has not yet +been restarted to load this numerical repair. A fresh `/api/state` still reports +`awaiting_external_stop`, `acquisition.stop.device_stopping` and `reconnecting` +for the preceding physical acquisition. Its recovery checkpoint remains active. +The owner has been asked whether the scanner is off/stopped or still recording; +that answer is pending. No device command, force-finish, checkpoint reset or +backend interruption was performed. Keep the existing canonical service +running, confirm the physical stop, then replace that same service and verify +8000 before inviting another walk. Code/replay qualification is not a claim +that the running backend has already changed. + +The owner subsequently confirmed twice that K1 had been switched off after the +failed scan. The existing local-only force-finish action was attempted with +exact acquisition/revision/runtime/generation fences. It failed before side +effects with `acquisition-force-finish-not-recovering`: public recovery state +was reconnecting/allowed, but its acquisition lineage was absent. Re-reading +the same idempotency key returned that failed operation, not a new attempt. +This separate recovery-presentation inconsistency is retained for follow-up. + +The canonical LaunchAgent was then replaced: backend PID 68323 → 75869. +Health is operational, port 8765 has no listener, and 8000 serves +`app-CMB8f3eT.js`. The 29 implementation hashes in the final physical replay +manifest match the current source. The new process is idle with no acquisition; +the durable recovery checkpoint remains unchanged at active/revision 242 with +no automatic restart authority. No checkpoint, raw input or command ledger was +deleted or rewritten to manufacture a confirmed physical stop. + +Browser preparation created «JA-SADOVAYA · повторная проверка старта», using +JA-SADOVAYA-002 / 30.050 m and a new live pass. Run +`82ff69a7-ac67-4658-8d98-cb5944101d4c` is waiting, with query session absent, +tracking false, `stationary-entry/v2` and `stationary-fresh-bootstrap/v2` returned +by the actual 8000 API. The standard connection modal is left open, Quick +Connect selected, at «Найти по Bluetooth». Selecting that mode completed the +existing local connection reset. No discovery, provisioning, physical start or +stop command was invoked. Previous physical-command uncertainty stays in its +ledger for normal read-only verification when the scanner is reconnected. + +Private before/after states, the local-close request and failed result, health +and prepared-run evidence are under +`data_dir/missions/diagnostics/20260919-entry-fix-runtime-001`. The only live +planning owner is the deliberately prepared operator test, waiting for a new +capture. Field acceptance remains pending. + +## Decision + +The saved failure is repaired under the bounded archived scenarios. A new +physical trial is still required; no navigation, vehicle control, arbitrary +start or independent accuracy qualification is claimed. Use the same reference +and section as the failed start, a new query project, and wait without movement +through scanner preparation and reference confirmation until «Сопровождение». +Then perform the agreed short hand-carried walk. Measured replay time is not a +fixed readiness timer. diff --git a/docs/audits/2026-09-20-local-tracking-recovery-and-scene-80m.md b/docs/audits/2026-09-20-local-tracking-recovery-and-scene-80m.md new file mode 100644 index 0000000..31364f5 --- /dev/null +++ b/docs/audits/2026-09-20-local-tracking-recovery-and-scene-80m.md @@ -0,0 +1,93 @@ +# Local tracking, recovery and independent 80-m presentation + +Implementation following the operator's architecture review, 20 September 2026. +No scanner commands, new physical capture, vehicle control or deletion of raw evidence. + +## Operational contract + +The selected reference is the known working territory. Initial localisation is a hypothesis +search; ordinary tracking is local verification, not repeated global recognition. + +1. First acquisition: stationary fresh prefix, dense start-area search, provisional hypothesis. +2. Only a completed geometric rejection permits the existing selected-route fallback. + A time budget expiring is incomplete computation, not proof that the place is wrong. +3. Three disjoint fresh checks are needed for tracking. Original quality thresholds remain. +4. Tracking uses the established local 20-m input profile. Spatially selected reference windows + preserve 0.25-m map sampling. More than 100,000 target points is not itself a localisation failure. +5. After previously established tracking is lost, accepted live authority clears immediately. + The same session remains running and capture ownership stays with the recorder. +6. Recovery collects a new stationary prefix, searches the last confirmed reference position + first, then uses the existing route fallback when applicable. Historical position is a search + hint only, not a valid current transform. Failed recovery attempts repeat using new data. +7. Movement during recovery restarts stationary collection. Coordinate jumps do not count as + travelled distance. New session/generation cannot silently inherit an old registration. +8. Operator cancellation and actual input-session end still end the research. Selected-route + distance completion remains the existing diagnostic-run completion rule. + +Initial acquisition failure remains an actionable manual reinitialisation state. A moved manual +retry does not secretly start another attempt without a click. Initialisation and post-tracking +recovery are intentionally different states. + +## Display and clipping + +The presentation profile has an independent 80-m radial envelope and no hidden relative-height +filter. A separate scene atlas is produced from reference raw frames; numerical registration +never consumes this presentation derivative. Display LOD budgets remain, with raw data retained. + +The right-side vertical slider has upper value 80 m. Its top position means no user ceiling +(`ceiling_m` omitted); lowering it clips in reference-frame Z. Range is not inferred from the +current cloud's maximum Z and is not a claim that every recording contains points at 80 m. +An 80-m radial range and 80-m Z ceiling are different coordinate concepts. + +New runs retain a hash-bound `scene-reference.npy` separately from `reference.npy`. +Old numerical artefacts are not rewritten; historical reference presentation can be rebuilt +from verified source data, while old query previews retain their originally saved content. + +In the common Design Guideline StatusBadge, opt-in pulse animates only the lamp and respects +reduced motion. Mission Core uses it for lost/recovering/error, not for the panel background. +Search and fresh-confirmation banners omit the redundant grey descriptions. + +## Verification + +162 focused Python tests passed before archive qualification, including recovery after stale +data, insufficient local reference, rejected fit, worker failure and pose discontinuity. +Each recovery fixture reacquires with fresh observations; no capture stop is issued. + +Actual live service at 1× recorded receipt cadence, production derived ingress, isolated +in-process scene-delta endpoint, no listening test server: + +- 014: complete available recording, 38.262 m; 15 accepted fresh fits. A final in-flight fit is + rejected for input-ended, not density/registration failure. All 11 qualification checks pass. + Evidence: local private `.runtime/qualification-20260920-v5-014`. +- 007: 102.594 m, 25/25 fresh fits accepted, no recovery/loss before selected-distance completion. + All 11 checks pass. Evidence: local private `.runtime/qualification-20260920-v5-007`. +- Separate display atlas: 190,384 points, Z −2.904…62.702 m; numerical reference remains + 155,806 points, Z −0.741…7.439 m. Thus the old visual ceiling was a preparation filter, + not merely a slider label. + +Original input checksums and executed-code checksums are retained with each qualification. +All leases released and current alignment invalidated after source end. + +Two further plugin-contribution tests pass (164 focused Python tests total). +Frontend: 24 focused tests; Design Guideline: 7 tests plus registry validation. +Ruff passes for the changed orchestration/registration modules; production TypeScript/Vite +build passes (existing large-bundle advisory remains). + +Applied to the existing canonical service on 8000 using the versioned LaunchAgent manager: +idle and terminal-research preconditions checked, backup retained, health accepted. +Browser QA on the actual historical 014 scene: native slider min −2.904, max/value 80; +lowering to 20 visually clips the upper cloud; End restores 80. Actual status lamp computed +animation is nodedc-status-pulse, red RGB 255/116/116, badge background remains transparent. +The temporary browser tab was closed; no scanner recording was started. + +Ops structured-layout writes to MISSIONCOR-79/81 were denied by automatic safety review. +No external card update was applied. Separate approval was requested for a short, non-sensitive +comment instead; local acceptance does not depend on an Ops write. + +## Still unqualified + +This is LAB evidence, not a navigation-safety or independent-accuracy certificate. +The next physical test is a known-start 100-m passage followed by deliberate loss/recovery +while stationary. Arbitrary middle-of-route starts, 10-m displaced starts, kilometre routes, +independent pose error and deployment on the rover computer remain separate acceptance work. +No lowered overlap/RMSE acceptance thresholds and no vehicle-command authority were introduced. diff --git a/docs/audits/2026-09-20-mid-route-016-retrieval-diagnosis.md b/docs/audits/2026-09-20-mid-route-016-retrieval-diagnosis.md new file mode 100644 index 0000000..be88ece --- /dev/null +++ b/docs/audits/2026-09-20-mid-route-016-retrieval-diagnosis.md @@ -0,0 +1,126 @@ +# Two mid-route starts: retrieval failure, not absence of matching geometry + +Date: 2026-09-20. Scope: inspect physical attempt 016 and fix the explicitly +requested vertical-scale outline. No production localisation logic, admission +threshold, device command, raw recording or historical result was changed. + +## Evidence and integrity + +The operator reports first starting approximately 30 m along the reference, +then moving closer, approximately 20 m, and pressing reinitialisation. The +saved run contains two stationary initialisations and one fresh validation. +All 18 artifact hashes listed by its report match the files on disk, including +both frozen initialisation inputs and the fresh registration input. The report +records no receipt gaps. Prefix maximum motion was 5.0 mm and 5.4 mm: motion +during the stationary prefix does not explain the failure. + +This is one recording with two positions, not two independent captures. The +operator's distances are approximate, not surveyed ground truth. + +## What actually ran + +Both initialisations completed the 108-fit dense start stage and then the +18-fit route fallback. There was no worker timeout. Fallback wall time was +approximately 3.1 s after approximately 15 s for the start stage. + +The fallback descriptor evaluated all 18 anchors on the selected 81.06 m +reference. `rank_route_candidates` then retained only the first six; exact +registration evaluated three yaw seeds for each. It did not proceed to the +remaining descriptor candidates after rejecting that shortlist. + +| Attempt | Anchors admitted to exact registration, in descriptor order | Live outcome | +| --- | --- | --- | +| First | 0, 5, 10, 81.06, 80, 75 m | No admitted route location | +| Reinitialised | 0, 5, 81.06, 80, 75, 10 m | Provisional hypothesis near the route end; rejected by fresh validation | + +The second provisional transform placed the scanner nearest route progress +79.76 m, with 74.16% point overlap and 0.2475 m inlier RMSE. Its fresh check +reported 77.14% overlap and 0.2407 m RMSE, but failed numerical convergence +(`converged=false`). The hypothesis was never confirmed as tracking. It is +inconsistent with the operator's reported location; high overlap alone cannot +justify admitting it. Lowering the gate or ignoring convergence would therefore +not be an appropriate remedy. + +## Bounded offline counterfactual + +Read each frozen `route-relocalization-input.npz` and call `relocalize_route` +with the production policy, changing only `candidate_count` from 6 to 18 so +every existing anchor reaches the same three-yaw exact fit. Retain the same +clouds, 30 s deadline, 40 iterations and all geometric thresholds. No live +publication or historical result rewrite occurs. + +| Input | Selected anchor | Estimated scanner progress along reference | Point overlap | Inlier RMSE | Converged | +| --- | --- | --- | --- | --- | --- | +| First prefix | 30 m | 30.10 m | 96.95% | 0.1472 m | Yes | +| Reinitialised prefix | 20 m | 24.47 m | 98.41% | 0.1547 m | Yes | + +Both searches completed and returned a candidate without relaxing acceptance. +The first 30 m anchor ranked fifteenth; the second 20 m anchor ranked +fourteenth. The second 25 m anchor ranked thirteenth and independently fitted +the same location with 98.41% overlap. Distances from fitted scanner position to +the nearest reference path sample are approximately 1.03 m and 2.03 m. + +Overlap is the measured fraction of evaluated points within 0.5 m, not a +probability of correct location, centimetric accuracy or a vehicle-safety +qualification. Inlier RMSE is cloud residual, not independently measured pose +error. The counterfactual does not replay fresh confirmation or prove a live +recovery. It isolates a concrete retrieval false negative. + +## Code-level cause and next decision + +`route_relocalization.py` ranks radial-height histograms around cloud medians: +a 20 m live footprint is compared to 28 m reference contexts. These differently +observed and populated clouds can rank the true location poorly. The rankings +above prove that defect on this capture; they do not isolate which descriptor +feature is responsible. + +The implementation is only a truncated cascade: it discards candidates after +the sixth, labels completion relative to those attempted fits, and offers only +one provisional transform. `StationaryBootstrap.accept_fresh` ends initial +confirmation on rejection; it does not resume the ranked candidate queue. +Automatic recovery after established tracking does not repair cold-start +shortlist exhaustion. + +Recommended implementation, not applied in this turn: + +1. Preserve the established dense start path and local tracking. +2. Use ranking as work order, not as a permanent exclusion of other areas. + Process spatially diverse candidate batches and continue the queue after + rejection, under an explicit compute/freshness budget. +3. When the budget is exhausted with unvisited candidates, report incomplete + search, not evidence that the route cannot be matched. +4. Improve descriptor observation comparability and verify distinct competing + locations before promotion. Continue to another viable candidate when fresh + confirmation rejects a provisional one; preserve source-age and identity + fences and never reuse stale confirmation data. +5. Qualify against both frozen prefixes, wrong-location/ambiguity controls and + the known successful start/tracking recordings before requesting another + field test. Increasing a constant to 18 is a diagnostic, not the scalable + product design for a kilometre route. + +## Outline correction + +The shared Design Guideline explicitly drew a 2 px outline on the vertical +range wrapper when its native input matched `:focus-visible`. This state can +survive pointer interaction. The vertical variant now has no border, outline +or shadow in any state. Focus-visible underlines both existing contrast-aware +central text layers. Native arrow/Home/End behaviour, left-side endpoints and +the 80 m domain range remain unchanged. The shared registry, documentation, +catalog specimen and contract test changed together; no app-local CSS override +or localisation change was introduced. + +## Validation and handoff + +- Shared range contract: 5/5; Design Guideline registry validation and core / + catalog typechecks passed. +- Mission Core architecture contract: 4/4; full frontend typecheck passed; + full frontend unit suite: 872/872; production build passed. Existing bundle + size warning remains; no new server or scanner session was started. +- Browser QA on the saved 016 scene in normal and expanded layout: slider + min -3.023, max 80. Pointer click changed value to 38.48 with real input focus; + native and wrapper outlines were 0 px, border 0 px, shadow none. Keyboard + focus underlined both contrast layers; Up changed the value, End restored 80. +- Canonical service on port 8000 served the new production CSS/JS and remained + healthy. No temporary numerical workers or build watchers remain. One + background saved-evidence view is retained for owner review, at the full + 80 m clipping range. Device state is idle. diff --git a/docs/audits/2026-09-20-physical-midroute-017-018-results.md b/docs/audits/2026-09-20-physical-midroute-017-018-results.md new file mode 100644 index 0000000..08577ff --- /dev/null +++ b/docs/audits/2026-09-20-physical-midroute-017-018-results.md @@ -0,0 +1,167 @@ +# Physical mid-route starts 017/018 — result review + +## Scope and verdict + +Read-only review of the two latest physical experiments requested by the owner. +No registration, threshold, UI or device-control code was changed. No scanner +commands, replay session, service restart or Ops mutation was performed. + +Both cold mid-route starts and subsequent geometric tracking succeeded while +the scanner supplied spatial data. Run 017 moved forward; independently started +run 018 travelled back towards the reference origin. There were no rejected +fresh fits or inconsistent-transform decisions during either traversal. + +Two qualifications matter: the reported distances differ from the operator's +rough walking estimates, and both reports contain a terminal `stale` transition +**after commanded scanner stop**, not an in-motion recognition failure. +Absolute localisation accuracy is not established by these recordings. + +## Evidence and integrity + +Canonical private data root: `../NODEDC_MISSION_CORE/.runtime/mission-core`. + +| Item | 017 | 018 | +|---|---|---| +| Name | `ja-sun-017-30m-offset` | `ja-sun-018-80m-offset-back` | +| Live-test ID | `2de294bb-a18e-4dfd-a9c4-51cae38fa54d` | `f61c7041-c1bb-4434-97e5-413bbc3e0695` | +| Raw session | `20260920T182601Z_viewer_live` | `20260920T183041Z_viewer_live` | +| Selected reference length | 112.248 m | 120.073 m | +| Frozen result artifacts verified | 84/84 | 114/114 | +| Raw pose payload hashes verified | 1126/1126 | 1439/1439 | + +Both use reference session `20260911T085226Z_viewer_live`, generation +`8527e0de1995c93c835ee7223488ff851442792551e217d4ab43457974f798a1`, +route-relocalisation v6 and stationary fresh bootstrap v3. Current source hashes +match the previously qualified implementation for route retrieval, bootstrap +and live orchestration. Reviewed each step's `source.json`, numerical result +and `decision.json`, the raw receipt indexes, STOP timing diagnostics, temporal +gate and metric definitions in the actual code. + +All fresh windows were disjoint in source sequence, and every admitted receipt +was newer than its fresh fence and no later than calculation submission. Both +starts rejected the dense origin hypothesis and searched the selected route; +018 did not inherit the final transform of 017 as an accepted localisation. + +## Recognition and tracking + +| Metric | 017 forward | 018 backward | +|---|---:|---:| +| Computed reference progress at start | 38.37 m | 84.87 m | +| Computed progress at last accepted fit | 84.89 m | 0.22 m | +| Recorded travelled distance | 47.442 m | 85.023 m | +| Cold confirmation from first usable prefix | 51.614 s | 49.065 s | +| Dense-start fits | 108, rejected | 108, rejected | +| Whole-route regions / precise fits | 24 / 72 | 26 / 78 | +| Search completed / remaining regions | yes / 0 | yes / 0 | +| Selected coarse anchor | 40 m | 85 m | +| Initial overlap / inlier RMSE | 99.538% / 0.1509 m | 99.259% / 0.1553 m | +| Runner-up distinct overlap | 79.994% | 81.514% | +| Fresh accepted fits, including first three | 15/15 | 21/21 | +| Fresh overlap min / median / max | 98.961 / 99.325 / 99.577% | 95.482 / 98.163 / 99.516% | +| Inlier RMSE min / median / max | 0.1460 / 0.1488 / 0.1518 m | 0.1486 / 0.1567 / 0.1755 m | +| Largest inter-update position correction | 0.0401 m | 0.0441 m | +| Largest inter-update rotation correction | 0.2563° | 0.5650° | +| Maximum accepted input age at completion | 0.954 s | 1.499 s | +| Worker wall time median / max | 0.314 / 0.395 s | 0.304 / 0.407 s | + +Progress was obtained by projecting transformed scanner positions onto the +selected reference polyline. It is an estimate produced by the registration, +not surveyed ground truth. Its forward/backward progression matches the stated +experiments: 017 from about 38 to 85 m; 018 from about 85 m to the origin. +The travelled distance is the existing live-buffer accumulated SLAM distance, +not straight-line endpoint separation or an independently measured tape length. + +The complete route searches took 28.333 s (017) and 25.325 s (018) across the +dense and route stages. Confirmation durations include the stationary prefix +and three new-data checks, but exclude device preparation before usable clouds. +All numerical fits converged. Shape and information metrics remained above +their admission thresholds; no failed fit was disguised as an accepted one. + +Using the last raw pose and last accepted transform of 017, then the first raw +pose and third fresh (confirmed) transform of 018, their shared physical-place +coordinates differ by approximately **0.0173 m**. This is an encouraging +cross-session repeatability observation, conditional on the owner's statement +that the scanner restarted at the same physical location. It is not an +absolute 1.7 cm accuracy certificate: both estimates use the same reference, +and exact physical endpoint placement was not independently measured. + +At the current scanner location, the maximum difference between the initial +transform and later accepted transforms was 0.119 m in 017 and 0.124 m in 018. +These are registration corrections relative to the initial hypothesis, not +position errors against ground truth. Total orientation-transform changes were +0.357° and 1.135° respectively; the largest single update is listed separately. + +## End-of-run `stale`: lifecycle finding + +UTC chronology from raw receipt indexes and `scanner-diagnostics.jsonl`: + +| Event | 017 | 018 | +|---|---|---| +| STOP handling began | 18:28:20.335 | 18:33:28.029 | +| Last spatial cloud received | 18:28:20.710 | 18:33:30.337 | +| Last accepted fit completed | 18:28:21.033 | 18:33:27.795 | +| Temporal gate became stale | 18:28:28.698 | 18:33:35.441 | +| Capture/planning input finished | 18:29:12.068 | 18:34:22.051 | + +STOP was confirmed by the device. Heartbeats and device-status receipts +continued after spatial output ceased. The capture remained active for roughly +another 51 s while shutdown completed. The numerical gate correctly refused +to retain live localisation after its eight-second sample-age fence; the live +orchestrator consequently began recovery (`recovery_attempt=1`) despite the +scanner already being deliberately stopped. No new recovery fit followed. + +This is a real lifecycle/reporting defect: commanded stopping should not be +reported as unexpected loss requiring relocalisation. The correct follow-up is +to distinguish capture finalisation from active spatial acquisition, while +still clearing live authority. It is **not** a reason to weaken the freshness +fence or mark stale transforms as live. The present review does not implement +that follow-up. + +Code anchors: `causal_tracking.py:32` expires accepted evidence; +`stationary_live.py:349` ticks the gate and starts recovery before the source +has ended. The source's active state remains true during the prolonged stop +finalisation, so simply reordering the end-of-input check is insufficient. + +## Input delivery finding + +Raw spatial receipts averaged approximately 10 Hz in both experiments, with +maximum receipt gaps of about 1.11 s and 1.01 s. Recorded planning continuity +segments contained no gap beyond the two-second boundary and no detected pose +discontinuity. Burst delivery means that speed calculated from consecutive +host receipt intervals is not a valid physical velocity estimate. + +The private derived-data queue did discard some packets on overflow: + +- 017: 9/1125 lidar frames (0.80%), no pose frames. +- 018: 34/1439 lidar frames (2.36%), 1/1439 pose frames (0.07%). + +These are deltas of cumulative ingress counters, not raw-recorder losses. +Corresponding packets remain in the raw recordings. The discarded derived +packets did not produce a failed geometric/temporal check in these runs, but +delivery cannot honestly be described as lossless. Queue/scheduling headroom +remains a follow-up concern before greater speed, load or route length. + +## Accuracy interpretation and conclusion + +In `registration.py:140`, overlap is the fraction of sampled query points whose +nearest reference point lies within 0.5 m. RMSE is computed only over that +inlier set, after the existing 0.25 m voxel preparation. Thus 99% is not a +99% probability of correct localisation, and 0.15 m RMSE is neither a measured +scanner position error nor its guaranteed bound. Small corrections between +updates indicate consistency, not independently measured absolute accuracy. + +**Passed for this recorded location:** cold starts away from the route origin, +distinct-region selection, three fresh confirmation checks, forward tracking, +independent cold restart near 85 m, and backward tracking to the origin. +These are physical v6 results, not only archive simulations. + +**Not established:** centimetre-level absolute accuracy, reliability across +many independent starts, arbitrary off-route starts, kilometre-scale maps, +different locations/conditions, or safe autonomous driving margins. A physical +accuracy claim requires independently measured checkpoints and comparison of +reported scanner positions with those checkpoints. + +Recommended decision: retain the geometric cascade and thresholds. Address +commanded-stop semantics and inspect derived-queue headroom separately; use +measured checkpoints for the next accuracy assessment. Do not interpret a +larger walking distance alone as a measurement of absolute accuracy. diff --git a/docs/audits/2026-09-20-physical-repeat-007-100m-results.md b/docs/audits/2026-09-20-physical-repeat-007-100m-results.md new file mode 100644 index 0000000..4c067a4 --- /dev/null +++ b/docs/audits/2026-09-20-physical-repeat-007-100m-results.md @@ -0,0 +1,142 @@ +# Physical repeat `ja-sun-007-100m` — read-only acceptance review + +Reviewed on 2026-09-20 after the operator reported a successful approximately +100 m walk. This is a retained-data inspection, not a replay, new capture, +device operation, algorithm change, or navigation acceptance. No Ops mutations. + +## Identity and integrity + +- Live run: `ac07a83a-f784-4d31-a908-d1fd526921b4`. +- Query: `20260920T054444Z_viewer_live`, generation 1, display name + `ja-sun-007-100m`; do not confuse it with the earlier unsuccessful `ja-sun-007`. +- Reference: `JA-SADOVAYA-001`, `20260911T085226Z_viewer_live`. +- Draft: `672c1d9f-b817-4b78-b84c-b34a4edc0870`, revision 1. +- Selected route: indices 0–1320, length 102.59360280377184 m; context map + indices 0–1516, `route-context-map/v2`. +- All 133 declared run artifact hashes verified against `report.json`. +- Raw capture SHA-256: + `1c8161b2d2e22ecb98ccd1bbe04f1dc348b8d79b1cab1a2cf94dcecf58ea929c`. +- Metadata SHA-256: + `8c8caa3e293fa459e423c815a08c84ced20433cefb55eb81d018e245dd1c5783`. +- Raw and metadata hashes verified, all 3,858 message payload hashes verified; + full streaming decoding found zero pose/cloud decode errors. +- Empty recovery journal and clock origin verified. The summary declares the + **sealed session clock** + `mqtt.timeline.session-de7e1779fa341e2e81e0943d8523f09fc1003a0528b2f0342fa4ec86334d445b.json`; + its digest matches. The mutable transport `mqtt.timeline.json` is a different + envelope and must not be compared to the session-clock digest. +- Query raw data contains 1,625 poses, 1,624 clouds and 5,573,004 cloud points. + Capture summary records no rejected messages, recoveries, or transport error. + +Evidence root is the canonical `NODEDC_MISSION_CORE/.runtime/mission-core`: +run artifacts under `missions/live-tests//`, raw source under +`evidence/sessions//`. Private raw evidence remains outside Git. + +## Timeline and registration + +Times below are UTC; local operator time is UTC+3. Durations were calculated +from retained monotonic timestamps. + +| Event | UTC | +| --- | --- | +| First pose / first cloud | 05:45:11.013 / 05:45:11.024 | +| Stationary prefix complete; initial search starts | 05:45:21.048 | +| Provisional initial match complete | 05:45:36.390 | +| First fresh validation accepted | 05:45:38.881 | +| Third fresh validation; tracking established | 05:45:49.087 | +| Last fresh validation accepted | 05:47:41.109 | +| Selected route distance reached; tracking authority cleared | 05:47:43.034 | +| Run completed | 05:47:43.039 | +| Operator STOP correlated response | 05:47:53.360 | +| Last raw pose / cloud | 05:47:53.585 / 05:47:53.592 | + +Tracking established 38.076 s after the first pose. The initial search took +15.337 s wall time and only supplied a provisional prior; it is not counted as +an accepted fresh validation. All **25/25** fresh validations were accepted; +there was no rejection, reacquisition, receipt-gap segment change, or loss +between acquiring tracking and completing the selected distance. + +| Fresh-validation metric | Minimum | Median | Maximum | +| --- | ---: | ---: | ---: | +| Overlap | 96.658% | 98.497% | 99.638% | +| Inlier surface RMSE | 0.13956 m | 0.15095 m | 0.16728 m | +| Worker wall time | 0.26009 s | 0.35009 s | 0.47530 s | +| Latest input age at acceptance | 0.30952 s | 0.65603 s | 0.92065 s | +| Change at current query position between accepted transforms | 0.00710 m | 0.01281 m | 0.03758 m | +| Full rotation change between accepted transforms | 0.04392° | 0.13181° | 0.30600° | + +The transform yaw ranges from 4.0242° to 4.4371°, a span of 0.4129°. +This is the relationship between session frames, **not** an independently +measured heading error. Similarly, overlap and inlier RMSE describe agreement +of matched surfaces, not ground-truth rover position accuracy. + +Final fit overlap is 98.390%, RMSE 0.13956 m. Its sampled accumulated distance +is 98.463 m; the run continues using that fresh accepted alignment until +102.594 m, approximately two seconds later. Do not describe every metre of the +last interval as separately re-registered. + +Termination is `distance-limit`: the **operator-selected route length**, +not a restored hard-coded 100 m cap. `selected-live-route/v1` has no fixed +maximum route length or time ceiling. Terminal `tracking_state=lost` is the +intentional revocation at completion, not an in-motion localization loss. + +The raw recording continued for about ten seconds after the planning run: +scanner-reported distance at run end was 102.572 m, final scanner distance +104.463 m. Summing all raw positional increments gives 104.912 m, a different +metric from the planner's 5 cm filtered path accumulator. + +## Cloud delivery and retained visual evidence + +- Raw pose/cloud average cadence is approximately 10 Hz, including during + tracking. It is not uniformly spaced. +- During tracking, cloud gap p50/p95/max = 0.08162/0.24342/1.00042 s; + pose gap p50/p95/max = 0.08123/0.23606/1.65884 s. +- No raw pose/cloud gap exceeds 2 s. Across the full capture the longest cloud + gap is 1.06501 s. Maximum adjacent raw pose displacement is 0.12333 m; the + large timing gaps do not correspond to a coordinate teleport. +- At run completion the planning ingress reports lidar 1,521 published, + 1,492 consumed, 27 overflow drops and 2 queued; pose 1,522 published, + 1,519 consumed, 2 overflow drops and 1 queued. Raw recording is independently + retained; these counts must not be equated with raw file loss. +- Fast display accepted 1,484 cloud updates. Final rolling display has 20 chunks + and 39,238 points. It intentionally does not accumulate the entire route cloud. +- A read-only scan of the service access log found 1,703 requests for this run's + scene delta endpoint: 926 HTTP 200, 777 HTTP 204, no HTTP errors, one base + request. All observed requests kept reference/query/trajectory enabled in 3D. +- Request cursors include **802 distinct live cloud revisions**, 842 distinct + live pose sequences, and 25 distinct fit-evidence sequences. From the client + implementation, subsequent cursors follow a successful `send_rrd` call. + This supports repeated native-channel delivery, not merely production of + backend snapshots. Counts are aggregate access-log evidence, not per-display + frame timing or a GPU presentation acknowledgment. +- There is no retained measurement of actual browser paint FPS or end-to-end + visible latency. The operator's uncertain visual impression cannot be closed + as “smooth real time” from these counters alone. Green correspondence evidence + changes with the approximately five-second fit, separately from fast cloud. + +The camera archive summary is complete, with 1,534 segments and no failure +code. Camera media was not fully decoded or independently hash-verified in this +review, so no stronger video continuity/presentation claim is made. + +## Decision and remaining boundary + +Positive physical laboratory result: stationary acquisition and continuing +registration work across the selected approximately 100 m route. Quality did +not collapse with distance, and the earlier reported approximately 10° sudden +misalignment is not reproduced in the retained accepted transforms. + +Fast cloud delivery is physically evidenced beyond the previous offline +qualification, but subjective/paint-level visual acceptance remains open. +Ingress drops and occasional 1–1.66 s input pauses are real limitations to +measure, not grounds to call the entire acquisition smooth. + +This does not establish arbitrary-start relocalization, robustness across +mounting configurations, independent absolute localization accuracy, or an +acceptable navigation freshness/stopping budget. The laboratory still retains +`localization_confirmed=false`, `vehicle_control=false`; the 8 s alignment age +gate is not a vehicle safety criterion. A longer walk alone does not close those +questions. Next useful acceptance is repeated starts with known offsets and a +controlled observation of cloud/pose presentation and stale-input behavior. + +Only this audit was added. No implementation, runtime configuration, device, +recording, Ops card, or service lifecycle was changed during review. diff --git a/docs/audits/2026-09-20-planning-browser-presentation-telemetry.md b/docs/audits/2026-09-20-planning-browser-presentation-telemetry.md new file mode 100644 index 0000000..11e1161 --- /dev/null +++ b/docs/audits/2026-09-20-planning-browser-presentation-telemetry.md @@ -0,0 +1,85 @@ +# Planning browser-presentation telemetry — 2026-09-20 + +## Purpose + +Instrument the time from a fresh planning cloud package reaching the browser to +its admission by the existing native Rerun channel and two subsequent browser +animation-frame opportunities. The purpose is to investigate the operator's +observation that a new cloud appears substantially slower than an ordinary +session cloud, without changing registration, route following, device control, +or the visible LAB surface. + +## Exact boundary + +The browser reports one bounded, best-effort sample only after: + +1. `channel.send_rrd` has resolved for a live cloud package; +2. one browser animation frame has run (or timed out after 500 ms); and +3. a second browser animation frame has run (or timed out after 500 ms). + +The report stores metric summaries and at most 4,096 in-memory samples for the +selected run. Browser posts contain at most eight samples, have no retry queue, +and use an exact `run_id`, `display_epoch`, cloud revision, and cloud sequence +fence. A stale, terminal, or foreign packet is ignored. The final run report +receives only the summary. + +`source_to_second_animation_frame_upper_bound_ms` is deliberately conservative: +server-reported cloud age + browser request time + Rerun admission duration + +post-admission two-frame delay. + +This is not proof of a GPU canvas-paint receipt, physical scanner-to-pixel clock +synchronization, registration truth, route-following quality, navigation, or +safety authority. + +## Implementation + +- `apps/control-station/src/core/missions/planningSceneStream.ts` forwards the + exact live display identity and holds the native cadence until the bounded + delivery callback finishes. +- `apps/control-station/src/components/missions/PlanningLiveScene.tsx` records + the browser-side admission/two-frame proxy without adding controls, panels, + labels, or debug UI. +- `apps/control-station/src/core/missions/planningPresentationTelemetry.ts` + batches bounded best-effort browser reports. +- `src/k1link/web/planning_live_api.py`, + `src/k1link/missions/live_tests.py`, and + `src/k1link/missions/planning_browser_presentation.py` validate, fence, and + summarize those reports. + +## Existing physical context + +The operator deliberately starts the scanner about 1–3 m away from the prior +start and walks an offset trajectory. In the registered reference frame, the +two accepted repeat runs do not collapse into one exact line: + +| Run | Measured path | nearest-route median | nearest-route p95 | +| --- | ---: | ---: | ---: | +| `ja-sun-006` | 35.30 m | 1.12 m | 3.11 m | +| `ja-sun-007-100m` | 102.59 m | 0.95 m | 1.23 m | + +The transformed path origins are about 0.79 m and 0.65 m from the reference +route start, and about 0.51 m apart in XY. Those figures support that the +recorded passes are not identical traces, but they cannot independently survey +the operator's physical 1–3 m offset because registration maps each query into +the reference frame. + +## Verification and deployment + +- `pytest -q tests/test_planning_fast_display.py` — 4 passed. +- Focused frontend streaming/reporter tests — 8 passed. +- `npm run typecheck` — passed. +- `npm run test:unit` — 871 passed. +- `npm run build` — passed (only the established chunk-size warning). +- Planning Python suite rerun — 13 passed. One earlier stationary-start timing + assertion was repeated successfully; it does not cover this reporter path. +- Canonical LaunchAgent reloaded only after the selected run was terminal. + Health is `ok`, exactly one listener is bound to `127.0.0.1:8000`, no listener + is bound to 8765, and the new observation endpoint is mounted. + +## Next physical check + +Run a fresh planning pass using the normal deliberate offset; no special +placement, UI mode, or debug control is needed. A 30–40 m repeat is enough to +validate recording and inspect the new report. Keep the Planning scene open; +after the run finishes, review sample count, request/admission/frame percentiles +and frame-timeout count alongside the already existing registration evidence. diff --git a/docs/audits/2026-09-20-planning-live-presentation-head-and-height-slice.md b/docs/audits/2026-09-20-planning-live-presentation-head-and-height-slice.md new file mode 100644 index 0000000..d7e407a --- /dev/null +++ b/docs/audits/2026-09-20-planning-live-presentation-head-and-height-slice.md @@ -0,0 +1,34 @@ +# Planning live: temporal head and vertical slice — 2026-09-20 + +## Scope + +This review concerns the completed planning run `c7918881-c817-4d39-aa16-a1d2fc135d5b` (`ja-sun-008-100m`). The scanner was not started, stopped, or otherwise controlled while making this change. The run was terminal before every local reload. + +## Observation + +The 100.93 m run produced 1,576 LiDAR events; the planning consumer received 1,566, with eight overflow drops. Its bounded display tap received 1,563 frames. Browser presentation telemetry accepted 920 samples with no frame-timeout reports. The source-to-second-animation-frame proxy was 134.8 ms p50 and 368.6 ms p95 (maximum 1,297.7 ms). + +This rules out a two-to-five-second upstream scanner or ingress pause for that run. It does not claim a GPU-paint measurement: the browser metric is explicitly a bounded request/admission/animation-frame proxy. + +The visible stepping came from the presentation representation: `LiveDisplayBuffer` aggregated each half-second into a static Rerun chunk. Its 100 ms poll did not make that static replacement look like a continuously arriving cloud. + +## Change boundary + +`LiveDisplayBuffer` now retains the bounded, frozen half-second history while keeping a separate current point head for every accepted scanner frame. `live_scene_delta` logs that head on the scanner source-sequence timeline. The history remains capped; no numeric registration input, fit decision, vehicle command, scanner command, or authority boundary was changed. + +The active Planning Scene now reuses the canonical vertical `RangeControl`. It receives the exact finite Z bounds of the immutable reference cloud from the selected run status and continues to reconcile them from the scene response headers. Moving the control sends `ceiling_m` through the existing scene stream; reference, registered query, and green evidence are clipped in the reference frame. The route remains visible as context. + +The saved-project viewer is a separate static registration RRD. It is not evidence that the active Planning Scene has been visually exercised, and no duplicate slider was added there. + +## Validation + +- Backend planning tests: 13 passed (`tests/test_planning_live.py`, `tests/test_planning_fast_display.py`). +- Focused browser-stream tests: 9 passed. +- Frontend typecheck passed. +- Frontend unit suite: 872 passed, 0 failed. +- Production build passed. +- After controlled local reload, the service health check passed. The selected terminal run reports height bounds `-0.741 m` to `7.401 m`; the delta endpoint returns the same bounds. + +## Physical acceptance still required + +Start a fresh supervised planning run only through the ordinary operator flow. During the first continuous walk, confirm that the cloud has a moving live head rather than half-second static jumps. Move the vertical slice and verify that reference, new cloud, and green evidence cut at the same elevation. Afterwards retain the run identity and compare source frames, accepted browser samples, and p95 proxy with this baseline. This confirms visual behavior; it remains separate from any future rover-control authority decision. diff --git a/docs/audits/2026-09-20-planning-project-deletion.md b/docs/audits/2026-09-20-planning-project-deletion.md new file mode 100644 index 0000000..5a3d874 --- /dev/null +++ b/docs/audits/2026-09-20-planning-project-deletion.md @@ -0,0 +1,52 @@ +# Planning project catalog removal + +Owner request: trash actions in the “Совмещённые маршруты” selector, with +confirmation matching the recorded-session workflow. No actual operator record +was selected for deletion during implementation or browser QA. + +## Boundary + +- Removal is catalog-only, persisted by exact `kind:UUID` in + `registration-runs/catalog-deletions.sqlite3`. This is not disk reclamation. +- Reports, source captures, reference/query clouds, frozen draft snapshots and + other experiments stay intact. Tombstones do not rewrite evidence. +- Deleted runs still participate in draft ownership when projecting the list: + deleting the last result cannot resurrect its draft as a new catalog entry. +- Only completed/failed live runs, ready/failed recorded runs and unstarted + drafts are removable. Unknown runtime states fail closed. Draft revisions are + checked and drafts already associated with an experiment are refused. +- Repeating an acknowledged deletion is idempotent. Catalog and project detail + exclude removed entries after process restart. Engineering evidence endpoints + retain historical access; no source-store or scanner command is invoked. + +## Interface + +The Design Guideline `SelectOption.action` supplies an independent canonical +IconButton beside the selection button. Action-bearing menus use dialog +semantics; ordinary selectors retain listbox semantics. Activating a row action +closes the menu without selecting that row. Focus returns through the surviving +selector trigger when confirmation closes. + +Owner visual correction: row actions match Data → Sessions, with a transparent +2.35rem hit column, muted 15px trash glyph, no circular backing and danger color +on hover. Shared Select owns this geometry; Mission Core has no local override. + +Mission Core owns exact project identity, terminal-state admission, +ConfirmationModal copy, API request and ToastStack errors. The confirmation +includes the timestamp to distinguish identically named runs. Pending submission +is protected against repetition. Successful removal clears only that selected +project; late list/detail responses cannot reinsert a removed item. + +## Validation + +- 20 backend catalog tests: exact identity, same-name siblings, source/report + preservation, terminal/unknown states, revision mismatch, idempotence, + restart persistence, empty catalog, invalid requests and draft ownership. +- 869 Control Station tests; architecture checks, typecheck and production build. +- Design Guideline registry, workspace typechecks, six Select contract tests + and production catalog build. +- Browser QA uses existing evidence and cancels confirmation; it does not delete + a real project or create a synthetic project in the operator catalog. + +The canonical integrated service remains on port 8000. No alternate backend, +device acquisition, registration run or live replay publisher is introduced. diff --git a/docs/audits/2026-09-20-planning-scene-camera-and-tools.md b/docs/audits/2026-09-20-planning-scene-camera-and-tools.md new file mode 100644 index 0000000..eaec346 --- /dev/null +++ b/docs/audits/2026-09-20-planning-scene-camera-and-tools.md @@ -0,0 +1,80 @@ +# Spatial scene: camera ownership and modeless tools + +Follow-up, 2026-09-21: the owner reported native hover/picking of `world/grid`. +The data-geometry grid introduced here is selectable; this is an open +presentation regression, not covered by the acceptance below. The complete +planning upgrade inventory and pending grid check are recorded in +`2026-09-21-rerun-planning-customizations.md`. No fix is claimed in this report. + +## Scope + +Presentation-only change for the planning profile in the shared spatial scene. +No registration, tracking thresholds, recording, source data or device commands +were changed. Existing unrelated working-tree changes were retained. + +## Reproduced cause + +`PlanningLiveScene` already kept its native viewer/channel mounted. The reset +was server-side: any changed presentation option requested a full geometry +refresh, and `log_base` sent a new blueprint with a generated view and initial +`EyeControls3D`. Clipping, layers, point size and grid therefore replaced the +operator's native camera. Discarded responses and transport failures also +discarded the cursor, conflating a geometry repair with first admission. + +## Implementation + +- Separate `log_base` (data entities) from `log_view` (camera blueprint). +- Cursor carries `[mode, reset]` as the admitted camera intent. Only first + admission or a changed explicit view/reset intent sends a camera blueprint. +- Preserve the last admitted cursor on errors and superseded responses; + `needsBase` independently requests a complete geometry repair. Expired live + evidence is still rejected. No stale response gains localization authority. +- Send reset generation through the HTTP API instead of dropping it in the + client. Explicit reset and top/3D presets remain functional. +- Grid visibility is now an ordinary Rerun `LineStrips3D` display entity, so it + needs no blueprint activation or approximate camera-input journal. It is a + reference-bound XY guide with 80 m padding; guide spacing adapts to large + reference extents. This is display geometry, not inferred ground, clearance + or a source-distance limit. Native grid is disabled in the initial blueprint. +- Remove duplicate planning navigation and engine action from the spatial + toolbar. Preserve the optional source action for separate existing consumers. +- Use the canonical `WorkspaceWindow`, like planner settings, for layers and + display controls. It is inline, draggable/resizable, modeless, bounded by the + scene and supports maximize/restore and Escape. Switching tools retains the + open window's position. Its lifecycle is independent from the renderer. +- Height clipping still defaults to the full 80 m range and has no outline. + +## Acceptance + +- Application architecture tests: 4 passed. +- Full frontend typecheck: passed. +- Full frontend suite, serialized: 875 passed, 0 failures. +- Production build: passed. Existing large-chunk warning remains. +- Focused backend display/API tests: 8 passed. New tests cover all presentation + toggles, geometry repair, display epoch changes and explicit camera intents. +- Frontend stream tests cover stale responses and recovery retaining the last + admitted camera cursor; reset intent reaches the server. +- Unit tests verify modeless bounded tools and removal of duplicate actions. +- Full-suite output contained a Vite dependency-scanner teardown warning; + all test assertions and process exit passed. No watcher was retained. + +## Browser QA on canonical 8000 + +Used the existing in-app tab and real saved `ja-sun-018-80m-offset-back` scene. +No synthetic viewer, new application server, scan or device action was used. + +Rotated and zoomed the cloud away from its preset, dragged the tools window, +changed height from 80 to 3 m, hid trajectories, switched tools, increased +point size and hid the grid. The cloud changed without returning the native +camera to its preset. Tested top/3D and explicit reset separately. + +Verified cloud expansion, clipping while expanded, Escape restoration, ordinary +and expanded application-panel layouts, tool maximize/restore and close/Escape. +DOM measurement of maximized tool and its scene matched exactly (504 × 376.03125 +CSS px); `aria-modal=false`. Browser checking verifies visible behavior, not a +numerical measurement of native camera coordinates. + +Returned the scene to 3D, 80 m clip, visible grid/trajectories and closed tools. +The canonical service was updated through its hash-fenced LaunchAgent workflow +after proving acquisition absent and control idle. Health accepted; one backend +on 8000 remains running. K1 live-stream behavior was not physically retested. diff --git a/docs/audits/2026-09-20-planning-stop-and-delivery.md b/docs/audits/2026-09-20-planning-stop-and-delivery.md new file mode 100644 index 0000000..f3f2b1d --- /dev/null +++ b/docs/audits/2026-09-20-planning-stop-and-delivery.md @@ -0,0 +1,162 @@ +# Planning STOP lifecycle and burst delivery + +## Scope and decision + +Owner-authorized follow-up to the physical 017/018 review. Preserve the v6 +dense-start / route-search cascade and all geometric, causal, identity and +freshness gates. This increment addresses intentional STOP and bounded derived +delivery, not K1 SLAM, vegetation robustness, vehicle control or a new retrieval +algorithm. Original captures and comparison reports remain immutable. + +## Commanded stop is not unexpected loss + +Both physical runs stopped supplying clouds after operator STOP but retained +their raw-recording owners for approximately 51 seconds. The eight-second +localisation freshness check correctly expired; the planning owner incorrectly +treated intentional cessation as a reason to recover localisation. + +The K1 lifecycle facade now signals `spatial-stop-requested` after its existing +canonical `request_stop` has admitted the command. It supplies the exact +evidence session and ingress generation. This is an admitted **intent**, not +proof of physical STOP success or READY. A synchronous admission rejection +does not emit it; an asynchronous device failure remains owned and reported by +the existing physical-command lifecycle. Localisation does not automatically +resume after a failed STOP or replay a command. + +The ingress latches that signal independently of queue consumption. It cannot +stop another generation, survives a slow consumer and resets on a new session. +It neither closes raw capture nor rejects its subsequent publications. The +planning owner checks it before freshness/recovery work, clears live authority +and completes the study with `termination_reason=spatial-stop-requested`. +An already-running fit retains its compute lease until cleanup; its late output +is recorded as unaccepted evidence and cannot publish a new candidate/green +state or overwrite the last accepted historical alignment. End-of-input also +no longer publishes an intermediate synthetic `lost` phase. + +The existing UI already renders completed studies as the neutral +“Исследование завершено”. No frontend, visual components, clipping range or +operator-copy changes are part of this increment. Real stale data, rejected +geometry, receipt gaps and pose jumps still revoke localisation and initiate +the existing recovery flow. + +## Delivery findings and changes + +Physical evidence from the preceding audit: 017 discarded 9/1125 derived lidar +receipts, 018 discarded 34/1439 lidar and 1/1439 pose receipts. These are queue +overflows, **not missing raw recording packets**. Both physical walks passed +all fresh fits despite those losses. + +The retained receipt indexes show bursty delivery: + +| Recorded burst | 017 | 018 | +|---|---:|---:| +| Maximum lidar receipts in 100 ms | 12 | 12 | +| Maximum lidar receipts in 200 ms | 14 | 17 | +| Maximum lidar receipts in 500 ms | 17 | 19 | +| Previous lidar queue capacity | 8 | 8 | + +Both spatial modality queues now retain 32 receipts (previously lidar 8, +pose 16). This is bounded burst/scheduling headroom, not a route-length or +geometric acceptance limit. The existing 2 MiB packet bound makes the worst +case 64 MiB per spatial queue. Overflow counters, source order, original +receipt timestamps and all freshness limits remain unchanged. No unbounded +queue, new decoder thread, synthetic timestamp or interpolation was added. + +`PlanningLiveTests` also caches height bounds for each immutable reference +array. Summary and scene polling previously rescanned/copied the route-wide +point array while holding the planning lock. Replacing the reference invalidates +the cache; finite-point handling and the existing 80 m ceiling are unchanged. +This removes unnecessary work but does not prove that UI polling was the sole +cause of the physical packet losses. + +## Validation and evidence boundaries + +Focused suites r2/r3 plus acquisition-control regressions covered 170 distinct +tests: exact-generation stop, rejected admission, active raw capture after +stop, stop before first cloud, blocked initial/fresh workers, late worker error, +51-second virtual finalisation, real loss/recovery, queue overflow/order, +fresh-window separation, registration cascade and cached scene bounds. +Evidence: `.runtime/qualification-20260920-stop-tests-r2.xml`, +`qualification-20260920-stop-tests-r3.xml`, and +`qualification-20260920-stop-control-tests.xml`. + +An exploratory cProfile replay is retained at +`.runtime/qualification-20260920-stop-delivery-018-baseline`. It is **not an +acceptance pass or a timing baseline**: instrumentation caused the route search +to exceed its budget, expected-tracking/complete-search failed, and a test-file +edit during that diagnostic run invalidated its code-unchanged check. Original +input hashes remained unchanged. No result was promoted into the planner. + +The unprofiled final replay is independently sealed under +`.runtime/qualification-20260920-stop-delivery-018-final`. It uses raw 018 +receipts at their original 1x intervals, the real ingress, decoder, planner, +numerical child and 10 Hz in-process scene-delta endpoint. An explicitly +declared archived last-cloud boundary models admitted STOP while the private +capture source remains active; this is not a replay of the K1 command protocol. +The separate fake-device tests cover the actual facade admission edge. No +socket, scanner command, production comparison mutation or extra app server is +used by the archive probe. + +Final unprofiled result: **13/13 acceptance checks passed**, all input/code +hashes unchanged, full route search and tracking at 52.40 s, 20/20 disjoint +fresh fits accepted, 85.023 m accumulated travel. Maximum fresh-fit worker +wall time was 0.524 s; no recovery was initiated. STOP ended the study while +capture ownership remained active, with no green after completion and all +temporary leases released. The probe exercised 1,267 scene-delta requests. + +Each spatial queue published 1,439 receipts, consumed 1,436, reached a maximum +depth of 11/32, and recorded **zero overflow or oversize rejections**. Three +receipts per modality remained queued at intentional STOP; they were not +reported as processed or lost from raw capture. Maximum publisher scheduling +lag was 0.049 s. No camera/video producer or second browser was included, so +this is not proof of zero overflow under all field workloads. A new physical +pass must confirm the integrated host/device/browser workload. + +Final focused lifecycle/ingress rerun is retained separately in +`.runtime/qualification-20260920-stop-tests-r4-final.xml`. Ruff passed for all +changed production modules and qualification helpers; existing Starlette/httpx +deprecation warnings were not treated as new product failures. + +## Local runtime handoff + +After the final 37-test lifecycle/ingress rerun passed, the canonical LaunchAgent +was reloaded through `manage_mission_core_launch_agent.py plan/apply`, using +the unchanged current/desired declaration SHA +`5d7a58122c016f8021fede27d1b21891ee20238fc38c281e5b33574ff8204e3f`. +Its normal backup and health-acceptance path succeeded. Post-reload `/api/health` +reported operational readiness; `/api/state` reported idle acquisition, inactive +ingress, the new 32/32 capacities and `spatial_stop_requested=false`. The original +018 comparison remained selected/completed/historical with an 80 m ceiling. +Only the canonical backend listened on 8000 (PID 4146); no backend on 8765, +temporary replay, numerical child, pytest process or Docker backend remained. +Memory-pressure free percentage was 49%; swap did not grow during final +qualification/reload. No K1 command or Ops write was issued by this task. + +## Kilometre-scale next stage + +The selected-route policy has no fixed maximum walking distance or duration. +Tracking fits a local reference region and recovery starts at the last confirmed +place. That supports testing a longer taught route from its known start; it is +not evidence that the current system is qualified at kilometre scale. + +Cold route-wide retrieval still checks eligible regions at 5 m spacing, with +three precise yaw seeds per region and a 35-second combined search budget. +A 2 km polyline has 401 anchors rather than the 26 in physical 018. An incomplete +search must remain incomplete, not be accepted merely because one good result +was found before checking alternatives. The next scaling work is reusable +reference preparation and an indexed, ambiguity-aware retrieval strategy—not +lowering overlap thresholds or deleting freshness protection. + +Other implementation bounds still need qualification on the longer recording: +40 m preparation tiles, repeated source verification/staging and extraction, +90-second extraction/export budgets, a 100,000-pose / 30 MiB trajectory cache, +and route-wide map storage/scans. These are not claims of a physical 40 m or +100 m route limit; their cost grows with the reference. This increment does +not silently rewrite that architecture. + +Recommended field sequence: record a 1–2 km reference; qualify its preparation +and existing saved-pass localisation offline; then repeat from the known start. +Cold restart in the middle of the long route is a separate acceptance gate. +Foliage/grass changes and handheld motion are relevant nuisance geometry; +these two same-day passes show encouraging tolerance, not seasonal acceptance +or an absolute scanner-position error certificate. diff --git a/docs/audits/2026-09-20-reference-preparation-and-recovery.md b/docs/audits/2026-09-20-reference-preparation-and-recovery.md new file mode 100644 index 0000000..92cee97 --- /dev/null +++ b/docs/audits/2026-09-20-reference-preparation-and-recovery.md @@ -0,0 +1,182 @@ +# Reference preparation and recovery before the long field route + +## Scope and decision + +Owner-authorized follow-up: useful, evidence-based preparation before recording +a 1–2 km reference. Preserve dense-start-first v6, all eligible route regions, +ambiguity rejection, source freshness and independent recording ownership. +No new field capture, K1 command, threshold relaxation or vehicle authority. +The current worktree already contained substantial unrelated and earlier work; +no commit, reset or unrelated cleanup was performed. + +## Implementation + +- `missions/sources.py`: prepare one verified private source snapshot for the + tiles of one atlas, instead of copying and hashing the raw recording again + for every tile. Verify original source identity and digests before staging + and after assembly, before returning the atlas. Staging is removed on success, + corruption, extraction failure and cancellation. Cancellation from the shared + recording copier is translated to `InterruptedError`, so it remains a cancelled + planning preparation rather than a product error. Single-submap API remains + compatible; numerical and presentation maps remain separate derivatives. +- `missions/reference_map.py`: assemble inside that verified snapshot context. + Retain the original tile order, 40 m tile geometry and first-point-per-voxel + deduplication semantics. Lightweight archive/test adapters without a prepared + snapshot capability retain the existing submap interface. +- `missions/reference_window.py`: an immutable, run-owned spatial index stores + source indices grouped in 10 m cells. Exact spherical filtering retains all + eligible points, duplicates, boundary points and original source order. + No point-count acceptance gate, thinning, expanded acceptance radius or changed + transform convention. A reference-identity mismatch fails explicitly. +- `missions/stationary_live.py`: build the index once for the run and reuse it + for fresh validation windows. The index is local to the live worker and is + discarded with it. Lookup mode and examined-point count are retained in each + window's evidence alongside the unchanged target-array hash. +- `missions/route_relocalization.py`: preprocess the exact local target once + per candidate place, sharing its GICP tree across that place's three yaw + seeds. Seed list, ranking, complete-search obligation, deadlines, ambiguity + and fresh confirmation are unchanged. This is not a new global retrieval + algorithm and does not qualify arbitrary kilometre-scale cold starts. +- `scripts/planning_archive_source.py`, `check_planning_live_bootstrap.py`: + explicit receipt-drop interval for the private archive adapter, preserving + surviving source payloads, identity and receipt intervals. Capture ownership, + recovery and source integrity are checked independently of recognition success. +- `scripts/check_reference_windows.py`, `check_reference_preparation.py`: bounded + real-data exactness and preparation checks. They do not use a live singleton, + connect to hardware, modify physical reports or generate synthetic kilometres. + +## Exact geometry and measured preparation + +Private physical source: existing reference JA-SADOVAYA-001 and independent +physical repeat 018, with source digests from the previous sealed acceptance. + +`qualification-20260920-reference-index-018.json` and its `-r2` successor show +the index's development measurements. All 21 saved fresh-fit input windows +matched both the full-scan result and original frozen registration target +bit-for-bit. Initial default sorting was slower; stable integer sorting retained +exact source order with lower overhead. These are retained measurements, not +a claim that the short reference became faster. + +Final preparation evidence: +`.runtime/qualification-20260920-reference-preparation-full/report.json`. +The harness uses the real `PlanningSources` verification/staging/export/map +path with a private, hash-bound archive catalog adapter; no production catalog +or application state is changed. + +| Measured reference | Selected physical interval | Complete saved trajectory | +|---|---:|---:| +| Recorded length | 120.073 m | 487.611 m | +| Preparation tiles | 4 | 13 | +| Numerical points | 172,954 | 390,668 | +| Numerical array bytes | 4,150,896 | 9,376,032 | +| Preparation wall time | 8.671 s | 28.109 s | +| Index construction | 0.081 s | 0.187 s | +| Index integer-array bytes, excluding dictionary overhead | 1,383,632 | 3,125,344 | +| Median full-scan window | 3.076 ms | 5.682 ms | +| Median indexed window | 3.508 ms | 5.109 ms | +| Median examined points | 76,985 | 114,760 | + +The selected interval's reconstructed map is bit-identical to the original +physical report. For each map, 21 real query windows matched the corresponding +full-scan implementation exactly. The complete map contains additional nearby +geometry, so its targets are not claimed identical to targets on the smaller +map. This is preparation/lookup evidence, not a 488 m independent traversal or +recognition acceptance on the expanded map. Timings are one bounded pass per +map, not a latency guarantee or a synthetic load test. + +Trajectory export took 0.353 s for 4,959 poses; its private cache is 868,331 bytes. +All input/code digest checks and staging-cleanup checks passed. + +## Real archive baseline and injected loss + +`.runtime/qualification-20260920-map-preparation-018-baseline` replays complete +018 spatial receipts at 1x through the production ingress/decoder/planner, +numerical child and in-process 10 Hz scene-delta endpoint. All 13 acceptance +checks passed. Tracking began at 45.84 s; 22/22 disjoint fresh checks passed over +85.023 m, with no recovery before admitted STOP. Both spatial queues published +1,439 and consumed 1,435 receipts, with zero overflow; four per modality remained +queued at STOP, not lost from raw recording. Maximum depths: lidar 10/32, pose +9/32. Maximum publisher lag 0.048 s; 1,261 scene requests. Compared with the +previous 52.40 s replay this acquired earlier, but the comparison is not a +controlled performance guarantee and scheduling changes fresh-window boundaries. + +`.runtime/qualification-20260920-map-preparation-018-gap` uses the same archive +with only derived pose/lidar receipts from [70, 80) seconds omitted. Surviving +timestamps are not compressed and raw files are unchanged. Exactly 198 receipts +were omitted (99 each). All 17 checks passed: tracking established before the +fault; authority cleared at about 74.25 s when the last accepted result expired; +recovery began with capture still active; ordinary STOP completed the study; +no previous transform was resurrected as live authority. + +The recorded operator continues walking after this artificial loss and stops +for less than the required 10-second stationary prefix at the end. Recovery +therefore remains unconfirmed. The recorded 270 `recovery_attempt` increments +are repeated motion-interrupted collection attempts, **not 270 expensive route +searches**. No new successful numerical recovery or stationary behaviour was +invented. Both queues again had zero overflow (1,340 published / 1,337 consumed +per modality); three remained queued at STOP. This proves continued recovery +ownership and fail-closed behaviour on moving real data, not successful physical +stop-and-reacquire. That success remains a separate field check; focused tests +cover the orchestration with a new stationary prefix and three fresh checks. + +Both replays sealed unchanged executed code and original inputs. The subsequent +production change was only cancellation-exception translation in `sources.py`; +these archive replays supply already-prepared geometry and do not exercise that +method. The later real preparation probe and final focused suite exercise the +final `sources.py`. No executed replay source was edited during its run. + +## Tests and runtime + +Final focused suite: 152 tests, zero failures/errors/skips, retained in +`.runtime/qualification-20260920-reference-preparation-tests.xml`. +Coverage includes exact indexed lookup, source mismatch, one snapshot per atlas, +mutation after assembly, extraction failure, cancellation during copying and +between tiles, unchanged single-submap API, survivor clocks/payloads, complete +ranked search, exhausted budget, ambiguity, late jobs, actual loss/recovery, +STOP ownership, route limits, recording separation and lease cleanup. +Ruff and whitespace checks passed; existing TestClient/httpx deprecation warning +remains. All heavy checks ran sequentially; no Mac synthetic load/stress job. + +The canonical 8000 LaunchAgent was reloaded using its reviewed plan/apply tool, +after proving no acquisition and resolved/inactive device-command state. +Current/desired plist SHA remained +`5d7a58122c016f8021fede27d1b21891ee20238fc38c281e5b33574ff8204e3f`; +backup and health acceptance succeeded. This is a local code reload, not a NAS +artifact deployment, data-root migration or scanner command. The UI and 80 m +presentation ceiling were not changed. + +Post-reload acceptance: operational health, idle acquisition/control, resolved +inactive physical command, preserved historical completed planning result and +80 m ceiling. PID 6431 was the sole backend listener on 8000; none on 8765 and +no temporary replay/numerical/test/Docker workload remained. Swap decreased +from 5,947.50 to 5,931.50 MiB across the sequential checks. A concise result was +appended to the existing MISSIONCOR-81 card via ops-context; private identifiers, +paths and raw operational evidence were kept local. + +## Remaining scaling work and next field gate + +The preparation still decodes selected frames after two sequential archive +passes per tile and merges voxel keys over the accumulated map. These costs +still grow with route size. Shared staging removes repeated copying/hashing, +not all route-length-dependent work. The atlas remains in memory; it is not a +disk-paged large-area map. A shared seekable frame index / streaming tile builder +is the next preparation optimisation if the long capture's measurements require it. + +Protective implementation bounds still exist: export/extraction wall budgets, +trajectory cache byte/pose count bounds, per-tile frame/point budgets, queue +capacity and numerical freshness deadlines. None is evidence of a 100 m maximum. +They were identified, not blindly removed. The original 100,000-point local-map +rejection remains absent; the separate 100,000 **trajectory pose** bound remains. +The current 4,959-pose reference does not qualify that bound at long dwell times. + +Cold route-wide search still verifies every eligible 5 m anchor with three +precise seeds within its existing budget. Around 2 km gives 401 anchors; sharing +target preprocessing does not prove this exhaustive stage will finish on time. +Do not weaken ambiguity checks or accept an incomplete search to hide this. + +Morning sequence remains: ordinary recording of one approximately 2 km reference +(line or convenient loop); verify complete capture, export/map preparation, +memory and source integrity; then a separate repeat from a known start. Cold +mid-route restart on the long map and physical stop-and-reacquire are separate +gates. No 2 km, seasonal, absolute-position or autonomous-driving acceptance is +claimed by the present increment. diff --git a/docs/audits/2026-09-20-route-cascade-v6-archive-qualification.md b/docs/audits/2026-09-20-route-cascade-v6-archive-qualification.md new file mode 100644 index 0000000..b10ca88 --- /dev/null +++ b/docs/audits/2026-09-20-route-cascade-v6-archive-qualification.md @@ -0,0 +1,182 @@ +# Route cascade v6 — archive qualification + +## Scope and architectural boundary + +Owner-authorized implementation following the run 016 retrieval diagnosis. +This change stays inside Teach & Repeat for the selected, previously recorded +location. It neither changes K1 SLAM nor admits autonomous vehicle control. +Original captures, physical experiment reports and planner comparisons remain +immutable; qualification results live in separate private `.runtime` folders. +No scanner, recording, MQTT publisher or second application server is started. + +The defect was not insufficient overlap or scanner range. In run 016 the coarse +descriptor ranked the actual location below a permanent top-six shortlist. +The precise fitter never received that location. See +[the preceding diagnosis](2026-09-20-mid-route-016-retrieval-diagnosis.md). + +## Implemented sequence + +1. Collect a continuous stationary prefix using the existing ten-second and + motion gates. Start with the established dense local-start search; after a + tracking loss its target is the last confirmed place, not the route origin. +2. If dense search honestly rejects the location, rank eligible local regions + across the selected route. Ranking controls work order, not eligibility: + the six-item permanent shortlist is gone. Six is now only a batch size. +3. Precisely fit the ranked regions sequentially, constructing one region at + a time. Preserve the existing numerical and freshness budgets. Account for + evaluated and remaining regions; an unfinished search is *incomplete*, not + a completed negative result or a valid provisional position. +4. Group converged fits by their resulting position **and orientation**. + Compare competing distinct locations, including fits originating from the + same coarse anchor. An ambiguous best result cannot become a prior. +5. A qualified best hypothesis remains provisional. Only three consistent, + disjoint fresh-data windows can establish tracking. If fresh geometry + rejects this hypothesis before tracking, try the next non-ambiguous + alternative, reset the streak, and collect new receipts after a new fence. + The original prefix's age limit is not extended by a retry. +6. If a dense-start provisional hypothesis fails fresh confirmation, collect + a new stationary prefix within the same recording and search the route + without repeatedly retrying that disproved start. Transport, identity, + stale-data and ambiguous-location failures cannot use this fallback to + manufacture a successful geometric result. +7. Established tracking keeps the existing local update and recovery flow. + Loss clears accepted localisation immediately and begins stationary + recovery around the last confirmed position. Capture ownership remains + separate. End of input clears live authority and releases the compute lease. + +The three fresh checks, overlap/RMSE, convergence, shape/information, identity, +continuity and age gates were not weakened. Descriptor similarity and fitted +overlap are not probabilities that the scanner is in the correct place. + +## Implementation map + +- `route_relocalization.py`: v6 full ranked queue, lazy local targets, + completeness accounting, distinct fitted-pose ambiguity and candidate queue. +- `stationary_bootstrap.py`: v3 per-hypothesis fresh confirmation, queue + continuation and dense-start rejection signal. +- `stationary_live.py` and `route_relocalization_worker.py`: same-capture + route-only retry after collecting a new stationary prefix; auditable candidate + index and trial number. Existing manual reinitialisation and post-loss recovery + retain their separate lifecycle. +- `planning_archive_source.py` / `check_planning_live_bootstrap.py`: optional + lower receipt boundary for testing the second recorded position independently; + preserves original clock intervals, epoch and source sequence. The boundary + does not provide the fitter with a reference position. + +## Acceptance evidence + +Qualification runs use the real derived ingress queue, K1 decoder, live service, +CPU worker and temporal gate at original 1x receipt timing. Each run seals input +digests, executed source copies, decisions, deliveries and terminal cleanup. +These are counterfactual software replays, not new physical field passes. + +Initial focused suite: 68 tests passed, including real numerical synthetic +registration and live-service fallback tests. Explicit cases cover a valid +location beyond the first batch, partial search, ambiguity from the same +anchor, distinct fresh receipts, stale priors/results, queue exhaustion, +transport discontinuity, source end and resource cleanup. The fake-result +service tests prove orchestration, not geometric recognition accuracy. + +Pre-final-metadata full 016 replay in +`.runtime/qualification-20260920-v6-016-full`: all 11 checks passed. Complete +108-seed start rejection followed by 54 fits across 18 route regions selected +the 30 m anchor. Three fresh checks established tracking at about 48.9 s; +the recorded approximately 6 m relocation remained tracked until input ended. +No operator reinitialisation command was injected in this full replay: unlike +the physical failed run, the first hypothesis now succeeded. + +Independent second-position replay in +`.runtime/qualification-20260920-v6-016-second`: search completed, selected a +20 m coarse anchor and passed two fresh checks. The original retained suffix +contains only 46.746 s of receipts after reinitialisation. Input ended before +the third check, so tracking was **not** established and the expected-tracking +assertion failed. The other ten checks passed. This is explicitly partial +evidence, not a passed cold-start qualification, and no frames were duplicated +to prolong it. + +Final-code full 015 regression in +`.runtime/qualification-20260920-v6-015-full`: all 11 checks passed. The unchanged +dense-start path succeeded without route retrieval (108 fits, 14.978 s numerical +search). Tracking was established at 39.1 s; all 27 fresh checks were accepted. +The recorded 99.282 m traversal completed without tracking loss or recovery; +end-of-input correctly cleared live localisation. Maximum ingress delivery lag +was 0.055 s. The physical original had 28 checks; replay scheduling changes +window boundaries and is not a claim of bit-for-bit fit identity. + +Wrong-region control in `.runtime/qualification-20260920-v6-016-wrong-region`: +the same 016 raw input was compared with the actual reference recording's +130–160 m interval, not a translated copy of the correct map. All 11 checks +passed, with **no** provisional position and **no** tracking. Start search +rejected as ambiguous; subsequent route search completed all 24 generated fits +and returned no admissible location. Together with 108 start fits, all 132 +attempts were accounted for. Maximum ingress lag was 0.029 s. This is one +negative example, not proof that all perceptually aliased places are rejected. + +Final-code repeat in `.runtime/qualification-20260920-v6-016-final`: all 11 +checks passed. Complete 108 + 54 fits selected the 30 m anchor (overlap +0.96945, inlier RMSE 0.14720 m); dense stage took 15.449 s, route stage 10.088 s. +Tracking started at 48.9 s. All 12 fresh checks passed, including the recorded +5.976 m movement; no tracking loss preceded the end of receipts at 96.9 s. +Maximum ingress lag was 0.043 s. RMSE measures fit residual, **not** absolute +localisation error. All original inputs and executed files remained unchanged +during each qualification; the final source/script hashes were rechecked. + +Expanded backend regression: **155/155 passed** in 20.29 s; retained JUnit is +`.runtime/qualification-20260920-v6-final-tests-r2.xml`. One existing +Starlette/httpx deprecation warning remains. Ruff on changed implementation and +focused tests, plus `git diff --check`, passed. The first expanded run is +retained separately: five distance tests had a v2-only bootstrap fake missing +the newly recorded candidate fields, and one existing threaded test asserted +publication immediately after worker submission. Updated the fake's contract +and waited for the actual published phase; no production acceptance rule was +changed to fix these tests. Only test files changed after archive qualification; +the executed product and probe script hashes still match the final replay. + +Source base: `be58d589e2fba520b1a3800574073f01a7fc9591`, with the existing dirty +worktree preserved. Qualified v6 source SHA-256: + +- `route_relocalization.py`: + `b49e802015b062d531cf4f6a0570f8ae45a4db34886ba7a4f13d465983fd9fe7` +- `stationary_bootstrap.py`: + `40720eab565b7860d7829fefe0e2583d0a6c6859f493be05da1b4eefdbbd265e` +- `stationary_live.py`: + `95f5bfc7d3a22851745b6f9e52d6e51da38eb9d7251c1c4ce9c1be90d1dda791` + +## Local service acceptance + +With K1 source idle and capture completed, reviewed a fresh plan and applied +the repository's `manage_mission_core_launch_agent.py` workflow. Installed and +desired plist SHA were identical: +`5d7a58122c016f8021fede27d1b21891ee20238fc38c281e5b33574ff8204e3f`. +This was a code reload in the same repository, not a configuration or data-root +migration. The workflow retained its backup and accepted health. + +Final runtime started at `2026-09-20T17:33:53.567849Z`: PID 95001 listens only +on canonical `127.0.0.1:8000`; `/api/health` is operational, device/source state +is idle, live ingress inactive. All 16 saved planner comparisons remain. +Nothing listens on 8765. Qualification publishers, numerical workers and pytest +processes have exited; no Docker workload or duplicate server was introduced. +Memory pressure remained normal (48% free by `memory_pressure`); swap stayed +at approximately 6339.5 MiB during the final sequential qualification stages. + +## Limits and next field gate + +This repairs exclusion by the shortlist and premature abandonment of a viable +queue. It is not a validated kilometre-scale place-recognition solution: visiting +all local regions still grows with route length. A runtime deadline remains a +freshness/resource guard, not a distance cap; exceeding it must remain visible +as incomplete. Descriptor comparability, spatially diverse retrieval and a +more scalable atlas remain follow-up work, not claims of this patch. + +The 80 m visual clipping domain and outline correction are unchanged. Visual +range is not a promise of usable geometric overlap at an arbitrary position. +Archive success does not establish absolute ground-truth accuracy, arbitrary +10 m off-route cold starts, lighting robustness, different mounting/terrain, +kilometre routes or vehicle safety. + +Only after archive regressions and negative controls pass is a short physical +test appropriate: initialise at the previously failed 20–30 m route location, +remain stationary through confirmation, then make a short monitored walk. +The independent second-position cold start still needs enough fresh real +receipts to complete its third check. There is no reason to request another +long walk just to debug the same saved input. diff --git a/docs/audits/2026-09-20-route-wide-relocalization.md b/docs/audits/2026-09-20-route-wide-relocalization.md new file mode 100644 index 0000000..75889bf --- /dev/null +++ b/docs/audits/2026-09-20-route-wide-relocalization.md @@ -0,0 +1,206 @@ +# Route-wide stationary relocalisation — implementation boundary + +## Intent + +Replace the laboratory assumption that an independent scanner session starts at +the first pose of the selected route. A stationary prefix now retrieves +candidate places from the full selected route before local GICP refinement. +This supports recovery after an interrupted run at a known part of a route; it +does not grant any rover, scanner, or navigation authority. + +## Implemented protocol + +1. The existing ten-second stationary prefix and its identity, monotonic-clock, + gap and motion fences are unchanged. +2. `route_relocalization.py` indexes the full reference map in a 10-m spatial + grid and resamples the *entire selected path* at five-metre atlas anchors. + Each anchor receives a compact rotation-invariant radial/height descriptor + of its local reference submap. The full map is not subject to the 100,000 + point cap for one GICP target; only each local target is. The query + descriptor is ranked against every usable anchor, not against + `reference_path[0]`. +3. The six best descriptor candidates receive a local target and three + yaw hypotheses chosen from a polar descriptor. Every generated hypothesis + is qualified by the existing small-gicp implementation, with a separately + recorded relocalisation policy. The broader initial correction envelope is + only for this isolated initial search; routine tracking retains its local + policy. +4. Candidate transforms are clustered by their transformed stationary query + position. Similar-quality clusters at distinct route progress are rejected + as `ambiguous-route-location`; no candidate yields `no-route-location`; + a worker or search deadline yields `incomplete-route-search`. +5. Only a complete, singular candidate becomes a **provisional** prior. The + pre-existing three disjoint fresh registration windows still decide whether + tracking is established. Candidate output retains + `localization_confirmed=false` and `vehicle_control=false`. + +All route-search policy values, descriptor coverage, generated-attempt count, +clusters and chosen route progress are stored in the initialization artifact. +The 30-s computational deadline deliberately remains below the bootstrap's +40-s source-age fence; a timeout is an observable incomplete calculation, not +a geometric rejection or a stale provisional position. + +## Operator-visible states + +- During retrieval: **«Поиск положения на маршруте»**. +- No stable place: **«Синхронизация маршрута не выполнена»** with a request to + remain near an explored area and repeat stationary calibration. +- Repeated/similar geometry: an explicit multiple-similar-segments status; the + system does not select one arbitrarily. +- Incomplete computation: **«Синхронизация маршрута не завершилась»**, distinct + from a geometric no-match. + +## Validation completed in code + +- 90-m synthetic route with the query constructed at the middle: descriptor + retrieval chose 40–45 m and real GICP accepted the fit at 100% overlap and + 0.0165 m RMSE. +- Separate synthetic duplicated-place case rejects as ambiguous and clears + correspondence colouring. +- The bootstrap rejects a route result whose declared generated-attempt count + does not match its recorded attempts. +- Existing causal stationary bootstrap, route-length and live ownership tests + remain green. + +## Not yet demonstrated + +This is an implementation and synthetic qualification, not proof on the K1 +field data. It does not establish capture radius, recovery at 10 m offset, +recovery in the middle of the present 100-m route, kilometre-scale memory/time, +mount transferability, absolute localisation accuracy, obstacle handling or +autonomous vehicle motion. The next physical acceptance should start a fresh +stationary run away from the route start, first around the current known route +and then at a deliberately chosen mid-route point. Preserve each result, +including explicit no-match, ambiguity or incomplete-search outcomes. + +## 2026-09-20 field finding and corrective decision + +The latest independent field attempt, `ja-sun-010-100m-10left`, started about +10 m opposite the normal route entry and reported `no-route-location`. This +was not a route-entry-radius rejection: all 18 generated route hypotheses ran. +The descriptor ranked the known entry region first (then the 5 m and 10 m +anchors). Its best GICP result converged with 61.25% overlap, 0.811 m +correction and 5.605° rotation, but was rejected solely because its 0.27594 m +inlier RMSE exceeded the common 0.25 m tracking threshold. + +The diagnosis also found a conflicting implementation detail: the stationary +query accumulator silently retained only a 20 m radius even though the route +search is intended to use the useful K1 scene around the operator. Therefore +the correct first change is **not** a global threshold reduction. + +- `route-relocalization/v2` explicitly records a 40 m stationary query radius + for whole-route initialisation and its three fresh local confirmations. It + is a numerical input footprint only: it is not a cap on route length, + recording duration, scanner range, or the normal live presentation budget. +- `LiveCloudBuffer`, `StationaryPrefix` and the fresh-confirmation buffer now + receive that radius explicitly and persist it into each staged calculation. + Ordinary short-range tracking retains its 20 m default profile. +- The rejected-run screen now provides **«Переинициализировать»**. It is + admitted only for a running, unconfirmed `lost` state. The action clears the + old derived preview, prior and result; it does not send a K1 command, stop + recording, reuse an old cloud, or start a vehicle. The next pose/cloud pair + starts a new ten-second stationary prefix and is stamped as the next attempt + in the run evidence. + +The 0.25 m tracking policy remains unchanged. Lowering it everywhere would +weaken routine motion tracking and incorrectly treat a one-shot recovery +near-miss as ongoing confirmation. If the repeat with the 40 m initial scene +again selects the correct local anchor but stays in the 0.27–0.30 m band, the +next bounded change is a separate provisional-initialisation policy, locked to +that selected local target and still requiring all three disjoint fresh windows +to agree before any tracking presentation. It must not alter vehicle +authority, which remains false in this laboratory profile. + +Code validation covers the 40 m accumulator request, `lost → operator retry → +new prefix → second route search` without closing the source lease, the typed +HTTP endpoint, existing stationary/route cases and the Control Station build. +The required physical acceptance is a repeat of the same 10 m opposite-entry +placement: keep the scanner still for the full prefix, save the resulting run, +and compare candidate progress, overlap, RMSE, convergence and all three fresh +windows before considering a separate provisional band. + +## 2026-09-20 `ja-sun-011` retry correction and 80-m footprint + +`ja-sun-011-100m-10left` recorded the intended failure separately from K1 +capture. Its first initialization generated all 18 route hypotheses in 2.94 s +and ended at `no-route-location`; the nearest entry hypothesis reached 57.82% +overlap but 0.2783 m RMSE, so it correctly did not bypass the 0.25 m gate. + +The following retry began 0.16 s after the operator click, reached a new +`collecting` prefix, and then surfaced the generic +`Stationary prefix`/geometry failure as a terminal **«Совмещение остановлено»**. +The raw recording was still active, but the presentation made an operator +repositioning mistake look like a K1 or route-search stop. The old worker also +continued to ingest numerical receipts after an unconfirmed route loss, so +carrying the scanner could contaminate the next prefix. + +`route-relocalization/v3` changes the boundary as follows: + +- The stationary query, presentation head and candidate GICP target now use + K1's declared 80-m sensing footprint. This is not a maximum walk distance, + capture duration or route length. A dense target is deterministically + voxelised to the explicit per-GICP computational budget without reducing + its radial footprint. +- The 28-m radial/height descriptor remains only the compact retrieval key for + selecting places across the route. Expanding it to 80 m made neighbouring + anchors on the short synthetic route less distinctive. After retrieval, the + actual GICP comparison receives the complete 80-m query and local target. +- In unconfirmed `lost`, the raw K1 source continues but numerical receipts are + deliberately ignored. The operator may carry the scanner to another + observable point, stop there, then press **«Переинициализировать»**. +- If the button is pressed before the scanner has stopped, movement or a + receipt gap during the new prefix returns to actionable `lost` with a + reinitialization diagnostic; it does not terminate the research run or + issue any K1 stop command. The following retry starts a clean buffer and + prefix. +- The vertical **«Срез»** control remains a Z-height visualization filter. Its + 7.5 m label is measured height in the currently rendered cloud, not an + 80-m sensor or relocalization range. The Planning "Движок" panel now shows + the independent working radius explicitly. + +Python validation covers 80-m capture, an 80-m dense target under the GICP +budget, full-route retrieval, and `lost → carry/retry interruption → retry → +second route search` while the exclusive source lease stays active. Control +Station typecheck, 872 unit tests and production build also passed. This remains +laboratory-only; the next field acceptance must preserve the retry result and +its artifacts before any threshold adjustment is considered. + +## 2026-09-20 v4 hybrid restoration after `ja-sun-012` / `ja-sun-013` + +The subsequent physical runs exposed that v3 had made normal startup depend on +the global target representation. It did not exhaust the route-search budget: +on `ja-sun-013`, it ranked the entry anchor first but the broad, voxel-reduced +target stopped at 70.69% overlap and 0.2577 m RMSE. That is not a valid reason +to relax the common 0.25 m acceptance limit. + +`route-relocalization/v4` therefore uses a staged strategy, +`dense-start-first-then-route-recovery/v1`: + +1. A normal start first runs the established 108-seed stationary acquisition + against a precise local reference window at the selected route start. Its + query footprint remains 80 m. A successful local result has to satisfy all + existing support, convergence and geometry checks; it remains provisional + until the three disjoint fresh windows confirm it. +2. Only a complete, honest local rejection enters full-route descriptor + retrieval. That fallback receives only the remaining search time under the + same 35-s / 40-s freshness fences. It cannot replace a valid dense start, + become vehicle authority, or silently reuse an old sample. +3. A retry whose prefix proves incomplete at `start_search()` now returns to + actionable `lost` with the reinitialisation diagnostic. It no longer + converts an operator repositioning attempt into terminal + **«Совмещение остановлено»**; raw recording remains under its existing owner. + +Offline replay through the actual isolated worker, using unchanged saved +inputs, accepts both known-good runs in the dense-start stage: + +- `ja-sun-013-100m`: 94.11% overlap, 0.1504 m RMSE, 108 attempts, 21.54 s + accumulated registration time. +- `ja-sun-012-100m-10left`, step 3: 90.09% overlap, 0.1808 m RMSE, 108 + attempts, 17.13 s accumulated registration time. + +The global recovery path is deliberately retained but not claimed as field +qualified yet. Its next bounded improvement is a coarse full-route retrieval +followed by a high-resolution local refinement *around the retrieved anchor*, +with separate field evidence at a deliberately chosen mid-route point. A +blind threshold reduction or an unbounded scan of the whole map remains out of +scope. diff --git a/docs/audits/2026-09-21-rerun-planning-customizations.md b/docs/audits/2026-09-21-rerun-planning-customizations.md new file mode 100644 index 0000000..7af816a --- /dev/null +++ b/docs/audits/2026-09-21-rerun-planning-customizations.md @@ -0,0 +1,96 @@ +# Rerun customization register: planning profile addendum + +Date: 2026-09-21. Documentation-only inventory of the implementation completed +on 2026-09-20, plus the owner's subsequent grid-picking regression report. +This does not implement a fix or update the Rerun dependency. + +## Scope and version + +The existing complete baseline remains +`2026-09-05-rerun-customization-inventory.md` and Ops MISSIONCOR #74. +Web Viewer and Python SDK remain pinned to 0.36.3. This planning work changes +Mission Core adapters, not the vendor SDK/WASM. The September 5 package/hash +audit is historical evidence; package provenance was not re-audited here. + +The planning viewer is a separate profile from recorded Sessions and canonical +LAB replay. Do not assume their camera-journal, FOLLOW or blueprint-lease +mechanisms are interchangeable. + +## Runtime and geometry transport + +- `components/missions/PlanningLiveScene.tsx` owns one isolated Rerun host and + one channel per run/retry. Layers, clipping, point size and tool-window state + are not dependencies of its mounting effect. +- `core/missions/planningSceneStream.ts` admits latest-only deltas with one + in-flight request. The last admitted cursor survives stale replies and + transport errors. `needsBase` repairs geometry independently of camera intent. +- `missions/live_scene_delta.py` records camera intent as `[mode, reset]`. + Only initial admission or an explicit mode/reset change sends `log_view`. + `log_base` refreshes reference, trajectory and grid entities without writing + the operator's eye. A display epoch or alignment change is not a camera reset. +- The query transform, bounded cloud chunks, current temporal cloud head and + accepted-match display are separate entities. `Clear`, static component + replacement and `Transform3D` inheritance are upgrade-sensitive semantics. +- HTTP reset generation is explicitly carried by `web/planning_live_api.py`. + A transport cursor is not localization evidence. Expired live replies remain + rejected; renderer recovery must not restore green localization authority. +- Browser presentation telemetry measures channel admission and two bounded + animation-frame opportunities, not actual GPU paint or physical accuracy. + +## Operator controls and camera ownership + +- `PlanningSceneToolWindow.tsx` uses the Design Guideline `WorkspaceWindow`: + modeless, draggable, resizable and scene-bounded, with maximize/restore and + Escape. Layers and Display share it and retain its position when switching. +- `PlanningSpatialWorkspace.tsx` and `workspaces/spatial/SpatialWorkspace.tsx` + supply the window through the shared scene tools slot. The duplicate + Planning and Engine toolbar actions are absent in this spatial profile. + The optional source action remains available to other consumers. +- Height clipping uses canonical vertical `RangeControl`, right-side shaft, + left-side endpoints, no perimeter outline, and a displayed upper bound of + 80 m. The full-scale position sends no extra height cut. Presentation + extraction and numerical localization extraction are independent; this is + not evidence of sensor range, terrain height or vehicle clearance. +- Native camera navigation is retained by not sending a replacement blueprint. + This planning path does not reconstruct eye from a pointer-input journal. + Explicit reset and top/3D presets may intentionally change the eye. + +## Known open regression: selectable grid + +To avoid a grid toggle reactivating a camera blueprint, the native +`LineGrid3D` is disabled and `world/grid` is logged as `LineStrips3D` in +`missions/live_scene.py`. Its XY extent follows reference bounds with 80 m +padding and adaptive spacing. It is a visual guide, not inferred ground. + +The owner screenshot on September 21 shows a hover tooltip for +`/world/grid[20]` and a highlighted line. Ordinary data geometry participates +in native picking; the previous acceptance missed this behavioral regression. +It remains **unfixed** in this checkpoint. A future fix must make the guide +non-interactive without disabling useful point/trajectory picking or +reintroducing camera resets. Prefer a supported viewer/blueprint mechanism; +do not silently patch WASM or intercept all pointer input. + +## Upgrade acceptance extension + +- [ ] Preserve iframe, native recording/channel and eye through clipping, + layer toggles, point-size/grid changes, stale replies and geometry repair. +- [ ] Confirm explicit reset and top/3D presets still work. +- [ ] Confirm static replacement, child transforms, current cloud head and + historical/live separation with the new SDK and Web Viewer together. +- [ ] Drag/resize/maximize tools while orbiting/zooming; test Escape, full screen + and focus without blocking scene interaction or remounting the viewer. +- [ ] Verify full-scale 80 m display, partial glyph contrast and no focus/drag + perimeter. UI clipping must not alter recording or registration inputs. +- [ ] Resolve and verify grid picking independently; retain useful picking on + actual scene data. Do not mark this item complete from the older screenshot QA. +- [ ] Repeat scene stream, scene tools, fast-display/API tests and browser QA; + retain the separate recorded Sessions/FOLLOW upgrade checks from the baseline. + +## Evidence limits + +September 20 acceptance: 875 frontend tests, typecheck/build, 8 focused backend +tests and browser interaction QA, recorded in +`2026-09-20-planning-scene-camera-and-tools.md`. These results predate the grid +report and do not prove that grid picking was correct. No new physical scan, +SLAM closure algorithm, registration threshold change or device command is +part of this documentation update. diff --git a/packages/spatial-ui/src/SpatialScene.tsx b/packages/spatial-ui/src/SpatialScene.tsx index 35ab3a0..ddbb903 100644 --- a/packages/spatial-ui/src/SpatialScene.tsx +++ b/packages/spatial-ui/src/SpatialScene.tsx @@ -7,22 +7,26 @@ export function SpatialScene({viewportRef, focused, primaryFocused, mediaMaximiz navigationReady=false}: { viewportRef: RefObject; focused?:boolean; primaryFocused?:boolean; mediaMaximized?:boolean; toolbar:ReactNode; renderer:ReactNode; deviceControls?:ReactNode; - sourceControls?:ReactNode; status:{label:string;tone:'neutral'|'success'|'warning'|'danger';message?:string}; + sourceControls?:ReactNode; status:{label:string;tone:'neutral'|'success'|'warning'|'danger';message?:string;pulse?:boolean}; metrics:ReactNode; timeline?:ReactNode; media?:ReactNode; overlays?:ReactNode; footer?:ReactNode; navigationReady?:boolean; }) { + const detailsHidden = primaryFocused || mediaMaximized; return
{toolbar}
{renderer} {deviceControls&&
{deviceControls}
} - {sourceControls} -
- ВИЗУАЛЬНЫЙ ДВИЖОК - {status.label} - {status.message?{status.message}:null} + {detailsHidden ? sourceControls : null} +
+ {!detailsHidden ? sourceControls : null} +
+ ВИЗУАЛЬНЫЙ ДВИЖОК + {status.label} + {status.message?{status.message}:null} +
+
{metrics}
-
{metrics}
{overlays} {navigationReady&&!mediaMaximized&&
Колесо · зум к курсоруWASD · свободный проход
} {timeline}{media} diff --git a/packages/spatial-ui/src/SpatialToolbarActions.tsx b/packages/spatial-ui/src/SpatialToolbarActions.tsx index 66ce5dd..abc19cf 100644 --- a/packages/spatial-ui/src/SpatialToolbarActions.tsx +++ b/packages/spatial-ui/src/SpatialToolbarActions.tsx @@ -1,8 +1,8 @@ import {Button,Icon} from '@nodedc/ui-react'; -export function SpatialToolbarActions({openSource,openLayers,openDisplay}:{openSource:()=>void;openLayers:()=>void;openDisplay:()=>void}) { +export function SpatialToolbarActions({openSource,openLayers,openDisplay,activeTool}:{openSource?:()=>void;openLayers:()=>void;openDisplay:()=>void;activeTool?:'layers'|'display'|null}) { return <> - - - + {openSource&&} + + ; } diff --git a/packages/spatial-ui/src/observation.css b/packages/spatial-ui/src/observation.css index c31cd0a..eea5c01 100644 --- a/packages/spatial-ui/src/observation.css +++ b/packages/spatial-ui/src/observation.css @@ -86,15 +86,6 @@ right: 0.85rem; } -.scene-status--top-left { - left: 7.1rem; -} - -.scene-status[aria-hidden="true"], -.scene-metrics[aria-hidden="true"] { - display: none; -} - .observation-source-menu { overflow: hidden; font-family: var(--nodedc-font-family); @@ -634,11 +625,6 @@ i[data-availability="error"] { } @media (max-width: 760px) { - .scene-status--top-left { - top: 4.2rem; - left: 0.6rem; - } - .observation-timeline[data-accumulation="true"] { grid-template-columns: minmax(0, 1fr); } diff --git a/packages/spatial-ui/src/spatial.css b/packages/spatial-ui/src/spatial.css index fcdc563..ced8193 100644 --- a/packages/spatial-ui/src/spatial.css +++ b/packages/spatial-ui/src/spatial.css @@ -261,8 +261,6 @@ line-height: 1.5; } -.scene-status, -.scene-metrics, .scene-adapter-note, .scene-selection, .scene-timeline { @@ -273,13 +271,34 @@ backdrop-filter: blur(16px); } -.scene-status--top-left { +.scene-information { + position: absolute; + z-index: 12; top: 0.85rem; left: 0.85rem; + max-width: min(20rem, calc(100% - 1.7rem)); + display: grid; + justify-items: start; + gap: 0.65rem; + pointer-events: none; +} + +.scene-information[aria-hidden="true"] { display: none; } +.scene-information > .scene-source-controls { position: static; pointer-events: auto; } + +.scene-status, +.scene-metrics { + border: 0; + background: rgb(9 10 13 / 0.74); + backdrop-filter: blur(16px); + border-radius: 0.9rem; + max-width: 100%; +} + +.scene-status { display: grid; justify-items: start; gap: 0.42rem; - border-radius: 0.9rem; padding: 0.7rem; } @@ -291,11 +310,8 @@ } .scene-metrics { - top: 0.85rem; - right: 0.85rem; display: grid; min-width: 10rem; - border-radius: 0.9rem; padding: 0.35rem 0.75rem; } @@ -313,6 +329,10 @@ .scene-metrics strong { color: var(--nodedc-text-primary); font-size: 0.72rem; } .scene-metrics small { color: var(--nodedc-text-muted); font-size: 0.52rem; font-weight: 500; } +@media (max-width: 760px) { + .scene-information { top: 0.6rem; left: 0.6rem; max-width: min(20rem, calc(100% - 1.2rem)); } +} + .scene-adapter-note { top: 50%; left: 50%; diff --git a/plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx b/plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx index 855a704..532b272 100644 --- a/plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx +++ b/plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx @@ -71,8 +71,10 @@ export function runSpatialActiveStreamForceFinish( export function K1SpatialControlsView({ controller, + spatialActivity, }: { controller: XgridsK1Controller; + spatialActivity?: DevicePluginConnectionProps["spatialActivity"]; }) { const { state, @@ -169,6 +171,13 @@ export function K1SpatialControlsView({ busy: false, } : k1SpatialPhasePresentation(acquisition, softwareCommanded); + // The workflow may continue the normal acquiring phase only. Calibration, + // recovery, authority failure and stopping always retain device-owned copy. + const presentedPhase = spatialActivity + && acquisition.state === "acquiring" && dataAuthoritative + && !physicalStopInFlight && !terminalPhysicalStopRequired && !cleanupPending + && spatialActivity.sessionId === state.connection_supervisor?.observed.data_plane.session_id + ? spatialActivity : phase; const telemetry = deviceTelemetry(dataAuthoritative ? state.metrics : undefined); const stopDisabled = pendingAction !== null || stopping; @@ -190,7 +199,7 @@ export function K1SpatialControlsView({ ); const actionFailure = runtimeActionFailure ?? spatialActionFailure(authorityFailure); return ( - + {actionFailure ? (
{actionFailure.title} @@ -240,7 +249,7 @@ export function K1SpatialControlsView({ ); } -export function K1SpatialControls(_props: DevicePluginConnectionProps) { +export function K1SpatialControls(props: DevicePluginConnectionProps) { const controller = useXgridsK1Controller(); - return ; + return ; } diff --git a/plugins/xgrids-k1/frontend/src/components/K1SpatialSession.css b/plugins/xgrids-k1/frontend/src/components/K1SpatialSession.css index 388f21a..160c63d 100644 --- a/plugins/xgrids-k1/frontend/src/components/K1SpatialSession.css +++ b/plugins/xgrids-k1/frontend/src/components/K1SpatialSession.css @@ -4,7 +4,7 @@ max-width: 100%; align-items: center; gap: 0.85rem; - border: 1px solid rgb(255 255 255 / 0.1); + border: 0; border-radius: 1rem; background: rgb(9 10 13 / 0.88); padding: 0.55rem 0.65rem 0.55rem 0.75rem; @@ -27,20 +27,16 @@ gap: 0.15rem; } -.xgrids-k1-spatial-controls__phase strong, -.xgrids-k1-spatial-controls__phase small { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - .xgrids-k1-spatial-controls__phase strong { - font-size: 0.66rem; + font-size: var(--nodedc-font-size-xs); } .xgrids-k1-spatial-controls__phase small { color: var(--nodedc-text-muted); - font-size: 0.53rem; + font-size: var(--nodedc-font-size-xs); + white-space: normal; + overflow-wrap: anywhere; + line-height: 1.35; } .xgrids-k1-spatial-controls__telemetry { @@ -99,7 +95,6 @@ @media (max-width: 960px) { .xgrids-k1-spatial-controls {min-width:0} - .xgrids-k1-spatial-controls__phase small, .xgrids-k1-spatial-controls__telemetry, .xgrids-k1-spatial-controls__error small {display:none} } diff --git a/plugins/xgrids-k1/frontend/src/components/K1SpatialSession.tsx b/plugins/xgrids-k1/frontend/src/components/K1SpatialSession.tsx index 68ab6b2..aee9672 100644 --- a/plugins/xgrids-k1/frontend/src/components/K1SpatialSession.tsx +++ b/plugins/xgrids-k1/frontend/src/components/K1SpatialSession.tsx @@ -96,7 +96,7 @@ export function K1SpatialSession({phase,telemetry,children}:{phase:PhasePresenta aria-busy={phase.busy} data-busy={phase.busy ? "true" : undefined} > -
+
{phase.busy ? : null} {phase.label} diff --git a/plugins/xgrids-k1/frontend/src/styles.css b/plugins/xgrids-k1/frontend/src/styles.css index f54c8f1..23daf0e 100644 --- a/plugins/xgrids-k1/frontend/src/styles.css +++ b/plugins/xgrids-k1/frontend/src/styles.css @@ -1105,7 +1105,6 @@ box-sizing: border-box; min-width: 0; } - .xgrids-k1-spatial-controls__phase small, .xgrids-k1-spatial-controls__telemetry, .xgrids-k1-spatial-controls__error small { display: none; diff --git a/plugins/xgrids-k1/packaging/runtime-files.json b/plugins/xgrids-k1/packaging/runtime-files.json index 242440e..53bdd47 100644 --- a/plugins/xgrids-k1/packaging/runtime-files.json +++ b/plugins/xgrids-k1/packaging/runtime-files.json @@ -70,6 +70,7 @@ "src/k1link/sessions/camera_frame.py", "src/k1link/sessions/equipment.py", "src/k1link/sessions/lab_cache.py", + "src/k1link/sessions/live_planning.py", "src/k1link/sessions/media.py", "src/k1link/sessions/models.py", "src/k1link/sessions/plugin_contract.py", diff --git a/pyproject.toml b/pyproject.toml index 7c7a51b..f45f675 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,9 @@ missioncore-plugin-sdk = { path = "packages/plugin-sdk", editable = true } [project.optional-dependencies] perception-stream = ["grpcio>=1.76,<2"] node-device-media = ["aiortc==1.14.0"] +localization = [ + "small-gicp==1.0.1", +] [project.scripts] k1link = "k1link.device_plugins.xgrids_k1.cli:app" diff --git a/scripts/build_camera_browser_probe.mjs b/scripts/build_camera_browser_probe.mjs new file mode 100644 index 0000000..6924f84 --- /dev/null +++ b/scripts/build_camera_browser_probe.mjs @@ -0,0 +1,97 @@ +/** Build a removable, offline-only browser probe around the production player. + * Input manifest/media must already be staged in outputRoot by a trusted local + * caller. No device API, live singleton or actual WebSocket is used by this probe. + */ +import {createRequire} from 'node:module'; +import {readFile,writeFile} from 'node:fs/promises'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const repository=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'); +const app=path.join(repository,'apps/control-station'); +const outputRoot=path.resolve(process.argv[2]); +if(!outputRoot.startsWith(path.join(app,'dist')+path.sep))throw new Error('Probe must remain below the canonical static root.'); +const require=createRequire(path.join(app,'package.json')); +const {build}=require('esbuild'); +const player=path.join(app,'src/components/MseFmp4WebSocketPlayer.tsx'); +const source=` +import React from 'react'; +import {createRoot} from 'react-dom/client'; +import {MseFmp4WebSocketPlayer} from ${JSON.stringify(player)}; +const base=new URL('.',location.href), nativeFetch=window.fetch.bind(window); +const manifest=await (await nativeFetch(new URL('manifest.json',base))).json(); +const report={schema:'missioncore.offline-browser-camera-probe/v1',startedAt:new Date().toISOString(),cases:[],unexpectedRequests:[],hardware:false,realWebSocket:false,realMediaSource:true}; +let current=null,finished=false; +const output=document.querySelector('pre'); +function renderReport(){output.textContent=JSON.stringify(report,null,2);} +window.fetch=async(input,options={})=>{ + const url=new URL(typeof input==='string'?input:input.url??input,location.href); + if(url.origin===base.origin&&url.pathname.startsWith(base.pathname)&&(options.method??'GET')==='GET')return nativeFetch(input,options); + if(url.pathname==='/api/v1/viewer/live-diagnostics'&&options.method==='POST'){ + if(current)current.events.push({atMs:performance.now()-current.started,event:JSON.parse(options.body)}); + return new Response('{}',{status:200}); + } + report.unexpectedRequests.push({path:url.pathname,method:options.method??'GET'});renderReport(); + throw new Error('Offline probe forbids all device and unrelated HTTP requests.'); +}; +class ArchiveSocket extends EventTarget{ + static CONNECTING=0;static OPEN=1;static CLOSING=2;static CLOSED=3; + readyState=0;binaryType='arraybuffer';timers=[]; + constructor(url){ + super(); + if(new URL(url).pathname!==base.pathname+'offline-socket')throw new Error('Unexpected socket destination'); + this.owner=current;this.owner.sockets++;this.owner.activeSockets++; + this.timers.push(setTimeout(()=>this.open(),0)); + } + open(){ + if(this.readyState===3)return; + this.readyState=1;this.dispatchEvent(new Event('open')); + const elapsed=this.owner.streamStarted===undefined?0:performance.now()-this.owner.streamStarted; + this.owner.streamStarted??=performance.now(); + // Every replacement gets init plus only subsequent media, like a disposable + // reader joining the existing stream. It cannot rewind the archived source. + this.sendBytes(this.owner.bytes[0]); + this.owner.entries.slice(1).forEach((row,index)=>{ + if(row.atMsthis.sendBytes(this.owner.bytes[index+1]),Math.max(0,row.atMs-elapsed))); + }); + } + sendBytes(bytes){if(this.readyState===1){this.owner.delivered++;this.dispatchEvent(new MessageEvent('message',{data:bytes.slice(0)}));}} + close(){if(this.readyState===3)return;this.readyState=3;this.timers.forEach(clearTimeout);this.owner.activeSockets--;this.dispatchEvent(new CloseEvent('close',{code:1000}));} + send(){throw new Error('Offline camera is read-only.');} +} +window.WebSocket=ArchiveSocket; +const root=createRoot(document.querySelector('#player')); +const sampleTimer=setInterval(()=>{ + if(!current||finished)return; + const video=document.querySelector('video'); + const quality=video?.getVideoPlaybackQuality?.(); + current.samples.push({atMs:performance.now()-current.started,status:document.querySelector('.mse-fmp4-player')?.dataset.status,currentTime:video?.currentTime??null,readyState:video?.readyState??null,error:video?.error?.code??null,decoded:quality?.totalVideoFrames??video?.webkitDecodedFrameCount??null,dropped:quality?.droppedVideoFrames??null}); + renderReport(); +},500); +const wait=ms=>new Promise(resolve=>setTimeout(resolve,ms)); +for(const fixture of manifest.cases){ + const bytes=await Promise.all(fixture.entries.map(async row=>(await nativeFetch(new URL(row.path,base))).arrayBuffer())); + current={name:fixture.name,started:performance.now(),entries:fixture.entries,bytes,samples:[],events:[],sockets:0,activeSockets:0,delivered:0}; + // The public DOM report excludes binary bytes and internal timer state. + const publicCase={name:current.name,samples:current.samples,events:current.events};report.cases.push(publicCase); + document.querySelector('h1').textContent='Проверка браузерной камеры: '+fixture.name+' · только архив'; + root.render(React.createElement(MseFmp4WebSocketPlayer,{key:fixture.name,label:'Сохранённая камера · тест декодера',delivery:{kind:'mse-fmp4-websocket',id:'offline-'+fixture.name,url:base.pathname+'offline-socket',mediaType:'video/mp4; codecs="avc1.641028"'},recoveryAuthorityIdentity:'offline-fixture-'+fixture.name})); + await wait(fixture.durationMs); + root.render(null);await wait(100); + Object.assign(publicCase,{sockets:current.sockets,activeSockets:current.activeSockets,delivered:current.delivered,expectedMedia:fixture.entries.length-1}); + renderReport(); +} +clearInterval(sampleTimer);root.unmount();finished=true;report.finishedAt=new Date().toISOString(); +const gapSamples=report.cases[0].samples.filter(s=>s.atMs>10000&&s.atMs<20000); +report.checks={cleanPlayed:report.cases[0].samples.some(s=>s.status==='playing'&&s.decoded>0),cleanGapVisible:gapSamples.length>0&&gapSamples.every(s=>s.status==='buffering'),cleanNoRestarts:report.cases[0].sockets===1,cleanNoDecodeErrors:!report.cases[0].samples.some(s=>s.error)&&!report.cases[0].events.some(e=>e.event.event_code==='live_camera_transport_restart_requested'),corruptDetected:report.cases[1].samples.some(s=>s.error)||report.cases[1].events.some(e=>e.event.event_code==='live_camera_transport_restart_requested'),allReadersClosed:report.cases.every(c=>c.activeSockets===0),noUnexpectedRequests:report.unexpectedRequests.length===0}; +document.querySelector('h1').textContent='Проверка браузерной камеры завершена · только архив';renderReport(); +`; +await writeFile(path.join(outputRoot,'probe-source.jsx'),source); +await build({stdin:{contents:source,resolveDir:app,sourcefile:'offline-camera-probe.jsx',loader:'jsx'},absWorkingDir:app,bundle:true,format:'esm',target:'es2022',jsx:'automatic',define:{'process.env.NODE_ENV':'"production"'},outfile:path.join(outputRoot,'probe.js')}); +const shell=await readFile(path.join(app,'dist/index.html'),'utf8'); +const css=shell.match(/href="([^\"]+\.css)"/)?.[1]; +if(!css)throw new Error('Canonical built stylesheet is missing.'); +await writeFile(path.join(outputRoot,'index.html'), + `Offline camera decoder qualification

Проверка браузерной камеры · только архив

Штатный плеер и настоящий MediaSource. Источник — локальные файлы. Подключения к K1 нет.

`); +console.log(JSON.stringify({outputRoot,productionPlayer:player,hardware:false})); diff --git a/scripts/check_entry_acquisition_controls.py b/scripts/check_entry_acquisition_controls.py new file mode 100644 index 0000000..3119d9f --- /dev/null +++ b/scripts/check_entry_acquisition_controls.py @@ -0,0 +1,75 @@ +"""Repeat the frozen negative controls with exactly the new entry search policy.""" + +import argparse +import json +from pathlib import Path + +import numpy as np + +from k1link.artifacts import utc_now_iso +from k1link.missions.causal_replay import digest +from k1link.missions.entry_acquisition_worker import run_entry_acquisition + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--controls", type=Path, required=True) + p.add_argument("--replay", type=Path, required=True) + p.add_argument("--output", type=Path, required=True) + args = p.parse_args() + prior = json.loads((args.controls / "report.json").read_text()) + replay = json.loads((args.replay / "report.json").read_text()) + path_file = args.replay / "step-003/query-path.npy" + files = {path_file: replay["artifacts"]["step-003/query-path.npy"]} + for name in ["wrong-region", "far-seed"]: + relative = name + "/registration-input.npz" + files[args.controls / relative] = prior["artifacts"][relative] + for file, expected in files.items(): + if digest(file) != expected: + raise ValueError("Control input changed.") + query_path = np.load(path_file, allow_pickle=False) + direction = next( + p - query_path[0] for p in query_path[1:] if np.linalg.norm((p - query_path[0])[:2]) >= 3 + ) + args.output.mkdir(parents=True, exist_ok=False) + report = dict( + schema_version="missioncore.entry-controls/v1", + created_at_utc=utc_now_iso(), + source_step="step-003", + results={}, + vehicle_control=False, + localization_confirmed=False, + input_digests={str(k): v for k, v in files.items()}, + ) + for name in ["wrong-region", "far-seed"]: + with np.load(args.controls / name / "registration-input.npz", allow_pickle=False) as data: + reference, query, initial = data["reference"], data["query"], data["initial"] + directory = args.output / name + directory.mkdir() + result = run_entry_acquisition( + directory, reference, query, initial, query_path[0], initial[:3, :3] @ direction + ) + report["results"][name] = {k: v for k, v in result.items() if k != "matched_query_indices"} + print( + json.dumps( + dict( + control=name, + status=result["status"], + reasons=result["reasons"], + hypotheses=len(result["initialization"]["attempts"]), + clusters=result["initialization"]["clusters"], + elapsed_s=result["initialization"]["elapsed_s"], + ) + ), + flush=True, + ) + report["source_integrity_verified"] = all(digest(k) == v for k, v in files.items()) + report["artifacts"] = { + str(x.relative_to(args.output)): digest(x) for x in args.output.rglob("*") if x.is_file() + } + report["finished_at_utc"] = utc_now_iso() + (args.output / "report.json").write_text(json.dumps(report, allow_nan=False)) + + +if __name__ == "__main__": + main() diff --git a/scripts/check_planning_live_bootstrap.py b/scripts/check_planning_live_bootstrap.py new file mode 100644 index 0000000..11626a5 --- /dev/null +++ b/scripts/check_planning_live_bootstrap.py @@ -0,0 +1,599 @@ +"""Bounded archive qualification of the actual live planning service, without devices.""" + +import argparse +import json +import shutil +import threading +import time +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +from check_stationary_bootstrap import code_hashes, write +from planning_archive_camera import ArchiveCamera +from planning_archive_source import ReceiptQueueArchiveSource + +from k1link.artifacts import utc_now_iso +from k1link.device_plugins.xgrids_k1.localization_source import extract_scene_submap, extract_submap +from k1link.device_plugins.xgrids_k1.planning_replay import iter_planning_events +from k1link.missions.causal_replay import digest +from k1link.missions.live_limits import live_route_limits +from k1link.missions.live_scene_delta import decode_cursor +from k1link.missions.live_tests import PlanningLiveTests +from k1link.missions.reference_map import build_reference_map +from k1link.missions.stationary_bootstrap import BOOTSTRAP_POLICY +from k1link.missions.stationary_entry import STATIONARY_POLICY + + +class ArchiveSource: + """One pending receipt, original intervals, no synthetic device authority.""" + + def __init__(self, raw, session, maximum_seconds=65, *, after_monotonic_ns=None): + if not 0 < maximum_seconds <= 600: + raise ValueError("This offline probe supports up to ten minutes of recorded receipts.") + self.maximum_seconds = maximum_seconds + self.iterator = iter(iter_planning_events(raw, session)) + self.pending = next(self.iterator) + while after_monotonic_ns is not None and self.pending.monotonic_ns < after_monotonic_ns: + self.pending = next(self.iterator) + self.origin = self.pending.monotonic_ns + self.started = None + self.session = session + self.owner = None + self.deliveries = [] + self.active = False + + def snapshot(self): + return dict( + active=self.active, + session_id=self.session if self.started else None, + session_generation=1 if self.started else 0, + ) + + def open(self, owner): + if self.owner: + raise RuntimeError("An archive consumer already exists.") + self.owner = owner + + def close(self, owner): + assert self.owner == owner + self.owner = None + self.iterator.close() + + def activate(self): + self.started = time.monotonic_ns() + self.active = True + + def take(self, owner): + assert self.owner == owner + if self.started is None: + time.sleep(0.02) + return None + if self.pending is None: + self.active = False + return None + delay = self.pending.monotonic_ns - self.origin + if delay > self.maximum_seconds * 1e9: + self.active = False + return None + stamp = self.started + delay + now = time.monotonic_ns() + if now < stamp: + time.sleep(min(0.02, (stamp - now) / 1e9)) + return None + original = self.pending + self.pending = next(self.iterator, None) + self.deliveries.append( + dict( + sequence=original.sequence, + kind=original.kind, + original_monotonic_ns=original.monotonic_ns, + mapped_monotonic_ns=stamp, + delivered_monotonic_ns=now, + lag_s=(now - stamp) / 1e9, + ) + ) + return replace(original, monotonic_ns=stamp) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--predecessor", type=Path) + group.add_argument("--physical-diagnosis", type=Path) + group.add_argument("--live-run", type=Path) + parser.add_argument("--reference-capture", type=Path) + parser.add_argument("--query-capture", type=Path) + parser.add_argument("--planning-source", type=Path) + parser.add_argument( + "--route-length-m", + type=float, + help="Select a longer reference for this archive probe only.", + ) + parser.add_argument( + "--reference-kind", choices=["correct-entry", "wrong-region"], default="correct-entry" + ) + parser.add_argument("--queue-ingress", action="store_true") + parser.add_argument( + "--drop-interval-s", + nargs=2, + type=float, + metavar=("START", "END"), + help="Omit derived receipts only; no source edits or retiming.", + ) + parser.add_argument( + "--spatial-stop-monotonic-ns", + type=int, + help="Model admitted STOP at a retained receipt, with capture active until planner ends.", + ) + parser.add_argument( + "--profile-planning", + action="store_true", + help="Profile the isolated planning consumer, not the canonical runtime.", + ) + parser.add_argument( + "--after-monotonic-ns", + type=int, + help="Start at a retained operator retry boundary; never selects a reference position.", + ) + parser.add_argument( + "--maximum-seconds", + type=float, + default=None, + help="Explicit offline replay wall budget; never a product-session limit.", + ) + parser.add_argument( + "--fast-scene", + action="store_true", + help="Qualify production delta route in-process at 10 Hz, without a server.", + ) + parser.add_argument("--camera-epoch", type=Path) + parser.add_argument("--ffmpeg", type=Path) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + if args.spatial_stop_monotonic_ns is not None and not args.queue_ingress: + parser.error("--spatial-stop-monotonic-ns requires --queue-ingress") + if args.drop_interval_s is not None and not args.queue_ingress: + parser.error("--drop-interval-s requires --queue-ingress") + if args.route_length_m is not None: + live_route_limits(args.route_length_m) + if not args.live_run: + parser.error("--route-length-m requires --live-run and its frozen planning source.") + maximum_seconds = args.maximum_seconds or (120 if args.live_run else 65) + scene_cadence = 0.5 if args.live_run else 2 + if args.fast_scene: + scene_cadence = 0.1 + reference_provenance = None + scene_reference = scene_provenance = None + if args.camera_epoch and (not args.ffmpeg or not args.ffmpeg.is_file()): + parser.error("A retained camera requires an explicit local FFmpeg binary.") + if args.live_run: + if not all( + p is not None and p.is_file() + for p in [args.reference_capture, args.query_capture, args.planning_source] + ): + parser.error( + "A live-run replay requires both raw captures and the frozen planning source." + ) + original = json.loads((args.live_run / "report.json").read_text()) + assert all(digest(args.live_run / p) == sha for p, sha in original["artifacts"].items()) + assert ( + digest(args.reference_capture) + == original["reference"]["source_digests"]["raw-transport-primary"] + ) + planning = json.loads(args.planning_source.read_text()) + assert planning["generation"] == original["draft"]["zone"]["generation"] + start, end = ( + original["draft"]["route"]["start_index"], + original["draft"]["route"]["end_index"], + ) + if args.reference_kind == "wrong-region": + start = next(i for i, p in enumerate(planning["poses"]) if p["distance_m"] >= 130) + end = next(i for i, p in enumerate(planning["poses"]) if p["distance_m"] >= 160) + if args.route_length_m is not None: + target = planning["poses"][start]["distance_m"] + args.route_length_m + if target > planning["poses"][-1]["distance_m"]: + parser.error("The reference recording does not cover the requested route.") + end = max(i for i, p in enumerate(planning["poses"]) if p["distance_m"] <= target) + path = np.array([p["position"] for p in planning["poses"][start : end + 1]]) + + def submap(session, generation, first, last, *, presentation=False): + extractor = extract_scene_submap if presentation else extract_submap + points, provenance = extractor(args.reference_capture, planning, first, last) + return points, { + **provenance, + **{k: planning[k] for k in ["session_id", "generation", "source_digests"]}, + } + + prepared = SimpleNamespace(bound=lambda *a: planning, submap=submap) + reference, reference_provenance = build_reference_map( + prepared, planning["session_id"], planning["generation"], start, end + ) + scene_reference, scene_provenance = build_reference_map( + prepared, planning["session_id"], planning["generation"], start, end, presentation=True + ) + files = [ + args.live_run / "report.json", + args.reference_capture, + args.query_capture, + args.planning_source, + args.query_capture.with_name("mqtt.metadata.jsonl"), + args.reference_capture.with_name("mqtt.metadata.jsonl"), + ] + inputs = {str(p): digest(p) for p in files} + previous = dict( + query_raw=str(args.query_capture), + query_session=original["query_session_id"], + reference_session=planning["session_id"], + ) + elif args.physical_diagnosis: + if args.reference_kind != "correct-entry": + parser.error("A physical diagnosis uses its exact frozen reference.") + previous = json.loads((args.physical_diagnosis / "run/report.json").read_text()) + sealed = json.loads((args.physical_diagnosis / "manifest.redacted.json").read_text()) + inputs = { + str(args.physical_diagnosis / item["path"]): item["sha256"] for item in sealed["files"] + } + reference = np.load(args.physical_diagnosis / "run/reference.npy", allow_pickle=False) + path = np.array([p["position"] for p in previous["draft"]["route"]["points"]]) + previous = dict( + query_raw=str(args.physical_diagnosis / "capture/captures/mqtt_live/mqtt.raw.k1mqtt"), + query_session=previous["query_session_id"], + reference_session=previous["reference"]["session_id"], + ) + else: + previous = json.loads((args.predecessor / "manifest.json").read_text()) + inputs = { + **previous["input_digests"], + str(args.predecessor / "manifest.json"): digest(args.predecessor / "manifest.json"), + } + with np.load(previous["references"][args.reference_kind], allow_pickle=False) as data: + reference, path = data["reference"], data["reference_path"] + assert all(digest(Path(p)) == sha for p, sha in inputs.items()) + root = args.output + root.mkdir(parents=True, exist_ok=False) + camera = ( + ArchiveCamera(args.camera_epoch, root / "camera", args.ffmpeg) + if args.camera_epoch + else None + ) + if camera: + inputs.update(camera.inputs) + implementation = { + **code_hashes(), + "scripts/check_planning_live_bootstrap.py": digest(Path(__file__)), + "scripts/planning_archive_source.py": digest(Path("scripts/planning_archive_source.py")), + "scripts/planning_archive_camera.py": digest(Path("scripts/planning_archive_camera.py")), + "src/k1link/device_plugins/xgrids_k1/camera.py": digest( + Path("src/k1link/device_plugins/xgrids_k1/camera.py") + ), + "src/k1link/web/camera_archive.py": digest(Path("src/k1link/web/camera_archive.py")), + "tests/test_planning_live.py": digest(Path("tests/test_planning_live.py")), + } + for name in [ + "src/k1link/missions/live_display_buffer.py", + "src/k1link/compute/live_perception.py", + "src/k1link/device_plugins/xgrids_k1/facade.py", + "tests/test_planning_stop_lifecycle.py", + "src/k1link/missions/live_limits.py", + "src/k1link/missions/reference_map.py", + "src/k1link/missions/reference_window.py", + "src/k1link/missions/live_buffer.py", + "src/k1link/missions/live_scene_delta.py", + "src/k1link/web/planning_live_api.py", + "tests/test_planning_fast_display.py", + "apps/control-station/src/core/missions/planningSceneStream.ts", + "apps/control-station/src/components/missions/PlanningLiveScene.tsx", + ]: + implementation[name] = digest(Path(name)) + for name in implementation: + target = root / "executed-source" / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(name, target) + write( + root / "manifest.json", + dict( + schema_version="missioncore.live-bootstrap-qualification/v1", + created_at_utc=utc_now_iso(), + created_monotonic_ns=time.monotonic_ns(), + input_digests=inputs, + implementation_sha256=implementation, + protocol=BOOTSTRAP_POLICY, + entry_policy=STATIONARY_POLICY, + queue_ingress=args.queue_ingress, + drop_interval_s=args.drop_interval_s, + profile_planning=args.profile_planning, + spatial_stop_monotonic_ns=args.spatial_stop_monotonic_ns, + after_monotonic_ns=args.after_monotonic_ns, + camera_replay=bool(camera), + reference_kind=args.reference_kind, + scene_cadence_s=scene_cadence, + fast_scene=args.fast_scene, + archive_maximum_seconds=maximum_seconds, + **live_route_limits(float(np.linalg.norm(np.diff(path, axis=0), axis=1).sum())), + pace=1, + expectation=( + "wrong-region: reject without tracking; correct-entry: complete prior -> " + "three disjoint fresh fits -> tracking -> end" + ), + clock=( + "original receipt deltas rebased to this process monotonic start; " + "original epoch retained" + ), + authority=( + "isolated archive adapter; optional in-process ASGI GET only; " + "no listening server, capture, device commands or vehicle control" + ), + limitations=( + "recorded startup/motion only; stationary physical wait and scanner UI " + "require field acceptance; optional camera uses real parser/archive/queue " + "and local FFmpeg decoder, not RTSP, browser MSE or acquisition authority" + ), + ), + ) + np.save(root / "qualified-reference.npy", reference, allow_pickle=False) + if reference_provenance is not None: + write(root / "reference-provenance.json", reference_provenance) + draft = dict( + id="archive-probe", + name="Stationary live integration · archive qualification", + revision=1, + zone=dict(session_id=previous["reference_session"], generation="frozen-archive"), + route=dict( + length_m=float(np.linalg.norm(np.diff(path, axis=0), axis=1).sum()), + start_index=0, + end_index=len(path) - 1, + points=[dict(position=p.tolist()) for p in path], + ), + ) + (root / "runtime").mkdir() + sources = SimpleNamespace( + store=SimpleNamespace( + get_session=lambda _: SimpleNamespace(plugin_id="archive-qualification") + ), + reference_map=lambda *args, **kwargs: ( + reference, + reference_provenance + or dict(session_id=previous["reference_session"], source_digests=inputs), + ), + ) + if scene_reference is not None: + sources.scene_reference_map = lambda *args, **kwargs: (scene_reference, scene_provenance) + drafts = SimpleNamespace( + database=root / "runtime" / "drafts.json", sources=sources, get=lambda _: draft + ) + source_type = ReceiptQueueArchiveSource if args.queue_ingress else ArchiveSource + source = source_type( + Path(previous["query_raw"]), + previous["query_session"], + maximum_seconds, + after_monotonic_ns=args.after_monotonic_ns, + **( + {"spatial_stop_monotonic_ns": args.spatial_stop_monotonic_ns} + | {"drop_interval_s": args.drop_interval_s} + if args.queue_ingress + else {} + ), + ) + lock = threading.Lock() + service = PlanningLiveTests(drafts, {"archive-qualification": source}, lock) + if args.profile_planning: + import cProfile + + original_work = service.work + + def profiled_work(*work_args): + profile = cProfile.Profile() + try: + return profile.runcall(original_work, *work_args) + finally: + profile.dump_stats(str(root / "planning-consumer.prof")) + + service.work = profiled_work + observations = [] + started_utc = utc_now_iso() + run = service.start(draft["id"], 1) + scene_saved = False + first_tracking_state = None + last_scene = 0 + scene_count = 0 + scene_measurements = [] + cursor = "" + client = None + if args.fast_scene: + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from k1link.web.planning_live_api import build_planning_live_router + + app = FastAPI() + app.include_router(build_planning_live_router(service)) + client = TestClient(app) + client.__enter__() + try: + deadline = time.monotonic() + 10 + while service.get()["state"] == "preparing" and time.monotonic() < deadline: + time.sleep(0.02) + assert service.get()["state"] == "waiting" + source.activate() + if camera: + camera.start() + while service.thread.is_alive(): + now = time.monotonic_ns() + assert now - source.started < (maximum_seconds + 40) * 1e9, ( + "Functional probe wall bound exceeded." + ) + state = service.get() + key = (state["state"], state.get("planning_phase"), state.get("result_source_sequence")) + if not observations or key != observations[-1]["key"]: + observations.append( + dict( + key=key, + at_s=(now - source.started) / 1e9, + frame_age_s=state["frame_age_s"], + result_age_s=state["result_age_s"], + tracking_state=state["tracking_state"], + message=state["message"], + source_active=source.snapshot().get("active", False), + recovery_attempt=state.get("recovery_attempt", 0), + accepted_sample=service.accepted_sample is not None, + ) + ) + print(json.dumps(observations[-1], ensure_ascii=False), flush=True) + if state["tracking_state"] == "tracking" and not scene_saved: + first_tracking_state = state + (root / "tracking.rrd").write_bytes(service.scene(run["id"], True)) + scene_saved = True + if now - last_scene >= scene_cadence * 1e9: + before = time.monotonic() + extra = {} + if client: + response = client.get( + f"/api/v1/mission-planner/live-tests/{run['id']}/scene-delta.rrd", + params=dict(cursor=cursor, base=scene_count == 0), + ) + assert response.status_code in (200, 204) + cursor = response.headers["X-Planning-Scene-Cursor"] + token = decode_cursor(cursor) + extra = dict( + cloud_revision=token["cloud"], + pose_sequence=token["pose"], + delta_live=token["live"], + display_cloud_age_s=float(response.headers["X-Planning-Cloud-Age"]), + at_s=(now - source.started) / 1e9, + ) + payload = response.content + else: + payload = service.scene(run["id"], scene_count == 0) + scene_measurements.append( + dict( + **extra, + seconds=time.monotonic() - before, + bytes=len(payload), + presentation_state=state.get("presentation_state"), + frame_age_s=state["frame_age_s"], + result_age_s=state["result_age_s"], + pose_age_s=state.get("pose_age_s"), + ) + ) + last_scene, scene_count = now, scene_count + 1 + time.sleep(0.01 if client else 0.05) + finally: + if client: + client.__exit__(None, None, None) + service.close() + if camera: + camera.close() + write(root / "deliveries.json", source.deliveries) + write(root / "observations.json", observations) + if args.drop_interval_s is not None: + write(root / "dropped-receipts.json", source.dropped_receipts) + result = service.get() + (root / "terminal.rrd").write_bytes(service.scene(run["id"], True)) + write(root / "scene-measurements.json", scene_measurements) + directory = service.directory(run["id"]) + steps = [] + seen = set() + for step in sorted(directory.glob("step-*")): + sample = json.loads((step / "source.json").read_text()) + decision = json.loads((step / "decision.json").read_text()) + if sample["role"] == "fresh-validation": + ids = {e["sequence"] for e in sample["events"]} + assert ids and not seen.intersection(ids) + assert all( + sample["fresh_floor_ns"] < e["monotonic_ns"] <= sample["requested_monotonic_ns"] + for e in sample["events"] + ) + seen.update(ids) + steps.append(dict(role=sample["role"], **decision)) + checks = dict( + completed=result["state"] == "completed", + expected_tracking=scene_saved == (args.reference_kind == "correct-entry"), + prior_not_accepted=result.get("initialization_temporal", {}).get("accepted") is False, + complete_search=( + result.get("initialization_result", {}).get("initialization", {}).get("complete") + is True + and len(result["initialization_result"]["initialization"]["attempts"]) + == result["initialization_result"]["initialization"]["expected_attempts"] + ), + producer_ok=getattr(source, "error", None) is None, + no_live_authority_after_end=service.accepted_sample is None + and result["tracking_state"] == "lost", + lease_released=source.owner is None and not lock.locked(), + inputs_unchanged=all(digest(Path(p)) == sha for p, sha in inputs.items()), + code_unchanged=all(digest(Path(p)) == sha for p, sha in implementation.items()), + no_green_after_end=result.get("presentation_state") != "live", + terminal_transform_retained=(not scene_saved or service.presentation.result is not None), + ) + if camera: + camera_report = camera.report() + write(root / "camera-report.json", camera_report) + checks["camera_passed"] = all(camera_report["checks"].values()) + if args.spatial_stop_monotonic_ns is not None: + checks["commanded_stop_not_loss"] = result[ + "termination_reason" + ] == "spatial-stop-requested" and ( + args.drop_interval_s is not None + or ( + result.get("recovery_attempt", 0) == 0 + and not any(t["phase"] == "lost" for t in result.get("phase_transitions", [])) + ) + ) + checks["capture_retained_at_stop"] = bool( + source.stopping_snapshot and source.stopping_snapshot["active"] + ) + if args.drop_interval_s is not None: + start, end = args.drop_interval_s + # The fault probe requires successful acquisition BEFORE the declared + # fault. Recovery may remain unconfirmed if the recorded operator kept + # walking; a synthetic stationary interval must not be manufactured. + prior = ( + (first_tracking_state or {}).get("initialization_result", {}).get("initialization", {}) + ) + checks["complete_search"] = ( + prior.get("complete") is True and len(prior["attempts"]) == prior["expected_attempts"] + ) + checks["prior_not_accepted"] = (first_tracking_state or {}).get( + "initialization_temporal", {} + ).get("accepted") is False + checks["tracking_before_fault"] = any( + o["tracking_state"] == "tracking" and o["at_s"] < start for o in observations + ) + checks["fault_exercised"] = bool(source.dropped_receipts) + recovery = [o for o in observations if o["key"][1] == "recovering"] + checks["recovery_keeps_capture"] = bool(recovery) and all( + o["key"][0] == "running" and o["source_active"] for o in recovery + ) + checks["recovery_revokes_old_authority"] = bool(recovery) and all( + o["tracking_state"] != "tracking" and not o["accepted_sample"] for o in recovery + ) + write( + root / "summary.json", + dict( + started_at_utc=started_utc, + finished_at_utc=utc_now_iso(), + started_monotonic_ns=source.started, + run_id=run["id"], + checks=checks, + steps=steps, + event_count=len(source.deliveries), + scene_count=scene_count, + ingress=source.snapshot().get("queues", {}), + display=result.get("display"), + producer_error=getattr(source, "error", None), + maximum_delivery_lag_s=max(d["lag_s"] for d in source.deliveries), + vehicle_control=False, + localization_confirmed=False, + ), + ) + write( + root / "seal.json", + {str(p.relative_to(root)): digest(p) for p in root.rglob("*") if p.is_file()}, + ) + print(json.dumps(checks), flush=True) + assert all(checks.values()), "See retained integration evidence." + + +if __name__ == "__main__": + main() diff --git a/scripts/check_planning_recovery.py b/scripts/check_planning_recovery.py new file mode 100644 index 0000000..76151ad --- /dev/null +++ b/scripts/check_planning_recovery.py @@ -0,0 +1,228 @@ +"""Fixed recovery/stationary functional probes; no device or application access.""" + +import argparse +import json +import platform +from pathlib import Path + +import numpy as np + +from k1link.artifacts import utc_now_iso +from k1link.device_plugins.xgrids_k1.localization_source import extract_submap +from k1link.device_plugins.xgrids_k1.planning_replay import iter_planning_events +from k1link.missions.causal_replay import digest, replay +from k1link.missions.entry_acquisition_worker import run_entry_acquisition +from k1link.missions.replay_faults import drop_receipts +from k1link.missions.stationary_entry import STATIONARY_POLICY, stationary_prefix + + +def write(path, data): + path.write_text(json.dumps(data, allow_nan=False, indent=2)) + + +def code_hashes(): + root = Path(__file__).resolve().parents[1] + paths = [ + Path(__file__).resolve(), + *sorted((root / "src/k1link/missions").glob("*.py")), + root / "src/k1link/device_plugins/xgrids_k1/planning_replay.py", + root / "src/k1link/device_plugins/xgrids_k1/planning_live.py", + root / "src/k1link/device_plugins/xgrids_k1/localization_source.py", + ] + return {str(p.relative_to(root)): digest(p) for p in paths} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--stage", choices=["prepare", "baseline", "drop", "stationary"], required=True + ) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--reference-raw", type=Path) + parser.add_argument("--reference-planning", type=Path) + parser.add_argument("--query-raw", type=Path) + parser.add_argument("--query-planning", type=Path) + parser.add_argument("--negative-controls", type=Path) + args = parser.parse_args() + root = args.output + if args.stage == "prepare": + if any( + x is None + for x in ( + args.reference_raw, + args.reference_planning, + args.query_raw, + args.query_planning, + args.negative_controls, + ) + ): + parser.error("Preparation requires all five source arguments.") + a = json.loads(args.reference_planning.read_text()) + b = json.loads(args.query_planning.read_text()) + if a["session_id"] == b["session_id"]: + raise ValueError("Independent query required.") + files = { + args.reference_planning: digest(args.reference_planning), + args.query_planning: digest(args.query_planning), + } + for raw, p in ((args.reference_raw, a), (args.query_raw, b)): + files[raw] = p["source_digests"]["raw-transport-primary"] + files[raw.with_name("mqtt.metadata.jsonl")] = p["source_digests"]["raw-transport-index"] + negative = json.loads((args.negative_controls / "report.json").read_text()) + negative_file = args.negative_controls / "wrong-region/registration-input.npz" + files[negative_file] = negative["artifacts"]["wrong-region/registration-input.npz"] + if any(digest(p) != h for p, h in files.items()): + raise ValueError("Input digest mismatch.") + root.mkdir(parents=True, exist_ok=False) + end = max(i for i, p in enumerate(a["poses"]) if p["distance_m"] <= 40) + reference, extraction = extract_submap(args.reference_raw, a, 0, end) + path = np.array([p["position"] for p in a["poses"][: end + 1]]) + wrong_start = next(i for i, p in enumerate(a["poses"]) if p["distance_m"] >= 130) + wrong_entry = np.array(a["poses"][wrong_start]["position"]) + wrong_forward = next( + np.array(p["position"]) - wrong_entry + for p in a["poses"][wrong_start + 1 :] + if np.linalg.norm((np.array(p["position"]) - wrong_entry)[:2]) >= 3 + ) + np.savez_compressed(root / "reference.npz", reference=reference, reference_path=path) + write( + root / "manifest.json", + dict( + schema_version="missioncore.recovery-probe/v1", + created_at_utc=utc_now_iso(), + reference_session=a["session_id"], + query_session=b["session_id"], + reference_length_m=a["poses"][end]["distance_m"], + extraction=extraction, + query_raw=str(args.query_raw), + negative_file=str(negative_file), + negative_entry=wrong_entry.tolist(), + negative_forward=wrong_forward.tolist(), + input_digests={str(p): h for p, h in files.items()}, + reference_sha256=digest(root / "reference.npz"), + implementation_sha256=code_hashes(), + vehicle_control=False, + localization_confirmed=False, + ), + ) + print( + json.dumps( + dict( + stage="prepared", + reference_length_m=a["poses"][end]["distance_m"], + points=len(reference), + ) + ), + flush=True, + ) + return + manifest = json.loads((root / "manifest.json").read_text()) + inputs = { + **manifest["input_digests"], + str(root / "reference.npz"): manifest["reference_sha256"], + } + if any(digest(Path(p)) != h for p, h in inputs.items()): + raise ValueError("Input changed before probe.") + code = code_hashes() + with np.load(root / "reference.npz", allow_pickle=False) as data: + reference, path = data["reference"], data["reference_path"] + events = iter_planning_events(Path(manifest["query_raw"]), manifest["query_session"]) + destination = root / args.stage + if args.stage in {"baseline", "drop"}: + fault = {} + if args.stage == "drop": + baseline = json.loads((root / "baseline/report.json").read_text()) + if baseline["first_tracking_s"] is None or baseline["first_tracking_s"] >= 44: + raise ValueError( + "Baseline did not establish tracking before frozen fault interval." + ) + events = drop_receipts(events, 44.0, 47.0, fault) + report = replay(events, reference, path, destination, mode="acquisition") + report["fault_injection"] = fault or None + else: + destination.mkdir(exist_ok=False) + report = dict( + schema_version="missioncore.stationary-probe/v1", + created_at_utc=utc_now_iso(), + policy=STATIONARY_POLICY, + results={}, + ) + sample, initial, forward, prefix = stationary_prefix(events, path) + events.close() + report["prefix"] = prefix + np.savez_compressed( + destination / "prefix.npz", + points=sample["points"], + path=sample["path"], + initial=initial, + forward=forward, + ) + with np.load(manifest["negative_file"], allow_pickle=False) as data: + wrong_reference = data["reference"] + # Only fixed A geometry is reused. No previous B seed or fit is read. + wrong_entry = np.array(manifest["negative_entry"]) + wrong_forward = np.array(manifest["negative_forward"]) + wrong_initial = np.eye(4) + wrong_initial[:3, 3] = wrong_entry - sample["path"][0] + for name, ref, hint, basis in [ + ("correct-entry", reference, initial, forward), + ("wrong-region", wrong_reference, wrong_initial, wrong_forward), + ]: + job = destination / name + job.mkdir() + result = run_entry_acquisition( + job, ref, sample["points"], hint, sample["path"][0], basis, mode="stationary" + ) + report["results"][name] = { + k: v for k, v in result.items() if k != "matched_query_indices" + } + print( + json.dumps( + dict( + control=name, + status=result["status"], + reasons=result["reasons"], + attempts=len(result["initialization"]["attempts"]), + clusters=result["initialization"]["clusters"], + seconds=result["initialization"]["elapsed_s"], + ) + ), + flush=True, + ) + report["artifacts"] = { + str(p.relative_to(destination)): digest(p) + for p in destination.rglob("*") + if p.is_file() + } + report.update( + input_digests=inputs, + source_integrity_verified=all(digest(Path(p)) == h for p, h in inputs.items()), + implementation_sha256=code, + runtime=dict( + system=platform.system(), machine=platform.machine(), python=platform.python_version() + ), + vehicle_control=False, + localization_confirmed=False, + finished_at_utc=utc_now_iso(), + ) + write(destination / "report.json", report) + print( + json.dumps( + { + k: report.get(k) + for k in ( + "mode", + "state", + "first_candidate_s", + "first_tracking_s", + "source_integrity_verified", + "transitions", + ) + } + ), + flush=True, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/check_planning_replay_controls.py b/scripts/check_planning_replay_controls.py new file mode 100644 index 0000000..1e7be37 --- /dev/null +++ b/scripts/check_planning_replay_controls.py @@ -0,0 +1,97 @@ +"""Two bounded negative checks using an already captured causal snapshot.""" + +import argparse +import json +from pathlib import Path + +import numpy as np + +from k1link.device_plugins.xgrids_k1.localization_source import extract_submap +from k1link.missions.causal_replay import digest +from k1link.missions.registration import path_hint +from k1link.missions.registration_worker import run_registration + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--replay", type=Path, required=True) + p.add_argument("--reference-raw", type=Path, required=True) + p.add_argument("--reference-planning", type=Path, required=True) + p.add_argument("--output", type=Path, required=True) + args = p.parse_args() + base = json.loads((args.replay / "report.json").read_text()) + planning = json.loads(args.reference_planning.read_text()) + # Fixed third causal snapshot, never the final offline B fit. + snapshot = args.replay / "step-003/registration-input.npz" + query_path_file = snapshot.with_name("query-path.npy") + files = { + snapshot: base["artifacts"][str(snapshot.relative_to(args.replay))], + query_path_file: base["artifacts"][str(query_path_file.relative_to(args.replay))], + args.reference_raw: planning["source_digests"]["raw-transport-primary"], + args.reference_raw.with_name("mqtt.metadata.jsonl"): planning["source_digests"][ + "raw-transport-index" + ], + } + for file, expected in files.items(): + if digest(file) != expected: + raise ValueError("Control input digest mismatch.") + args.output.mkdir(parents=True, exist_ok=False) + with np.load(snapshot, allow_pickle=False) as data: + reference, query, hint = data["reference"], data["query"], data["initial"] + path = np.load(query_path_file, allow_pickle=False) + distant = hint.copy() + distant[:3, 3] += 1000 + far_dir = args.output / "far-seed" + far_dir.mkdir() + far = run_registration(far_dir, reference, query, distant) + # This disjoint A interval was specified before executing the controls. + poses = planning["poses"] + start = next(i for i, x in enumerate(poses) if x["distance_m"] >= 130) + end = next(i for i, x in enumerate(poses) if x["distance_m"] >= 155) + wrong, meta = extract_submap(args.reference_raw, planning, start, end) + wrong_path = np.array([x["position"] for x in poses[start : end + 1]]) + wrong_dir = args.output / "wrong-region" + wrong_dir.mkdir() + other = run_registration(wrong_dir, wrong, query, path_hint(wrong_path, path)) + + def clean(value): + return {k: v for k, v in value.items() if k != "matched_query_indices"} + + report = dict( + schema_version="missioncore.causal-replay-controls/v1", + source_step="step-003", + reference_interval_m=[130, 155], + reference_interval_indices=[start, end], + reference_extraction=meta, + results={"far-seed": clean(far), "wrong-region": clean(other)}, + input_digests={str(k): v for k, v in files.items()}, + source_integrity_verified=all(digest(k) == v for k, v in files.items()), + vehicle_control=False, + localization_confirmed=False, + ) + report["artifacts"] = { + str(x.relative_to(args.output)): digest(x) for x in args.output.rglob("*") if x.is_file() + } + (args.output / "report.json").write_text(json.dumps(report, allow_nan=False)) + print( + json.dumps( + { + k: { + j: v.get(j) + for j in [ + "status", + "reasons", + "overlap", + "inlier_rmse_m", + "registration_seconds", + ] + } + for k, v in report["results"].items() + } + ), + flush=True, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/check_reference_preparation.py b/scripts/check_reference_preparation.py new file mode 100644 index 0000000..5d2e965 --- /dev/null +++ b/scripts/check_reference_preparation.py @@ -0,0 +1,175 @@ +"""Qualify preparation on an existing complete reference, without a live service.""" + +import argparse +import json +import time +from pathlib import Path +from types import SimpleNamespace + +import numpy as np + +from k1link.artifacts import utc_now_iso +from k1link.device_plugins.xgrids_k1.localization_source import extract_submap +from k1link.device_plugins.xgrids_k1.planning_source import export_planning_source +from k1link.missions.causal_replay import digest +from k1link.missions.reference_window import ReferenceWindowIndex, reference_window +from k1link.missions.sources import PlanningSources +from k1link.sessions.models import ReplayArtifact, ReplayCommand + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--previous-manifest", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + previous = json.loads(args.previous_manifest.read_text()) + inputs = {Path(p): sha for p, sha in previous["input_digests"].items()} + assert all(digest(p) == sha for p, sha in inputs.items()) + run_report = next(p for p in inputs if p.name == "report.json") + physical = json.loads(run_report.read_text()) + session = physical["reference"]["session_id"] + raw = next(p for p in inputs if session in str(p) and p.name == "mqtt.raw.k1mqtt") + index = raw.with_name("mqtt.metadata.jsonl") + root = args.output + root.mkdir(parents=True, exist_ok=False) + code_paths = [ + Path(__file__), + *Path("src/k1link/missions").glob("*.py"), + Path("src/k1link/device_plugins/xgrids_k1/localization_source.py"), + Path("src/k1link/device_plugins/xgrids_k1/planning_source.py"), + ] + code = {str(p): digest(p) for p in code_paths} + artifacts = tuple( + ReplayArtifact( + artifact_id=name, + path=p.resolve(), + media_type=media, + file_byte_length=p.stat().st_size, + replay_byte_length=p.stat().st_size, + expected_sha256=inputs[p], + ) + for name, p, media in [ + ("raw-transport-primary", raw, "application/x-k1mqtt"), + ("raw-transport-index", index, "application/x-ndjson"), + ] + ) + command = ReplayCommand( + session_id=session, + plugin_id="archive-qualification", + allowed_root=raw.parent.resolve(), + session_root=raw.parent.resolve(), + primary_artifact_id="raw-transport-primary", + artifacts=artifacts, + timeline_origin_epoch_ns=0, + timeline_origin_monotonic_ns=0, + speed=1.0, + loop=False, + ) + detail = SimpleNamespace( + plugin_id=command.plugin_id, + summary=SimpleNamespace(replayable=True, lab=None), + as_dict=lambda: {"display_name": "Private archive preparation"}, + ) + store = SimpleNamespace( + data_dir=root, prepare_replay=lambda _: command, get_session=lambda _: detail + ) + sources = PlanningSources( + store, {command.plugin_id: export_planning_source}, {command.plugin_id: extract_submap} + ) + t0 = time.monotonic() + doc = sources.get(session) + export_s = time.monotonic() - t0 + draft = physical["draft"]["route"] + measurements = [] + for label, first, last in [ + ("physical-selected", draft["start_index"], draft["end_index"]), + ("complete-recorded-reference", 0, len(doc["poses"]) - 1), + ]: + t0 = time.monotonic() + points, provenance = sources.reference_map(session, doc["generation"], first, last) + seconds = time.monotonic() - t0 + if label == "physical-selected": + original = run_report.parent / "reference.npy" + assert digest(original) == physical["artifacts"]["reference.npy"] + assert np.array_equal(points, np.load(original, allow_pickle=False)) + t1 = time.monotonic() + spatial_index = ReferenceWindowIndex(points) + indexed_s = time.monotonic() - t1 + windows = [] + for step in sorted(run_report.parent.glob("step-*")): + source_file = step / "source.json" + input_file = step / "registration-input.npz" + if not input_file.is_file(): + continue + assert ( + digest(source_file) + == physical["artifacts"][str(source_file.relative_to(run_report.parent))] + ) + assert ( + digest(input_file) + == physical["artifacts"][str(input_file.relative_to(run_report.parent))] + ) + saved = json.loads(source_file.read_text()) + with np.load(input_file, allow_pickle=False) as sample_input: + sample = dict(points=sample_input["query"], path=np.asarray(saved["query_path"])) + t2 = time.monotonic() + full, old = reference_window(points, sample, sample_input["initial"]) + t3 = time.monotonic() + local, new = reference_window( + points, sample, sample_input["initial"], index=spatial_index + ) + t4 = time.monotonic() + assert np.array_equal(full, local) + windows.append( + dict( + step=step.name, + full_s=t3 - t2, + indexed_s=t4 - t3, + examined=new["examined_points"], + target=len(local), + ) + ) + measurement = dict( + label=label, + seconds=seconds, + points=len(points), + bytes=points.nbytes, + route_m=doc["poses"][last]["distance_m"] - doc["poses"][first]["distance_m"], + tiles=len(provenance["tiles"]), + index_s=indexed_s, + index_array_bytes=spatial_index.order.nbytes, + spatial_cells=len(spatial_index.slices), + provenance=provenance, + exact_windows=len(windows), + median_window_full_s=float(np.median([w["full_s"] for w in windows])), + median_window_indexed_s=float(np.median([w["indexed_s"] for w in windows])), + median_window_examined=float(np.median([w["examined"] for w in windows])), + window_measurements=windows, + ) + measurements.append(measurement) + print(json.dumps({k: v for k, v in measurement.items() if k != "provenance"}), flush=True) + del points, spatial_index + checks = dict( + inputs_unchanged=all(digest(p) == sha for p, sha in inputs.items()), + code_unchanged=all(digest(Path(p)) == sha for p, sha in code.items()), + staging_removed=not list(sources.root.glob(".source.*")), + selected_reference_exact=True, + ) + result = dict( + created_at_utc=utc_now_iso(), + inputs={str(p): sha for p, sha in inputs.items()}, + implementation_sha256=code, + export_s=export_s, + pose_count=len(doc["poses"]), + trajectory_cache_bytes=(sources.root / (doc["generation"] + ".json")).stat().st_size, + checks=checks, + measurements=measurements, + limitation="Preparation of existing reference only, not independent long traversal.", + ) + (root / "report.json").write_text(json.dumps(result, indent=2, allow_nan=False)) + print(json.dumps(checks), flush=True) + assert all(checks.values()) + + +if __name__ == "__main__": + main() diff --git a/scripts/check_reference_windows.py b/scripts/check_reference_windows.py new file mode 100644 index 0000000..a00db3c --- /dev/null +++ b/scripts/check_reference_windows.py @@ -0,0 +1,92 @@ +"""Compare indexed and full-scan windows on frozen physical-pass fit inputs.""" + +import argparse +import json +import time +from pathlib import Path + +import numpy as np + +from k1link.artifacts import utc_now_iso +from k1link.missions.causal_replay import digest +from k1link.missions.reference_window import ReferenceWindowIndex, reference_window + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + report = json.loads((args.run / "report.json").read_text()) + artifacts = {args.run / p: sha for p, sha in report["artifacts"].items()} + assert all(digest(p) == sha for p, sha in artifacts.items()) + code = {p: digest(p) for p in [Path(__file__), Path("src/k1link/missions/reference_window.py")]} + reference = np.load(args.run / "reference.npy", allow_pickle=False) + started = time.perf_counter() + index = ReferenceWindowIndex(reference) + preparation = time.perf_counter() - started + measurements = [] + for step in sorted(args.run.glob("step-*")): + source = json.loads((step / "source.json").read_text()) + if source["role"] != "fresh-validation": + continue + input_path = step / "registration-input.npz" + if not input_path.exists(): + continue + with np.load(input_path, allow_pickle=False) as frozen: + sample = dict( + points=frozen["query"], path=np.asarray(source["query_path"]) + ) + t0 = time.perf_counter() + expected, old = reference_window(reference, sample, frozen["initial"]) + t1 = time.perf_counter() + actual, new = reference_window(reference, sample, frozen["initial"], index=index) + t2 = time.perf_counter() + assert np.array_equal(expected, actual) + assert np.array_equal(actual, frozen["reference"]) + assert new["target_sha256"] == old["target_sha256"] + measurements.append( + dict( + step=step.name, + full_scan_s=t1 - t0, + indexed_s=t2 - t1, + map_points=len(reference), + target_points=len(actual), + examined_points=new["examined_points"], + target_sha256=new["target_sha256"], + ) + ) + assert measurements + assert all(digest(p) == sha for p, sha in artifacts.items()) + assert all(digest(p) == sha for p, sha in code.items()) + result = dict( + created_at_utc=utc_now_iso(), + run=str(args.run), + original_report_sha256=digest(args.run / "report.json"), + implementation_sha256={str(p): sha for p, sha in code.items()}, + index_preparation_s=preparation, + index_bytes=index.order.nbytes, + spatial_cells=len(index.slices), + measurements=measurements, + all_exact=True, + sources_unchanged=True, + limitation="One bounded pass on real saved inputs, not kilometre qualification.", + ) + with args.output.open("x") as stream: + json.dump(result, stream, indent=2, allow_nan=False) + print( + json.dumps( + dict( + exact_windows=len(measurements), + map_points=len(reference), + index_preparation_s=preparation, + median_full_s=float(np.median([m["full_scan_s"] for m in measurements])), + median_indexed_s=float(np.median([m["indexed_s"] for m in measurements])), + median_examined=float(np.median([m["examined_points"] for m in measurements])), + ) + ) + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/check_stationary_bootstrap.py b/scripts/check_stationary_bootstrap.py new file mode 100644 index 0000000..53b6ee3 --- /dev/null +++ b/scripts/check_stationary_bootstrap.py @@ -0,0 +1,154 @@ +"""Frozen stationary-to-fresh functional experiment on independent archived walks.""" + +import argparse +import json +import platform +import shutil +import time +from pathlib import Path + +import numpy as np + +from k1link.artifacts import utc_now_iso +from k1link.device_plugins.xgrids_k1.planning_replay import iter_planning_events +from k1link.missions.causal_replay import digest +from k1link.missions.stationary_bootstrap import BOOTSTRAP_POLICY +from k1link.missions.stationary_replay import replay_stationary + + +def write(path, value): + path.write_text(json.dumps(value, indent=2, allow_nan=False)) + + +def code_hashes(): + root = Path(__file__).resolve().parents[1] + paths = [ + Path(__file__).resolve(), + *sorted((root / "src/k1link/missions").glob("*.py")), + root / "src/k1link/device_plugins/xgrids_k1/planning_replay.py", + root / "src/k1link/device_plugins/xgrids_k1/planning_live.py", + root / "src/k1link/device_plugins/xgrids_k1/localization_source.py", + root / "tests/test_stationary_bootstrap.py", + ] + return {str(p.relative_to(root)): digest(p) for p in paths} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--stage", choices=["prepare", "correct-entry", "wrong-region"], required=True + ) + parser.add_argument("--predecessor", type=Path) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + root = args.output + if args.stage == "prepare": + previous = args.predecessor + old = json.loads((previous / "manifest.json").read_text()) + inputs = {**old["input_digests"], str(previous / "reference.npz"): old["reference_sha256"]} + inputs[str(previous / "manifest.json")] = digest(previous / "manifest.json") + if any(digest(Path(p)) != sha for p, sha in inputs.items()): + raise ValueError("Predecessor input digest mismatch.") + root.mkdir(parents=True, exist_ok=False) + code = code_hashes() + for name in code: + target = root / "executed-source" / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(name, target) + a_path = next( + p + for p in old["input_digests"] + if p.endswith(".json") + and json.loads(Path(p).read_text()).get("session_id") == old["reference_session"] + ) + poses = json.loads(Path(a_path).read_text())["poses"] + wrong_path = np.array([p["position"] for p in poses if 130 <= p["distance_m"] <= 155]) + with np.load(old["negative_file"], allow_pickle=False) as data: + np.savez_compressed( + root / "wrong-reference.npz", reference=data["reference"], reference_path=wrong_path + ) + # The old archive supplies fixed A geometry only; no previous B fitted + # transform, mask or future B pose is used as an initialization seed. + inputs[str(root / "wrong-reference.npz")] = digest(root / "wrong-reference.npz") + write( + root / "manifest.json", + dict( + schema_version="missioncore.stationary-bootstrap-probe/v1", + created_at_utc=utc_now_iso(), + created_monotonic_ns=time.monotonic_ns(), + query_raw=old["query_raw"], + query_session=old["query_session"], + reference_session=old["reference_session"], + references={ + "correct-entry": str(previous / "reference.npz"), + "wrong-region": str(root / "wrong-reference.npz"), + }, + input_digests=inputs, + implementation_sha256=code, + protocol=BOOTSTRAP_POLICY, + maximum_seconds=65.0, + maximum_distance_m=40.0, + expectations=dict( + correct_entry="complete search -> provisional -> three fresh consistent fits", + wrong_region="no current candidate or tracking", + freshness="all validation observations after ready; windows have disjoint IDs", + pace="original receipt clocks, 1x; no offline B heading or transform", + ), + notes="Engineering qualification only. Frame continuity is unverified; " + "one pre-ready receipt gap permits a hypothesis, not a tracking result. " + "No new capture, device commands, UI changes or live activation.", + vehicle_control=False, + localization_confirmed=False, + ), + ) + print(json.dumps(dict(stage="prepared", output=str(root))), flush=True) + return + manifest = json.loads((root / "manifest.json").read_text()) + inputs = manifest["input_digests"] + code = code_hashes() + if code != manifest["implementation_sha256"]: + raise ValueError("Implementation changed since protocol freeze.") + if any(digest(Path(p)) != sha for p, sha in inputs.items()): + raise ValueError("Source changed before replay.") + with np.load(manifest["references"][args.stage], allow_pickle=False) as data: + reference, path = data["reference"], data["reference_path"] + events = iter_planning_events(Path(manifest["query_raw"]), manifest["query_session"]) + report = replay_stationary( + events, + reference, + path, + root / args.stage, + max_seconds=manifest["maximum_seconds"], + max_distance=manifest["maximum_distance_m"], + ) + report.update( + input_digests=inputs, + source_integrity_verified=all(digest(Path(p)) == h for p, h in inputs.items()), + implementation_sha256=code, + code_integrity_verified=code_hashes() == code, + runtime=dict( + system=platform.system(), machine=platform.machine(), python=platform.python_version() + ), + ) + write(root / args.stage / "report.json", report) + print( + json.dumps( + { + k: report.get(k) + for k in ( + "state", + "first_prior_s", + "first_candidate_s", + "first_tracking_s", + "transitions", + "source_integrity_verified", + "code_integrity_verified", + ) + } + ), + flush=True, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/planning_archive_camera.py b/scripts/planning_archive_camera.py new file mode 100644 index 0000000..4fa907e --- /dev/null +++ b/scripts/planning_archive_camera.py @@ -0,0 +1,179 @@ +"""Retained fMP4 through the real durable gateway and a bounded decoder reader. + +The producer reads local files at original receipt cadence. It cannot connect to +RTSP or send device commands. This qualifies downstream camera contention, not +network transport, device authority, or browser MSE. +""" + +import hashlib +import json +import subprocess +import sys +import threading +import time +from pathlib import Path + +from k1link.device_plugins.xgrids_k1.camera import ( + XgridsK1CameraGateway, + _CameraProducer, + _read_fmp4_stdout, +) +from k1link.missions.causal_replay import digest +from k1link.web.camera_archive import CameraArchiveWriter + + +def camera_inputs(epoch): + rows = [json.loads(line) for line in (epoch / "index.jsonl").read_text().splitlines()] + assert rows + inputs = {str(epoch / "index.jsonl"): digest(epoch / "index.jsonl")} + if rows[0]["kind"] != "init": + # The canonical August baseline indexes media only. Its init timestamp + # is unavailable; deliver init at the first media receipt without + # inventing a measured initialization latency. All media deltas survive. + summary = json.loads((epoch / "summary.json").read_text()) + inputs[str(epoch / "summary.json")] = digest(epoch / "summary.json") + rows.insert(0, dict(kind="init", path="init.mp4", sha256=summary["init_sha256"], + host_monotonic_ns=rows[0]["host_monotonic_ns"])) + previous = rows[0]["host_monotonic_ns"] + for row in rows: + path = (epoch / row["path"]).resolve() + assert path.is_relative_to(epoch.resolve()), "Camera path escaped retained epoch." + assert row["host_monotonic_ns"] >= previous + previous = row["host_monotonic_ns"] + assert digest(path) == row["sha256"] + inputs[str(path)] = row["sha256"] + assert (previous - rows[0]["host_monotonic_ns"]) / 1e9 <= 65 + return rows, inputs + + +class ArchiveCamera: + def __init__(self, epoch, root, ffmpeg): + self.epoch, self.root, self.ffmpeg = epoch, root, ffmpeg + self.rows, self.inputs = camera_inputs(epoch) + self.gateway = None + self.producer = None + self.decoder = None + self.threads = [] + self.files = [] + self.errors = [] + self.commits = [] + self.received = [] + + def start(self): + self.root.mkdir(parents=True, exist_ok=False) + self.started = time.monotonic_ns() + # Explicit local emitter only; never use the gateway's RTSP spawn path. + process = subprocess.Popen( + [sys.executable, str(Path(__file__).resolve()), str(self.epoch)], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + ) + self.gateway = XgridsK1CameraGateway( + self.root, "archive-qualification", committed_segment_observer=self.observe, + ) + self.producer = _CameraProducer( + 1, "sensor.camera.right", process, + CameraArchiveWriter(self.root, "sensor.camera.right", 1), + ) + # Private instance uses the same parser/archive/queue implementation. + # No singleton, active acquisition, authority ledger, or network endpoint. + self.gateway._producer = self.producer + self.gateway._generation = 1 + self.gateway._source_id = self.producer.source_id + self.gateway._recording_root = self.root + self.gateway._phase = "connecting" + self.lease = self.gateway.open_delivery(1, require_recording=True) + self.gateway.expect_source_end_for_device_stop() # Expected fixture EOF only. + frames = (self.root / "decoded.framemd5").open("wb") + errors = (self.root / "decode.log").open("wb") + self.files.extend([frames, errors]) + self.decoder = subprocess.Popen( + [str(self.ffmpeg), "-hide_banner", "-loglevel", "warning", "-threads", "1", + "-i", "pipe:0", "-map", "0:v:0", "-an", "-f", "framemd5", "pipe:1"], + stdin=subprocess.PIPE, stdout=frames, stderr=errors, + ) + self.threads = [ + threading.Thread(target=self.decode, name="archive-camera-preview"), + threading.Thread( + target=_read_fmp4_stdout, args=(self.gateway, self.producer), + name="archive-camera-parser", + ), + ] + for thread in self.threads: + thread.start() + + def observe(self, segment): + self.commits.append(dict( + kind=segment.kind, sequence=segment.sequence, + at_s=(time.monotonic_ns() - self.started) / 1e9, + sha256=hashlib.sha256(segment.payload).hexdigest(), + )) + + def decode(self): + try: + while (segment := self.lease.segments.get()) is not None: + kind, payload = segment + self.decoder.stdin.write(payload) + self.decoder.stdin.flush() + self.received.append(dict(kind=kind, sha256=hashlib.sha256(payload).hexdigest())) + except Exception as exc: + self.errors.append(f"{type(exc).__name__}: {exc}") + finally: + self.decoder.stdin.close() + + def close(self): + if self.gateway is None: + return + for thread in self.threads: + thread.join(3) + self.gateway.close() + for thread in self.threads: + thread.join(3) + for process in (self.producer.process, self.decoder): + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=3) + self.errors.append("Child exceeded cleanup deadline.") + for stream in self.files: + stream.close() + if self.producer.process.stdout: + self.producer.process.stdout.close() + + def report(self): + archive = self.root / "media/sensor.camera.right/epoch-1" + summary = json.loads((archive / "summary.json").read_text()) + expected = [row["sha256"] for row in self.rows] + frames = [line for line in (self.root / "decoded.framemd5").read_text().splitlines() + if line and not line.startswith("#")] + decoder_log = (self.root / "decode.log").read_text() + checks = dict( + archive_complete=summary["status"] == "complete", + committed_all_exact_bytes=[item["sha256"] for item in self.commits] == expected, + delivered_all_exact_bytes=[item["sha256"] for item in self.received] == expected, + preview_not_retired=self.lease.failure_code is None, + clean_decode=self.decoder.returncode == 0 and not decoder_log.strip() and bool(frames), + workers_stopped=all(not t.is_alive() for t in self.threads), + no_errors=not self.errors, + input_unchanged=all(digest(Path(p)) == sha for p, sha in self.inputs.items()), + ) + return dict(checks=checks, errors=self.errors, commits=self.commits, + media_segments=len(self.rows) - 1, decoded_frames=len(frames), + decoder_returncode=self.decoder.returncode, browser_mse_tested=False, + rtsp_tested=False, acquisition_authority_tested=False) + + +def emit(epoch): + rows, _ = camera_inputs(epoch) + started, origin = time.monotonic_ns(), rows[0]["host_monotonic_ns"] + for row in rows: + delay = started + row["host_monotonic_ns"] - origin - time.monotonic_ns() + if delay > 0: + time.sleep(delay / 1e9) + sys.stdout.buffer.write((epoch / row["path"]).read_bytes()) + sys.stdout.buffer.flush() + time.sleep(0.5) # Allow the disposable reader to drain before expected EOF. + + +if __name__ == "__main__": + emit(Path(sys.argv[1])) diff --git a/scripts/planning_archive_source.py b/scripts/planning_archive_source.py new file mode 100644 index 0000000..3b6993e --- /dev/null +++ b/scripts/planning_archive_source.py @@ -0,0 +1,141 @@ +"""Bounded 1x raw archive through the production derived queue and K1 decoder.""" + +import threading +import time + +from k1link.compute.live_perception import LivePerceptionIngress +from k1link.device_plugins.xgrids_k1.planning_live import K1PlanningLiveSource +from k1link.device_plugins.xgrids_k1.viewer.replay import iter_replay_messages + + +class ReceiptQueueArchiveSource: + """Private instance only: no live singleton, MQTT, capture or hardware access.""" + + def __init__( + self, + raw, + session, + maximum_seconds=65, + *, + after_monotonic_ns=None, + spatial_stop_monotonic_ns=None, + drop_interval_s=None, + ): + if not 0 < maximum_seconds <= 600: + raise ValueError("This offline probe supports up to ten minutes of recorded receipts.") + self.maximum_seconds = maximum_seconds + if drop_interval_s is not None and not ( + len(drop_interval_s) == 2 + and 0 < drop_interval_s[0] < drop_interval_s[1] < maximum_seconds + ): + raise ValueError("Invalid explicit archive fault interval.") + self.drop_interval_s = drop_interval_s + self.dropped_receipts = [] + self.raw, self.session = raw, session + self.after_monotonic_ns = after_monotonic_ns + self.spatial_stop_monotonic_ns = spatial_stop_monotonic_ns + self.stopping_snapshot = None + self.ingress = LivePerceptionIngress() + self.adapter = K1PlanningLiveSource(self.ingress) + self.started = None + self.owner = None + self.deliveries = [] + self.stop = threading.Event() + self.thread = None + self.error = None + + def snapshot(self): + return self.ingress.snapshot() + + def open(self, owner): + self.adapter.open(owner) + self.owner = owner + + def close(self, owner): + assert self.owner == owner + self.stop.set() + if self.thread: + self.thread.join(3) + assert not self.thread.is_alive(), "Archive publisher did not stop." + self.adapter.close(owner) + self.owner = None + + def activate(self): + self.ingress.begin_session(self.session) + self.started = time.monotonic_ns() + self.thread = threading.Thread(target=self.publish, name="bounded-archive-publisher") + self.thread.start() + + def take(self, owner): + return self.adapter.take(owner) + + def publish(self): + previous, origin = -1, None + try: + for message in iter_replay_messages(self.raw): + stamp = message.received_monotonic_ns + if stamp is None or stamp < previous: + raise ValueError("Archive has missing or regressing receipt clocks.") + previous = stamp + if self.after_monotonic_ns is not None and stamp < self.after_monotonic_ns: + continue + modality = ( + "pose" + if message.topic.endswith("/lio_pose") + else "lidar" + if message.topic.endswith("/lio_pcl") + else None + ) + if modality is None: + continue + if origin is None: + origin = stamp + delay = stamp - origin + if delay > self.maximum_seconds * 1e9: + break + if ( + self.drop_interval_s is not None + and self.drop_interval_s[0] <= delay / 1e9 < self.drop_interval_s[1] + ): + self.dropped_receipts.append( + dict(sequence=message.sequence, kind=modality, original_monotonic_ns=stamp) + ) + continue # Surviving receipts retain original cadence and identity. + mapped = self.started + delay + if self.stop.wait(max(0, (mapped - time.monotonic_ns()) / 1e9)): + break + delivered = time.monotonic_ns() + admitted = self.ingress.publish( + modality=modality, + source_id=message.topic, + source_sequence=message.sequence, + captured_at_epoch_ns=message.received_at_epoch_ns, + received_monotonic_ns=mapped, + payload=message.payload, + ) + self.deliveries.append( + dict( + sequence=message.sequence, + kind=modality, + original_monotonic_ns=stamp, + mapped_monotonic_ns=mapped, + delivered_monotonic_ns=delivered, + lag_s=(delivered - mapped) / 1e9, + admitted=admitted, + ) + ) + if ( + self.spatial_stop_monotonic_ns is not None + and stamp >= self.spatial_stop_monotonic_ns + ): + self.ingress.request_spatial_stop(self.session, 1) + self.stopping_snapshot = self.ingress.snapshot() + # Model retained recorder ownership. The planner must end + # first and close this private publisher, not wait for EOF. + if not self.stop.wait(10): + raise RuntimeError("Planning did not finish after the STOP boundary.") + break + except Exception as exc: + self.error = f"{type(exc).__name__}: {exc}" + finally: + self.ingress.end_session(self.session) diff --git a/scripts/replay_planning_registration.py b/scripts/replay_planning_registration.py new file mode 100644 index 0000000..fcbbf2e --- /dev/null +++ b/scripts/replay_planning_registration.py @@ -0,0 +1,133 @@ +"""Run one bounded causal experiment against an immutable saved reference. + +Use the repository virtual environment. This CLI never connects to hardware or +the application ingress. Inputs and reports belong in private runtime storage. +""" + +import argparse +import json +import platform +import time +from pathlib import Path + +import numpy as np + +from k1link.device_plugins.xgrids_k1.planning_replay import iter_planning_events +from k1link.missions.causal_replay import digest, replay + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--reference-run", required=True, type=Path) + parser.add_argument("--query-raw", required=True, type=Path) + parser.add_argument("--query-planning", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument( + "--mode", choices=["baseline", "tracking", "acquisition"], default="baseline" + ) + args = parser.parse_args() + started = time.monotonic() + repository = Path(__file__).resolve().parents[1] + code_paths = [Path(__file__).resolve()] + [ + repository / name + for name in ( + "src/k1link/missions/entry_acquisition.py", + "src/k1link/missions/entry_acquisition_worker.py", + "src/k1link/missions/causal_replay.py", + "src/k1link/missions/causal_tracking.py", + "src/k1link/missions/live_buffer.py", + "src/k1link/missions/registration.py", + "src/k1link/missions/registration_worker.py", + "src/k1link/device_plugins/xgrids_k1/planning_replay.py", + "src/k1link/device_plugins/xgrids_k1/planning_live.py", + ) + ] + code_hashes = {str(p.relative_to(repository)): digest(p) for p in code_paths} + original = json.loads((args.reference_run / "report.json").read_text()) + query = json.loads(args.query_planning.read_text()) + if query["session_id"] == original["reference"]["session_id"]: + raise ValueError("Independent replay requires a different query recording.") + files = { + args.reference_run / "report.json": digest(args.reference_run / "report.json"), + args.reference_run / "clouds.npz": original["artifacts"]["clouds.npz"], + args.query_raw: query["source_digests"]["raw-transport-primary"], + args.query_raw.with_name("mqtt.metadata.jsonl"): query["source_digests"][ + "raw-transport-index" + ], + args.query_planning: digest(args.query_planning), + } + for path, expected in files.items(): + if digest(path) != expected: + raise ValueError(f"Source digest mismatch: {path.name}") + with np.load(args.reference_run / "clouds.npz", allow_pickle=False) as archive: + # Deliberately do not load the fitted query, its path, or the final transform. + reference = archive["reference"] + reference_path = archive["reference_path"] + prep = time.monotonic() - started + report = replay( + iter_planning_events(args.query_raw, query["session_id"]), + reference, + reference_path, + args.output, + mode=args.mode, + ) + for path, expected in files.items(): + if digest(path) != expected: + report.update(state="invalid", source_integrity_verified=False) + (args.output / "report.json").write_text(json.dumps(report, allow_nan=False)) + raise ValueError("Source changed during replay.") + report.update( + reference_run_id=original["id"], + reference=original["reference"], + query={k: query[k] for k in ["session_id", "generation", "source_digests", "label"]}, + input_digests={str(path): value for path, value in files.items()}, + source_integrity_verified=True, + implementation_sha256=code_hashes, + reference_preparation_s=prep, + runtime=dict( + system=platform.system(), machine=platform.machine(), python=platform.python_version() + ), + ) + (args.output / "report.json").write_text(json.dumps(report, allow_nan=False)) + print( + json.dumps( + { + k: report[k] + for k in [ + "mode", + "state", + "elapsed_s", + "distance_m", + "first_heading_s", + "first_candidate_s", + "first_tracking_s", + "source_integrity_verified", + ] + } + ), + flush=True, + ) + print( + json.dumps( + [ + dict( + step=s["step"], + at=s["requested_s"], + distance=s["distance_m"], + seed=s["seed"], + status=s["result"]["status"], + temporal=s["temporal"], + overlap=s["result"].get("overlap"), + rmse=s["result"].get("inlier_rmse_m"), + fit_s=s["result"].get("registration_seconds"), + state=s["tracking_state"], + ) + for s in report["steps"] + ] + ), + flush=True, + ) + + +if __name__ == "__main__": + main() diff --git a/src/k1link/compute/live_perception.py b/src/k1link/compute/live_perception.py index e411b94..f7a1dad 100644 --- a/src/k1link/compute/live_perception.py +++ b/src/k1link/compute/live_perception.py @@ -385,8 +385,12 @@ class LivePerceptionIngress: "control": 4, "camera-init": 1, "camera-frame": 2, - "lidar": 8, - "pose": 16, + # Recorded K1 bursts reach 19 spatial receipts in 500 ms despite a + # ~10 Hz mean. Allow one burst plus consumer scheduling headroom. + # Still bounded (64 MiB worst case per spatial modality); overflow is + # observable and receipt timestamps/freshness are never rewritten. + "lidar": 32, + "pose": 32, } _MAX_PAYLOAD_BYTES: Final[dict[LiveIngressModality, int]] = { "control": 16 * 1024, @@ -406,6 +410,7 @@ class LivePerceptionIngress: self._session_id: str | None = None self._session_generation = 0 self._active = False + self._spatial_stop_requested = False self._closed = False self._consumer_id: str | None = None self._results_accepted = 0 @@ -430,6 +435,7 @@ class LivePerceptionIngress: self._session_id = session_id self._session_generation += 1 self._active = True + self._spatial_stop_requested = False self._publish_locked( modality="control", source_id="mission-core", @@ -439,6 +445,33 @@ class LivePerceptionIngress: payload=b'{"event":"session-start"}', ) + def request_spatial_stop(self, session_id: str, session_generation: int) -> bool: + """Fence derived localisation after an admitted acquisition STOP. + + Not proof of hardware standby: recording and raw-first publications + remain active. Only the lifecycle owner supplies this exact identity. + The latched snapshot survives overflow and wakes a waiting consumer. + """ + with self._condition: + if ( + not self._active + or self._closed + or self._session_id != session_id + or self._session_generation != session_generation + ): + return False + if not self._spatial_stop_requested: + self._spatial_stop_requested = True + self._publish_locked( + modality="control", + source_id="mission-core", + source_sequence=0, + captured_at_epoch_ns=time.time_ns(), + received_monotonic_ns=time.monotonic_ns(), + payload=b'{"event":"spatial-stop-requested"}', + ) + return True + def end_session(self, session_id: str) -> None: with self._condition: if not self._active or self._session_id != session_id: @@ -560,6 +593,7 @@ class LivePerceptionIngress: "schema_version": LIVE_INGRESS_SCHEMA, "mode": "shadow-diagnostic-only", "active": self._active, + "spatial_stop_requested": self._spatial_stop_requested, "session_id": self._session_id, "session_generation": self._session_generation, "consumer_connected": self._consumer_id is not None, diff --git a/src/k1link/device_plugins/xgrids_k1/composition.py b/src/k1link/device_plugins/xgrids_k1/composition.py index 7ef5e93..76a35f5 100644 --- a/src/k1link/device_plugins/xgrids_k1/composition.py +++ b/src/k1link/device_plugins/xgrids_k1/composition.py @@ -6,6 +6,7 @@ from missioncore_plugin_sdk.v0alpha2 import RuntimePluginDescriptor from k1link.web.plugin_runtime import DevicePluginRuntimeContribution, InProcessDevicePluginRuntime +from .planning_live import K1PlanningLiveSource from .camera import build_xgrids_k1_camera_router from .facade import ( XGRIDS_K1_PLUGIN_ID, @@ -57,5 +58,5 @@ def build_xgrids_k1_plugin(repository_root: Path) -> DevicePluginRuntimeContribu ), ), ), - observation=build_xgrids_k1_observation(repository_root), + observation=build_xgrids_k1_observation(repository_root, K1PlanningLiveSource(service.live_perception_ingress)), ) diff --git a/src/k1link/device_plugins/xgrids_k1/facade.py b/src/k1link/device_plugins/xgrids_k1/facade.py index 72ce288..aa0c0c1 100644 --- a/src/k1link/device_plugins/xgrids_k1/facade.py +++ b/src/k1link/device_plugins/xgrids_k1/facade.py @@ -22641,6 +22641,7 @@ class XgridsK1CompatibilityService: def dispatch_admission_deadline_reached() -> bool: return self._operations.deadline_reached(operation.operation_id) + stop_ingress = self.live_perception_ingress.snapshot() try: self._application_control_session.request_stop( confirmation=request.physical_acceptance.confirmation(), @@ -22663,6 +22664,14 @@ class XgridsK1CompatibilityService: expected_session_generation=(request.expected_control_session_generation), expected_state_revision=request.expected_control_state_revision, ) + # End derived localisation after admission, not after the + # potentially long raw-recording/READY finalisation. A + # rejected synchronous request never reaches this edge. + if prepared_stop_lineage.evidence_session_id is not None: + self.live_perception_ingress.request_spatial_stop( + prepared_stop_lineage.evidence_session_id, + stop_ingress["session_generation"], + ) with self._lock: # The fresh S1 now owns the durable edge. The retained # S0 classification owner must not race or survive it. diff --git a/src/k1link/device_plugins/xgrids_k1/localization_source.py b/src/k1link/device_plugins/xgrids_k1/localization_source.py new file mode 100644 index 0000000..71ab0bf --- /dev/null +++ b/src/k1link/device_plugins/xgrids_k1/localization_source.py @@ -0,0 +1,82 @@ +"""Extract a bounded, provenance-carrying K1 submap from a saved interval.""" +from __future__ import annotations + +import bisect +import time +import numpy as np +from .protocol.streams import decode_lio_pcl +from .viewer.replay import iter_replay_messages + +EXTRACTION = {'version': 'k1-submap/v1', 'max_frames': 120, 'max_raw_points': 2_000_000, + 'max_retained_points': 1_000_000, 'voxel_m': .25, + 'radius_m': 20., 'height_relative_m': [-3., 6.]} + + +def extract_submap(source, planning, start, end): + return _extract(source, planning, start, end, presentation=False) + + +def extract_scene_submap(source, planning, start, end): + """Presentation geometry is never an input to numerical localisation.""" + return _extract(source, planning, start, end, presentation=True) + + +def _extract(source, planning, start, end, *, presentation): + profile = ({**EXTRACTION, "version": "k1-scene-submap/v1", + "radius_m": 80.0, "height_relative_m": None} if presentation else EXTRACTION) + poses = planning['poses'] + if not 0 <= start < end < len(poses): + raise ValueError('Некорректный интервал записи.') + if poses[end]['distance_m'] - poses[start]['distance_m'] > 40: + raise ValueError('Для первой проверки выберите участок не длиннее 40 м.') + lower, upper = poses[start]['message_index'], poses[end]['message_index'] + started = time.monotonic() + def messages(): + for ordinal, msg in enumerate(iter_replay_messages(source)): + if ordinal > 2_000_000 or time.monotonic() - started > 90: + raise ValueError('Превышен предел подготовки участка записи.') + if ordinal > upper: + break + if ordinal >= lower and msg.topic.endswith('/lio_pcl'): + yield ordinal, msg + eligible = [ordinal for ordinal, _ in messages()] + if not eligible: + raise ValueError('На выбранном участке нет кадров облака.') + selected = {eligible[int(i)] for i in np.linspace(0, len(eligible)-1, min(120, len(eligible)))} + pose_ordinals = [p['message_index'] for p in poses] + chunks, provenance = [], [] + raw_count = retained_count = 0 + for ordinal, msg in messages(): + if ordinal not in selected: + continue + frame = decode_lio_pcl(msg.payload) # Fail closed on any selected corrupt frame. + xyz = np.array([p.scaled_xyz(frame.header.scaler) for p in frame.points], dtype=np.float64) + if not len(xyz): + continue + if not np.isfinite(xyz).all(): + raise ValueError('В облаке обнаружены некорректные координаты.') + pose = poses[bisect.bisect_right(pose_ordinals, ordinal)-1] + delta = xyz - np.asarray(pose['position']) + keep = np.linalg.norm(delta, axis=1) <= profile["radius_m"] + if profile["height_relative_m"] is not None: + low, high = profile["height_relative_m"] + keep &= (delta[:, 2] >= low) & (delta[:, 2] <= high) + raw_count += len(xyz); retained_count += int(keep.sum()) + if raw_count > EXTRACTION['max_raw_points'] or retained_count > EXTRACTION['max_retained_points']: + raise ValueError('Облако превышает предел размера проверочного участка.') + chunks.append(xyz[keep]) # K1 publishes map-space points: no second pose transform. + timing_available = pose['elapsed_s'] is not None + provenance.append({'message_index': ordinal, 'sequence': msg.sequence, + 'received_monotonic_ns': msg.received_monotonic_ns if timing_available else None, + 'received_at_epoch_ns': msg.received_at_epoch_ns if timing_available else None, + 'pose_index': pose['index']}) + points = np.concatenate(chunks) if chunks else np.empty((0, 3)) + _, indices = np.unique(np.floor(points / .25).astype(np.int64), axis=0, return_index=True) + points = points[np.sort(indices)] + if presentation and len(points) > 100_000: + # Display LOD spans the entire extracted volume, never a height slice. + points = points[np.linspace(0, len(points)-1, 100_000, dtype=int)] + return points, {'extraction': profile, 'start_index': start, 'end_index': end, + 'message_interval': [lower, upper], 'available_frames': len(eligible), + 'frames': provenance, 'raw_points': raw_count, 'retained_points': retained_count, + 'voxel_points': len(points), 'extraction_seconds': time.monotonic() - started} diff --git a/src/k1link/device_plugins/xgrids_k1/observation.py b/src/k1link/device_plugins/xgrids_k1/observation.py index 52b9d44..502e8ab 100644 --- a/src/k1link/device_plugins/xgrids_k1/observation.py +++ b/src/k1link/device_plugins/xgrids_k1/observation.py @@ -39,7 +39,7 @@ from k1link.web.camera_archive import recover_incomplete_camera_archives XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1" -def build_xgrids_k1_observation(repository_root: Path) -> ObservationRuntimeContribution: +def build_xgrids_k1_observation(repository_root: Path, live_planning_source=None) -> ObservationRuntimeContribution: """Compose every K1 evidence root behind the generic observation ABI.""" configured_legacy_root = os.environ.get( @@ -58,6 +58,9 @@ def build_xgrids_k1_observation(repository_root: Path) -> ObservationRuntimeCont ), ) point_colors = RecordedPointColorOverlayStore() + from .session_overview import export_session_overview + from .planning_source import export_planning_source + from .localization_source import extract_submap, extract_scene_submap return ObservationRuntimeContribution( archives=tuple( ObservationArchiveSource( @@ -71,6 +74,11 @@ def build_xgrids_k1_observation(repository_root: Path) -> ObservationRuntimeCont ), recording_exporter=_export_recording, point_color_renderer=point_colors.render, + overview_exporter=export_session_overview, + planning_exporter=export_planning_source, + submap_extractor=extract_submap, + scene_submap_extractor=extract_scene_submap, + live_planning_source=live_planning_source, ) diff --git a/src/k1link/device_plugins/xgrids_k1/planning_live.py b/src/k1link/device_plugins/xgrids_k1/planning_live.py new file mode 100644 index 0000000..e02cc79 --- /dev/null +++ b/src/k1link/device_plugins/xgrids_k1/planning_live.py @@ -0,0 +1,74 @@ +"""Planning profile uses the existing exclusive derived-data lease, never MQTT.""" + +import json + +import numpy as np + +from k1link.sessions.live_planning import PlanningLiveEvent + +from .protocol.streams import ( + decode_legacy_pointcloud, + decode_legacy_pose, + decode_lio_pcl, + decode_lio_pose, +) + + +def decode_planning_event(identity, source_id, modality, payload): + """One decoder for committed live ingress and receipt-paced archive replay.""" + if modality == "pose": + frame = ( + decode_legacy_pose(payload) if source_id == "RealtimePath" else decode_lio_pose(payload) + ) + return PlanningLiveEvent( + **identity, + kind="pose", + position=frame.position_xyz, + orientation_xyzw=frame.orientation_xyzw, + ) + if modality == "lidar": + if source_id == "RealtimePointcloud": + frame = decode_legacy_pointcloud(payload, max_points=100_000) + points = np.array([[p.x, p.y, p.z] for p in frame.points], dtype=float).reshape(-1, 3) + else: + frame = decode_lio_pcl(payload) + # Published points already occupy the K1 map frame. + points = np.array( + [p.scaled_xyz(frame.header.scaler) for p in frame.points], dtype=float + ).reshape(-1, 3) + if len(points) > 100_000 or not np.isfinite(points).all(): + raise ValueError("Некорректный кадр облака.") + return PlanningLiveEvent(**identity, kind="points", points=points) + if modality == "control": + return PlanningLiveEvent(**identity, kind=json.loads(payload)["event"]) + return None + + +class K1PlanningLiveSource: + def __init__(self, ingress): + self.ingress = ingress + + def snapshot(self): + return self.ingress.snapshot() + + def open(self, consumer_id): + self.ingress.open_consumer(consumer_id) + + def close(self, consumer_id): + self.ingress.close_consumer(consumer_id) + + def take(self, consumer_id): + event = self.ingress.take_next(consumer_id, timeout=0.25) + if event is None: + return None + identity = dict( + session_id=event.session_id, + generation=event.session_generation, + sequence=event.ingress_sequence, + monotonic_ns=event.received_monotonic_ns, + epoch_ns=event.captured_at_epoch_ns, + ) + decoded = decode_planning_event(identity, event.source_id, event.modality, event.payload) + # Preserve one receipt per turn, including modalities planning ignores. + # None means no receipt: bootstrap may use it to finish a queued prefix. + return decoded if decoded is not None else PlanningLiveEvent(**identity, kind="ignored") diff --git a/src/k1link/device_plugins/xgrids_k1/planning_replay.py b/src/k1link/device_plugins/xgrids_k1/planning_replay.py new file mode 100644 index 0000000..8e5c97e --- /dev/null +++ b/src/k1link/device_plugins/xgrids_k1/planning_replay.py @@ -0,0 +1,39 @@ +"""Read-only planning events with original, mandatory host receipt clocks.""" + +from .planning_live import decode_planning_event +from .viewer.replay import detect_replay_format, iter_replay_messages + + +def iter_planning_events(source, session_id): + if ( + detect_replay_format(source) != "k1mqtt" + or not source.with_name("mqtt.metadata.jsonl").is_file() + ): + raise ValueError("Causal replay requires native receipt metadata.") + previous = -1 + for message in iter_replay_messages(source): + stamp = message.received_monotonic_ns + if stamp is None or stamp < previous: + raise ValueError("Missing or non-monotonic receipt clock.") + previous = stamp + modality = ( + "pose" + if message.topic.endswith("/lio_pose") + else "lidar" + if message.topic.endswith("/lio_pcl") + else None + ) + if modality is None: + continue + yield decode_planning_event( + dict( + session_id=session_id, + generation=1, + sequence=message.sequence, + monotonic_ns=stamp, + epoch_ns=message.received_at_epoch_ns, + ), + message.topic, + modality, + message.payload, + ) diff --git a/src/k1link/device_plugins/xgrids_k1/planning_source.py b/src/k1link/device_plugins/xgrids_k1/planning_source.py new file mode 100644 index 0000000..9f0cab1 --- /dev/null +++ b/src/k1link/device_plugins/xgrids_k1/planning_source.py @@ -0,0 +1,58 @@ +"""Read the complete recorded scanner trajectory; never reapply K1 poses to its map.""" +from __future__ import annotations +import json +import math +import time +from pathlib import Path +from .protocol.streams import decode_lio_pcl, decode_lio_pose +from .viewer.replay import detect_replay_format, iter_replay_messages + + +def export_planning_source(source: Path, destination: Path, *, cancel_event=None, activity_callback=None) -> dict: + poses = [] + distance = 0.0 + first_time = None + point_frames = errors = 0 + started = time.monotonic() + timing = detect_replay_format(source) != 'k1mqtt' or source.with_name('mqtt.metadata.jsonl').is_file() + for ordinal, message in enumerate(iter_replay_messages(source)): + if ordinal % 100 == 0: + if time.monotonic() - started > 90 or (cancel_event is not None and cancel_event.is_set()): + raise ValueError('Превышено время подготовки траектории.') + if activity_callback: + activity_callback() + if ordinal > 2_000_000: + raise ValueError('Запись превышает размер поддерживаемой зоны.') + try: + if message.topic.endswith('/lio_pcl'): + # Prove a spatial payload once. Route extraction does not decode the + # entire cloud a second time; full cloud diagnostics belong to Overview. + if point_frames: + point_frames += 1 + elif decode_lio_pcl(message.payload).points: + point_frames = 1 + continue + if not message.topic.endswith('/lio_pose'): + continue + frame = decode_lio_pose(message.payload) + except ValueError: + errors += 1 + continue + xyz = list(frame.position_xyz) + if not all(math.isfinite(v) for v in xyz): + raise ValueError('Траектория содержит некорректные координаты.') + if len(poses) >= 100_000: + raise ValueError('Траектория превышает 100 000 положений.') + if poses: + distance += math.dist(poses[-1]['position'], xyz) + timestamp = (message.received_monotonic_ns if message.received_monotonic_ns is not None else message.received_at_epoch_ns) if timing else None + if first_time is None: + first_time = timestamp + poses.append({'index': len(poses), 'message_index': ordinal, 'position': xyz, + 'elapsed_s': (timestamp - first_time) / 1e9 if timestamp is not None else None, + 'distance_m': distance}) + if len(poses) < 2 or not point_frames: + raise ValueError('Для зоны необходимы облако точек и траектория.') + result = {'poses': poses, 'path_m': distance, 'point_frames': point_frames, 'decode_errors': errors} + destination.write_text(json.dumps(result, allow_nan=False)) + return {'pose_count': len(poses)} diff --git a/src/k1link/device_plugins/xgrids_k1/session_overview.py b/src/k1link/device_plugins/xgrids_k1/session_overview.py new file mode 100644 index 0000000..2377c3c --- /dev/null +++ b/src/k1link/device_plugins/xgrids_k1/session_overview.py @@ -0,0 +1,159 @@ +"""Bounded display overview of a K1 archive, independent of live preview drops.""" +from __future__ import annotations + +import math +import threading +from pathlib import Path +from typing import Callable + +import numpy as np +import rerun as rr +from rerun import blueprint as rrb + +from .protocol.streams import decode_lio_pcl, decode_lio_pose +from .viewer.replay import detect_replay_format, iter_replay_messages +from .protocol.normalizer import normalize_k1_message +from k1link.data_plane import DecodedPointCloudView, DecodedPoseView + +MAX_SAMPLE = 180_000 +MAX_SERIES = 200_000 + + +def export_session_overview(source: Path, destination: Path, *, + cancel_event: threading.Event | None = None, + activity_callback: Callable[[], None] | None = None) -> dict: + point_frames = pose_frames = points_total = errors = 0 + samples: list[np.ndarray] = [] + sample_count = 0 + stride = 64 + poses: list[tuple[float, float, float]] = [] + pose_stride = 1 + intervals: list[float] = [] + path_length = 0.0 + first_pose = last_pose = None + last_cloud_time = first_cloud_time = None + timing_available = detect_replay_format(source) != 'k1mqtt' or source.with_name('mqtt.metadata.jsonl').is_file() + sequence_previous: dict[str, int] = {} + sequence_gaps = sequence_backwards = 0 + arrival_backwards = 0 + max_gap = 0.0 + gaps_over_second = 0 + chart: dict[int, tuple[float, float]] = {} + chart_bucket_seconds = 1 + messages = 0 + for message in iter_replay_messages(source): + if cancel_event is not None and cancel_event.is_set(): + raise RuntimeError('overview cancelled') + messages += 1 + if messages % 100 == 0 and activity_callback: + activity_callback() + xyz = pose = None + try: + if message.topic.endswith('/lio_pcl'): + frame = decode_lio_pcl(message.payload) + xyz = np.array(frame.points, dtype=np.float64).reshape(-1, 4)[:, :3] / frame.header.scaler + seq = frame.header.seq + elif message.topic.endswith('/lio_pose'): + frame = decode_lio_pose(message.payload) + pose = frame.position_xyz + seq = frame.header.seq + else: + view = normalize_k1_message(message, processing_started_monotonic_ns=0) + if isinstance(view, DecodedPointCloudView): + xyz = np.asarray(view.positions_xyz, dtype=np.float64).reshape(-1, 3) + elif isinstance(view, DecodedPoseView): + pose = view.position_xyz + else: + continue + seq = None + if seq is not None: + previous = sequence_previous.get(message.topic) + if previous is not None: + sequence_gaps += max(0, seq - previous - 1) + sequence_backwards += int(seq <= previous) + sequence_previous[message.topic] = seq + except (ValueError, OverflowError): + errors += 1 + continue + if xyz is not None: + point_frames += 1 + points_total += len(xyz) + if len(xyz): + samples.append(xyz[(point_frames % min(stride, len(xyz)))::stride].astype(np.float32)) + sample_count += len(samples[-1]) + if sample_count > MAX_SAMPLE: + samples = [np.concatenate(samples)[::2]] + sample_count = len(samples[0]) + stride *= 2 + if timing_available: + t = (message.received_monotonic_ns if message.received_monotonic_ns is not None else message.received_at_epoch_ns) / 1e9 + if first_cloud_time is None: + first_cloud_time = t + if last_cloud_time is not None: + gap = t - last_cloud_time + arrival_backwards += int(gap < 0) + if gap >= 0: + max_gap = max(max_gap, gap) + gaps_over_second += int(gap > 1) + if len(intervals) < MAX_SERIES: + intervals.append(gap) + elapsed = t - first_cloud_time + bucket = int(elapsed // chart_bucket_seconds) + if bucket not in chart or gap > chart[bucket][1]: + chart[bucket] = (elapsed, gap) + if len(chart) > 1600: + chart_bucket_seconds *= 2 + merged: dict[int, tuple[float, float]] = {} + for item in chart.values(): + b = int(item[0] // chart_bucket_seconds) + if b not in merged or item[1] > merged[b][1]: + merged[b] = item + chart = merged + last_cloud_time = t + if pose is not None: + if not all(math.isfinite(v) for v in pose): + errors += 1 + continue + pose_frames += 1 + if first_pose is None: + first_pose = pose + if last_pose is not None: + path_length += math.dist(pose, last_pose) + last_pose = pose + if pose_frames % pose_stride == 0: + poses.append(pose) + if len(poses) > 20_000: + poses = poses[::2] + pose_stride *= 2 + cloud = np.concatenate(samples) if samples else np.empty((0, 3), dtype=np.float32) + recording = rr.RecordingStream('missioncore_session_overview') + recording.save(str(destination)) + recording.log('world', rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True) + if len(cloud): + # Fixed renderer colors encode geometry, not product control states. + height = cloud[:, 2] + low, high = np.quantile(height, [.05, .95]) + normalized = np.clip((height-low) / max(high-low, .01), 0, 1) + colors = np.column_stack([70+100*normalized, 135+80*normalized, 220-90*normalized]).astype(np.uint8) + recording.log('world/cloud', rr.Points3D(cloud, colors=colors, radii=rr.Radius.ui_points(1.5)), static=True) + if len(poses) > 1: + recording.log('world/route', rr.LineStrips3D([poses], colors=[180, 240, 90], radii=rr.Radius.ui_points(2)), static=True) + recording.log('world/endpoints', rr.Points3D([first_pose, last_pose], labels=['Старт', 'Финиш'], colors=[245, 248, 240], radii=rr.Radius.ui_points(5)), static=True) + recording.send_blueprint(rrb.Blueprint(rrb.Spatial3DView(name='Облако и траектория', origin='/world', background=[9, 10, 12, 255]), collapse_panels=True), make_active=True) + recording.flush() + recording.disconnect() + span = last_cloud_time-first_cloud_time if first_cloud_time is not None and last_cloud_time is not None else None + return { + 'point_frames': point_frames, 'pose_frames': pose_frames, 'point_count': points_total, + 'sample_points': len(cloud), 'decode_errors': errors, + 'sequence_gaps': sequence_gaps, 'sequence_nonincreasing': sequence_backwards, + 'path_m': path_length if pose_frames > 1 else None, + 'start_end_m': math.dist(first_pose, last_pose) if pose_frames > 1 else None, + 'stream_seconds': span, 'mean_hz': (point_frames-1)/span if span and span > 0 else None, + 'interval_p95_s': float(np.quantile(intervals, .95)) if intervals else None, + 'interval_statistics_complete': point_frames-1 <= MAX_SERIES, + 'interval_max_s': max_gap if intervals else None, 'gaps_over_second': gaps_over_second if intervals else None, + 'arrival_backwards': arrival_backwards, 'chart': sorted(chart.values()), + 'chart_bucket_seconds': chart_bucket_seconds, + 'spatial_available': bool(len(cloud) or poses), + } diff --git a/src/k1link/local_service_launchd.py b/src/k1link/local_service_launchd.py index 82c44bf..2e59a8f 100644 --- a/src/k1link/local_service_launchd.py +++ b/src/k1link/local_service_launchd.py @@ -40,6 +40,7 @@ class MissionCoreLaunchAgentPlan: desired_program_arguments: tuple[str, ...] local_observatory_worker_enabled: bool desired_payload: bytes + current_process_type: str def to_dict(self) -> dict[str, object]: return { @@ -57,6 +58,8 @@ class MissionCoreLaunchAgentPlan: ), "current_program_arguments": list(self.current_program_arguments), "desired_program_arguments": list(self.desired_program_arguments), + "current_process_type": self.current_process_type, + "desired_process_type": "Interactive", "changes": { "repository_migration": self.current_working_directory != self.desired_working_directory, @@ -72,6 +75,7 @@ class MissionCoreLaunchAgentPlan: "bounded_launchd_exit_timeout_seconds": 20, "keep_alive": True, "process_group_owned": True, + "operator_interactive_resources": True, "local_observatory_worker_enabled": ( self.local_observatory_worker_enabled ), @@ -184,7 +188,11 @@ def plan_mission_core_launch_agent( "KeepAlive": True, "RunAtLoad": True, "AbandonProcessGroup": False, - "ProcessType": "Background", + # This HTTP service owns operator live camera ingestion and bounded + # localization children. Background throttles CPU AND I/O even while + # the browser is active; HTTP does not promote an Adaptive XPC job. + # Interactive is the ordinary application class, not realtime priority. + "ProcessType": "Interactive", "ThrottleInterval": 5, "ExitTimeOut": 20, "StandardOutPath": str(log_path), @@ -202,6 +210,7 @@ def plan_mission_core_launch_agent( desired_program_arguments=desired_program_arguments, local_observatory_worker_enabled=enable_local_observatory_worker, desired_payload=desired_payload, + current_process_type=str(current.get("ProcessType", "Standard")), ) diff --git a/src/k1link/missions/__init__.py b/src/k1link/missions/__init__.py new file mode 100644 index 0000000..1b5dfa2 --- /dev/null +++ b/src/k1link/missions/__init__.py @@ -0,0 +1 @@ +"""Recorded-zone mission drafts. No vehicle execution authority.""" diff --git a/src/k1link/missions/causal_replay.py b/src/k1link/missions/causal_replay.py new file mode 100644 index 0000000..72877ce --- /dev/null +++ b/src/k1link/missions/causal_replay.py @@ -0,0 +1,269 @@ +"""Bounded 1x laboratory replay. Original receipt time controls input visibility.""" + +import hashlib +import json +import time +from concurrent.futures import ThreadPoolExecutor + +import numpy as np + +from k1link.artifacts import utc_now_iso + +from .causal_tracking import TRACKING_POLICY, CausalTracking +from .entry_acquisition import ENTRY_POLICY +from .entry_acquisition_worker import run_entry_acquisition +from .live_buffer import LiveCloudBuffer +from .registration import POLICY, path_hint +from .registration_worker import run_registration + + +def digest(path): + h = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def replay( + events, + reference, + reference_path, + directory, + *, + mode="baseline", + max_seconds=120.0, + max_distance=40.0, + calculate=run_registration, + initialize=run_entry_acquisition, +): + if mode not in {"baseline", "tracking", "acquisition"}: + raise ValueError("Unknown replay mode.") + if not 0 < max_seconds <= 120 or not 0 < max_distance <= 40: + raise ValueError("Replay exceeds functional probe bounds.") + directory.mkdir(parents=True, exist_ok=False) + buffer = LiveCloudBuffer(reference_path) + gate = CausalTracking() + steps, transitions, deliveries = [], [], [] + iterator = iter(events) + first = next(iterator, None) + if first is None: + raise ValueError("Empty replay.") + origin = first.monotonic_ns + started = time.monotonic_ns() + report = dict( + schema_version="missioncore.causal-planning-replay/v1", + mode=mode, + created_at_utc=utc_now_iso(), + started_monotonic_ns=started, + query_origin_monotonic_ns=origin, + pace=1, + policy=POLICY, + tracking_policy=TRACKING_POLICY, + maximum_seconds=max_seconds, + maximum_distance_m=max_distance, + vehicle_control=False, + localization_confirmed=False, + steps=steps, + transitions=transitions, + first_heading_s=None, + first_candidate_s=None, + first_tracking_s=None, + entry_policy=ENTRY_POLICY if mode == "acquisition" else None, + ) + last_fit = -5.0 + last_snapshot = -1.0 + pending = None + future = None + event = first + last_state = None + wall_origin = time.monotonic() + acquisition_attempts = {} + last_acquisition = -float("inf") + + def source_now(): + return origin + time.monotonic_ns() - started + + def observe_state(now): + nonlocal last_state + value = (gate.state, gate.reason, buffer.segment) + if value != last_state: + transitions.append( + dict( + time_s=(now - origin) / 1e9, + state=gate.state, + reason=gate.reason, + segment=buffer.segment, + ) + ) + last_state = value + + def finish_job(now, *, input_active=True): + nonlocal future, pending + if future is None or not future.done(): + return + result = future.result() + future = None + sample, info = pending + temporal = ( + gate.accept(result, sample, now, buffer.segment) + if input_active + else dict( + accepted=False, reason="input-ended", age_s=(now - sample["monotonic_ns"]) / 1e9 + ) + ) + info.update( + completed_s=(now - origin) / 1e9, + worker_wall_s=time.monotonic() - info.pop("_start"), + temporal=temporal, + tracking_state=gate.state, + streak=gate.streak, + result={k: v for k, v in result.items() if k != "matched_query_indices"}, + ) + steps.append(info) + if temporal["accepted"] and report["first_candidate_s"] is None: + report["first_candidate_s"] = info["completed_s"] + if gate.state == "tracking" and report["first_tracking_s"] is None: + report["first_tracking_s"] = info["completed_s"] + observe_state(now) + + pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="causal-replay-fit") + try: + while event is not None: + due = (event.monotonic_ns - origin) / 1e9 + if due > max_seconds: + report["end_reason"] = "time-bound" + break + now = source_now() + gate.tick(now, buffer.segment) + finish_job(now) + observe_state(now) + if now < event.monotonic_ns: + time.sleep(min(0.02, (event.monotonic_ns - now) / 1e9)) + continue + if time.monotonic() - wall_origin > max_seconds + 35: + raise ValueError("Replay exceeded bounded wall-clock allowance.") + before = time.monotonic() + buffer.ingest(event) + deliveries.append( + dict( + sequence=event.sequence, + kind=event.kind, + time_s=due, + lateness_s=max(0.0, (now - event.monotonic_ns) / 1e9), + ingest_s=time.monotonic() - before, + ) + ) + gate.tick(source_now(), buffer.segment) + if buffer.distance >= max_distance: + report["end_reason"] = "distance-bound" + break + if event.kind == "points" and due - last_snapshot >= 1: + before = time.monotonic() + sample = buffer.snapshot() + last_snapshot = due + snapshot_s = time.monotonic() - before + if ( + future is None + and due - last_fit >= 5 + and len(sample["points"]) >= 300 + and len(steps) < 24 + ): + try: + hint = path_hint(reference_path, sample["path"]) + except ValueError: + hint = None + if hint is not None: + if report["first_heading_s"] is None: + report["first_heading_s"] = due + seed = "route-entry-and-travel-heading" + acquiring = mode == "acquisition" and gate.matrix is None + if acquiring and ( + acquisition_attempts.get(buffer.segment, 0) + >= ENTRY_POLICY["maximum_attempts_per_segment"] + or due - last_acquisition < ENTRY_POLICY["retry_interval_s"] + ): + event = next(iterator, None) + continue + if mode in {"tracking", "acquisition"} and gate.matrix is not None: + hint = gate.matrix.copy() + seed = "previous-fresh-candidate" + if acquiring: + seed = "bounded-entry-search" + last_fit = due + step_id = len(steps) + 1 + step_dir = directory / f"step-{step_id:03d}" + step_dir.mkdir() + meta = dict( + step=step_id, + requested_s=due, + sample_s=(sample["monotonic_ns"] - origin) / 1e9, + sequence=sample["sequence"], + segment=sample["segment"], + distance_m=sample["distance"], + points=len(sample["points"]), + seed=seed, + snapshot_s=snapshot_s, + source_events=sample["events"], + ) + (step_dir / "source.json").write_text(json.dumps(meta)) + np.save(step_dir / "query-path.npy", sample["path"], allow_pickle=False) + pending = (sample, {**meta, "_start": time.monotonic()}) + if acquiring: + acquisition_attempts[buffer.segment] = ( + acquisition_attempts.get(buffer.segment, 0) + 1 + ) + last_acquisition = due + forward = next( + p - reference_path[0] + for p in reference_path[1:] + if np.linalg.norm((p - reference_path[0])[:2]) >= 3 + ) + future = pool.submit( + initialize, + step_dir, + reference, + sample["points"], + hint, + sample["path"][0], + forward, + ) + else: + future = pool.submit( + calculate, step_dir, reference, sample["points"], hint + ) + event = next(iterator, None) + report.setdefault("end_reason", "input-ended") + report["input_end_s"] = ( + (event.monotonic_ns - origin) / 1e9 + if event is not None + else (source_now() - origin) / 1e9 + ) + gate.clear("input-ended") + observe_state(source_now()) + # Finish numerical evidence, but never let a late result restore live state. + while future is not None: + finish_job(source_now(), input_active=False) + if future is not None: + time.sleep(0.02) + report["state"] = "completed" + except Exception as exc: + report.update(state="error", error=f"{type(exc).__name__}: {exc}") + raise + finally: + pool.shutdown(wait=True, cancel_futures=True) + close = getattr(iterator, "close", None) + if close: + close() + report.update( + finished_at_utc=utc_now_iso(), + elapsed_s=(time.monotonic_ns() - started) / 1e9, + gaps=buffer.gaps, + distance_m=buffer.distance, + ) + (directory / "deliveries.json").write_text(json.dumps(deliveries)) + report["artifacts"] = { + str(p.relative_to(directory)): digest(p) for p in directory.rglob("*") if p.is_file() + } + (directory / "report.json").write_text(json.dumps(report, allow_nan=False)) + return report diff --git a/src/k1link/missions/causal_tracking.py b/src/k1link/missions/causal_tracking.py new file mode 100644 index 0000000..6b2b296 --- /dev/null +++ b/src/k1link/missions/causal_tracking.py @@ -0,0 +1,76 @@ +"""Experimental temporal qualification; never grants vehicle authority.""" + +import numpy as np + +from .registration import angle_deg, rigid, transform + +TRACKING_POLICY = dict( + version="causal-consistency/v1", + consecutive=3, + maximum_position_change_m=0.5, + maximum_rotation_change_deg=5.0, + maximum_age_s=8.0, +) + + +class CausalTracking: + def __init__(self): + self.matrix = None + self.sample_ns = 0 + self.segment = 0 + self.streak = 0 + self.state = "acquiring" + self.reason = "initial" + + def clear(self, reason): + self.matrix = None + self.sample_ns = 0 + self.streak = 0 + self.state = "lost" + self.reason = reason + + def tick(self, now_ns, segment): + if segment != self.segment: + self.clear("receipt-gap") + self.segment = segment + elif ( + self.matrix is not None + and (now_ns - self.sample_ns) / 1e9 > TRACKING_POLICY["maximum_age_s"] + ): + self.clear("stale") + + def accept(self, result, sample, now_ns, segment): + self.tick(now_ns, segment) + age = (now_ns - sample["monotonic_ns"]) / 1e9 + evidence = dict(age_s=age, position_change_m=None, rotation_change_deg=None) + if sample["segment"] != segment: + # An old job must never overwrite new-segment state. + return {**evidence, "accepted": False, "reason": "old-segment"} + if not 0 <= age <= TRACKING_POLICY["maximum_age_s"]: + self.clear("stale-result") + elif result["status"] != "candidate": + self.clear("registration-rejected") + else: + matrix = rigid(result["T_reference_query"]) + if self.matrix is not None: + position = sample["path"][-1:] + delta = float( + np.linalg.norm(transform(position, matrix) - transform(position, self.matrix)) + ) + rotation = angle_deg(matrix[:3, :3] @ self.matrix[:3, :3].T) + evidence.update(position_change_m=delta, rotation_change_deg=rotation) + if ( + delta > TRACKING_POLICY["maximum_position_change_m"] + or rotation > TRACKING_POLICY["maximum_rotation_change_deg"] + ): + self.clear("inconsistent-candidate") + return {**evidence, "accepted": False, "reason": self.reason} + self.matrix = matrix + self.sample_ns = sample["monotonic_ns"] + self.streak += 1 + self.state = ( + "tracking" if self.streak >= TRACKING_POLICY["consecutive"] else "acquiring" + ) + self.reason = "consistent-candidate" + return {**evidence, "accepted": True, "reason": self.reason} + return {**evidence, "accepted": False, "reason": self.reason} diff --git a/src/k1link/missions/drafts.py b/src/k1link/missions/drafts.py new file mode 100644 index 0000000..e3ad323 --- /dev/null +++ b/src/k1link/missions/drafts.py @@ -0,0 +1,98 @@ +"""Server-owned drafts with optimistic revisions and immutable check reports.""" +from __future__ import annotations +import json +import math +import sqlite3 +from uuid import uuid4 +from k1link.artifacts import utc_now_iso + + +class DraftConflict(ValueError): + pass + + +def route_from_source(source: dict, start: int, end: int, direction: str) -> dict: + poses = source['poses'] + if not 0 <= start < end < len(poses) or direction not in {'forward', 'reverse'}: + raise ValueError('Выберите начало и конец маршрута в пределах записи.') + points = poses[start:end + 1] + if direction == 'reverse': + points = list(reversed(points)) + steps = [math.dist(a['position'], b['position']) for a, b in zip(points, points[1:])] + return {'start_index': start, 'end_index': end, 'direction': direction, + 'length_m': sum(steps), 'max_step_m': max(steps, default=0), + 'points': [{'source_index': p['index'], 'position': p['position']} for p in points]} + + +class MissionDrafts: + def __init__(self, root, sources): + root.mkdir(parents=True, exist_ok=True) + self.database = root / 'mission-drafts.sqlite3' + self.sources = sources + with self.connect() as db: + db.executescript('''CREATE TABLE IF NOT EXISTS drafts ( + id TEXT PRIMARY KEY, revision INTEGER NOT NULL, updated TEXT NOT NULL, body TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS checks ( + id TEXT PRIMARY KEY, draft_id TEXT NOT NULL, revision INTEGER NOT NULL, body TEXT NOT NULL);''') + + def connect(self): + return sqlite3.connect(self.database, timeout=10) + + def list(self): + with self.connect() as db: + return [dict(id=id, revision=rev, updated_at_utc=updated, name=name, zone=json.loads(zone), vehicle_id=None) + for id, rev, updated, name, zone in db.execute( + "SELECT id, revision, updated, json_extract(body, '$.name'), json_extract(body, '$.zone') FROM drafts ORDER BY updated DESC")] + + def get(self, id): + with self.connect() as db: + row = db.execute('SELECT revision, updated, body FROM drafts WHERE id=?', (id,)).fetchone() + if row is None: + raise KeyError(id) + return dict(json.loads(row[2]), id=id, revision=row[0], updated_at_utc=row[1]) + + def save(self, request): + source = self.sources.bound(request.session_id, request.generation) + route = route_from_source(source, request.start_index, request.end_index, request.direction) + id = str(request.id or uuid4()) + body = {'schema_version': 'missioncore.mission-draft/v1', 'name': request.name.strip(), + 'vehicle_id': None, 'status': 'draft', 'zone': {key: source[key] for key in + ('session_id', 'label', 'generation', 'frame_id', 'units', 'source_digests')}, 'route': route} + if not body['name']: + raise ValueError('Укажите название черновика.') + now = utc_now_iso() + with self.connect() as db: + db.execute('BEGIN IMMEDIATE') + current = db.execute('SELECT revision FROM drafts WHERE id=?', (id,)).fetchone() + if (current is None and request.revision != 0) or (current is not None and current[0] != request.revision): + raise DraftConflict('Черновик изменён в другом окне. Откройте сохранённую версию.') + revision = request.revision + 1 + db.execute('INSERT INTO drafts VALUES (?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET revision=excluded.revision, updated=excluded.updated, body=excluded.body', + (id, revision, now, json.dumps(body, allow_nan=False))) + return dict(body, id=id, revision=revision, updated_at_utc=now) + + def check(self, id, revision): + draft = self.get(id) + if draft['revision'] != revision: + raise DraftConflict('Черновик изменён. Повторите проверку сохранённой версии.') + source = self.sources.verify(draft['zone']['session_id'], draft['zone']['generation']) + route = route_from_source(source, **{key: draft['route'][key] for key in ('direction',)}, + start=draft['route']['start_index'], end=draft['route']['end_index']) + warnings = [] + if route['length_m'] < .5: + warnings.append('Маршрут короче 0,5 м: для прохода требуется другой участок.') + if route['max_step_m'] > 3: + warnings.append('Между соседними положениями есть разрыв больше 3 м.') + if source['decode_errors']: + warnings.append('В записи есть ошибки чтения кадров.') + report = {'id': str(uuid4()), 'draft_id': id, 'revision': revision, 'created_at_utc': utc_now_iso(), + 'kind': 'recorded-route-check', 'length_m': route['length_m'], 'pose_count': len(route['points']), + 'max_step_m': route['max_step_m'], 'source_verified': True, 'warnings': warnings, + 'localization': 'not_run', 'vehicle_control': False} + with self.connect() as db: + db.execute('BEGIN IMMEDIATE') + current = db.execute('SELECT revision FROM drafts WHERE id=?', (id,)).fetchone() + if current is None or current[0] != revision: + raise DraftConflict('Черновик изменён во время проверки.') + db.execute('INSERT INTO checks VALUES (?, ?, ?, ?)', (report['id'], id, revision, json.dumps(report))) + return report diff --git a/src/k1link/missions/entry_acquisition.py b/src/k1link/missions/entry_acquisition.py new file mode 100644 index 0000000..84d98f5 --- /dev/null +++ b/src/k1link/missions/entry_acquisition.py @@ -0,0 +1,227 @@ +"""Bounded, multi-start entry search; geometric agreement is not vehicle authority.""" + +import math +import time +from itertools import product + +import numpy as np + +from .registration import PreparedReference, angle_deg, cloud, rigid, transform + +ENTRY_POLICY = dict( + version="entry-multistart/v2", + search_order="centre-first/v1", + offsets_m=[-3.0, 0.0, 3.0], + yaw_degrees=[-15.0, 0.0, 15.0], + maximum_entry_radius_m=5.0, + maximum_entry_height_m=1.0, + maximum_entry_rotation_deg=30.0, + cluster_position_m=0.5, + cluster_rotation_deg=5.0, + minimum_support=3, + minimum_translation_seeds=2, + ambiguity_overlap_margin=0.05, + ambiguity_rmse_margin_m=0.03, + deadline_s=25.0, + maximum_attempts_per_segment=2, + retry_interval_s=10.0, +) + + +def entry_seeds(initial, query_entry, reference_forward, *, policy=ENTRY_POLICY): + initial = rigid(initial) + anchor = np.asarray(query_entry, dtype=float).reshape(1, 3) + forward = np.asarray(reference_forward, dtype=float)[:2] + if ( + not np.isfinite(anchor).all() + or not np.isfinite(forward).all() + or np.linalg.norm(forward) < 1e-9 + ): + raise ValueError("Invalid entry geometry.") + forward = forward / np.linalg.norm(forward) + across = np.array([-forward[1], forward[0]]) + target = transform(anchor, initial)[0] + seeds = list( + enumerate(product(policy["offsets_m"], policy["offsets_m"], policy["yaw_degrees"])) + ) + if policy.get("search_order") == "centre-first/v1": + seeds.sort( + key=lambda item: ( + item[1][0] ** 2 + item[1][1] ** 2, + abs((item[1][2] + 180) % 360 - 180), + item[0], + ) + ) + for index, (along, lateral, yaw) in seeds: + a = math.radians(yaw) + rotation = np.array( + [[math.cos(a), -math.sin(a), 0], [math.sin(a), math.cos(a), 0], [0, 0, 1]] + ) + seed = np.eye(4) + seed[:3, :3] = rotation @ initial[:3, :3] + offset = np.r_[along * forward + lateral * across, 0.0] + seed[:3, 3] = target + offset - seed[:3, :3] @ anchor[0] + yield dict(index=index, along_m=along, across_m=lateral, yaw_deg=yaw, matrix=seed) + + +def _distance(first, second, query_entry): + a, b = np.asarray(first), np.asarray(second) + position = float( + np.linalg.norm( + transform(np.asarray(query_entry).reshape(1, 3), a) + - transform(np.asarray(query_entry).reshape(1, 3), b) + ) + ) + return position, angle_deg(a[:3, :3] @ b[:3, :3].T) + + +def choose_entry(attempts, initial, query_entry, *, complete=True, policy=ENTRY_POLICY): + """Pure decision, tested independently on competing repeated-place solutions.""" + eligible = [] + diagnostics = [] + for attempt in attempts: + result = attempt["result"] + item = {k: v for k, v in attempt.items() if k != "result"} + item["result"] = {k: v for k, v in result.items() if k != "matched_query_indices"} + item["entry_admitted"] = False + if result["status"] == "candidate": + matrix = rigid(result["T_reference_query"]) + delta = ( + transform(np.asarray(query_entry).reshape(1, 3), matrix)[0] + - transform(np.asarray(query_entry).reshape(1, 3), initial)[0] + ) + angle = angle_deg(matrix[:3, :3] @ initial[:3, :3].T) + item.update( + entry_xy_m=float(np.linalg.norm(delta[:2])), + entry_z_m=float(abs(delta[2])), + entry_rotation_deg=angle, + ) + if ( + item["entry_xy_m"] <= policy["maximum_entry_radius_m"] + and item["entry_z_m"] <= policy["maximum_entry_height_m"] + and angle <= policy["maximum_entry_rotation_deg"] + ): + eligible.append(attempt) + item["entry_admitted"] = True + diagnostics.append(item) + eligible.sort(key=lambda a: (-a["result"]["overlap"], a["result"]["inlier_rmse_m"], a["index"])) + clusters = [] + for attempt in eligible: + for cluster in clusters: + distances = [ + _distance( + attempt["result"]["T_reference_query"], + x["result"]["T_reference_query"], + query_entry, + ) + for x in cluster + ] + if all( + p <= policy["cluster_position_m"] and r <= policy["cluster_rotation_deg"] + for p, r in distances + ): + cluster.append(attempt) + break + else: + clusters.append([attempt]) + reason = None + expected = len(policy["offsets_m"]) ** 2 * len(policy["yaw_degrees"]) + if not complete or len(attempts) != expected: + reason = "incomplete-search" + elif not clusters: + reason = "no-admissible-entry" + else: + best = clusters[0][0]["result"] + if any( + c[0]["result"]["overlap"] >= best["overlap"] - policy["ambiguity_overlap_margin"] + and c[0]["result"]["inlier_rmse_m"] + <= best["inlier_rmse_m"] + policy["ambiguity_rmse_margin_m"] + for c in clusters[1:] + ): + reason = "ambiguous-entry" + elif ( + len(clusters[0]) < policy["minimum_support"] + or len({(x["along_m"], x["across_m"]) for x in clusters[0]}) + < policy["minimum_translation_seeds"] + ): + reason = "insufficient-multistart-support" + if clusters: + selected = dict(clusters[0][0]["result"]) + else: + selected = dict( + status="rejected", + T_reference_query=initial.tolist(), + initial_T_reference_query=initial.tolist(), + overlap=0.0, + inlier_rmse_m=None, + matched_query_indices=[], + localization_confirmed=False, + vehicle_control=False, + ) + selected.update( + status="rejected" if reason else "candidate", reasons=[reason] if reason else [] + ) + if reason: + selected["matched_query_indices"] = [] + selected["initialization"] = dict( + policy=policy, + complete=complete, + reason=reason, + attempts=diagnostics, + selected_index=clusters[0][0]["index"] if clusters else None, + clusters=[ + dict( + indices=[a["index"] for a in c], + support=len(c), + overlap=c[0]["result"]["overlap"], + rmse_m=c[0]["result"]["inlier_rmse_m"], + ) + for c in clusters + ], + ) + selected["registration_seconds"] = sum( + a["result"].get("registration_seconds", 0.0) for a in attempts + ) + return selected + + +def acquire_entry( + reference, + query, + initial, + query_entry, + reference_forward, + *, + fitter=None, + clock=time.monotonic, + policy=ENTRY_POLICY, +): + reference, query, initial = cloud(reference), cloud(query), rigid(initial) + started = clock() + cpu_started = time.process_time() + if fitter is None: + prepared = PreparedReference(reference) + + def fitter(reference, query, initial): + return prepared.register(query, initial) + + attempts = [] + for seed in entry_seeds(initial, query_entry, reference_forward, policy=policy): + if clock() - started >= policy["deadline_s"]: + break + matrix = seed.pop("matrix") + result = fitter(reference, query, matrix) + attempts.append({**seed, "result": result}) + elapsed = clock() - started + result = choose_entry( + attempts, + initial, + query_entry, + complete=elapsed <= policy["deadline_s"], + policy=policy, + ) + result["initialization"]["elapsed_s"] = elapsed + # Diagnostic only: never replace the wall deadline with a CPU budget. + # Background scheduling can slow a child even with no competing scanner I/O. + result["initialization"]["process_cpu_s"] = time.process_time() - cpu_started + return result diff --git a/src/k1link/missions/entry_acquisition_worker.py b/src/k1link/missions/entry_acquisition_worker.py new file mode 100644 index 0000000..3d303df --- /dev/null +++ b/src/k1link/missions/entry_acquisition_worker.py @@ -0,0 +1,77 @@ +"""Single isolated CPU child for one complete bounded initial search.""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +import numpy as np + + +def run_entry_acquisition( + directory, reference, query, initial, query_entry, reference_forward, *, mode="travel" +): + if mode not in {"travel", "stationary"}: + raise ValueError("Unknown acquisition mode.") + source = directory / "registration-input.npz" + destination = directory / "registration-result.json" + np.savez_compressed( + source, + reference=reference, + query=query, + initial=initial, + query_entry=query_entry, + reference_forward=reference_forward, + ) + environment = { + **os.environ, + "OMP_NUM_THREADS": "1", + "OPENBLAS_NUM_THREADS": "1", + "VECLIB_MAXIMUM_THREADS": "1", + } + with (directory / "calculation.log").open("wb") as log: + try: + subprocess.run( + [ + sys.executable, + "-m", + "k1link.missions.entry_acquisition_worker", + str(source), + str(destination), + mode, + ], + env=environment, + stdout=log, + stderr=log, + timeout=30, + check=True, + ) + except subprocess.TimeoutExpired as exc: + raise ValueError( + "Entry acquisition exceeded its deadline; no result accepted." + ) from exc + return json.loads(destination.read_text()) + + +def main(): + from .entry_acquisition import ENTRY_POLICY, acquire_entry + from .stationary_entry import STATIONARY_POLICY + + mode = sys.argv[3] if len(sys.argv) > 3 else "travel" + if mode not in {"travel", "stationary"}: + raise ValueError("Unknown acquisition mode.") + with np.load(Path(sys.argv[1]), allow_pickle=False) as data: + result = acquire_entry( + data["reference"], + data["query"], + data["initial"], + data["query_entry"], + data["reference_forward"], + policy=STATIONARY_POLICY if mode == "stationary" else ENTRY_POLICY, + ) + Path(sys.argv[2]).write_text(json.dumps(result, allow_nan=False)) + + +if __name__ == "__main__": + main() diff --git a/src/k1link/missions/live_buffer.py b/src/k1link/missions/live_buffer.py new file mode 100644 index 0000000..db5cc3f --- /dev/null +++ b/src/k1link/missions/live_buffer.py @@ -0,0 +1,95 @@ +"""Bounded causal map-frame accumulator for one independent walk.""" + +import math +from collections import deque + +import numpy as np + +from .registration import path_hint + +class PoseDiscontinuity(ValueError): + """A new coordinate segment requires fresh localisation, not capture shutdown.""" + +class LiveCloudBuffer: + def __init__(self, reference_path, *, point_radius_m=20.0): + if not math.isfinite(point_radius_m) or point_radius_m <= 0: + raise ValueError("Радиус облака для совмещения должен быть конечным и положительным.") + self.reference_path = np.asarray(reference_path) + # This is an explicit calculation profile, not a limit on the walk or + # on the scanner. Presentation has its own independent 80-m envelope. + self.point_radius_m = float(point_radius_m) + self.path = [] + self.distance = 0. + self.pose_ns = 0 + self.sample_ns = 0 + self.sequence = 0 + self.chunks = deque(maxlen=40) + self.events = deque(maxlen=40) + self.segment = 0 + self.gaps = [] + + def ingest(self, event): + if event.kind == 'pose': + p = np.asarray(event.position, dtype=float) + if p.shape != (3,) or not np.isfinite(p).all(): + raise ValueError('Некорректное положение сканера.') + if event.monotonic_ns <= self.pose_ns: return + if self.path: + step = float(np.linalg.norm(p - self.path[-1])) + elapsed = (event.monotonic_ns - self.pose_ns) / 1e9 + if step > max(3., min(elapsed, 30.) * 3.): + raise PoseDiscontinuity('Разрыв координат сканера. Требуется новая привязка; запись продолжается.') + if elapsed > 2: + # A receipt gap is not an instantaneous coordinate jump. + # Never fit clouds across a gap or retain its old green result. + self.segment += 1 + self.gaps.append({'seconds': elapsed, 'displacement_m': step, + 'sequence': event.sequence}) + self.chunks.clear(); self.events.clear(); self.sample_ns = 0 + if step < .05: + self.pose_ns = event.monotonic_ns + return + self.distance += step + if len(self.path) >= 2000: + # Keep the first and latest pose; display history may decimate, + # but distance and jump checks must never use an old frozen tail. + self.path = self.path[::2] + [self.path[-1]] + self.path.append(p) + self.pose_ns = event.monotonic_ns + elif event.kind == 'points' and self.path: + if not 0 <= event.monotonic_ns - self.pose_ns <= 500_000_000: return + if event.monotonic_ns - self.sample_ns < 500_000_000: return + points = np.asarray(event.points) + d = points - self.path[-1] + keep = ( + (np.linalg.norm(d, axis=1) <= self.point_radius_m) + & (d[:,2] >= -3) + & (d[:,2] <= 6) + ) + points = points[keep] + _, ix = np.unique(np.floor(points/.25).astype(np.int64), axis=0, return_index=True) + points = points[np.sort(ix)] + # Fixed preview budget; raw capture is retained by the existing recorder. + if len(points) > 4000: points = points[np.linspace(0,len(points)-1,4000,dtype=int)] + self.chunks.append(points) + self.sample_ns = event.monotonic_ns + self.sequence = event.sequence + self.events.append({'sequence': event.sequence, 'monotonic_ns': event.monotonic_ns, 'epoch_ns': event.epoch_ns}) + + def snapshot(self): + points = np.concatenate(self.chunks) if self.chunks else np.empty((0,3)) + if len(points): + _, ix = np.unique(np.floor(points/.25).astype(np.int64), axis=0, return_index=True) + points = points[np.sort(ix)] + if len(points) > 40_000: points = points[np.linspace(0,len(points)-1,40_000,dtype=int)] + path = np.asarray(self.path).reshape(-1,3) + hint = None + if len(path): + hint = np.eye(4) + hint[:3,3] = self.reference_path[0] - path[0] + try: hint = path_hint(self.reference_path, path) + except ValueError: pass + return dict(points=points, path=path, hint=hint, distance=self.distance, + point_radius_m=self.point_radius_m, + sequence=self.sequence, monotonic_ns=self.sample_ns, events=list(self.events), + segment=self.segment, gaps=self.gaps[-30:]) diff --git a/src/k1link/missions/live_display_buffer.py b/src/k1link/missions/live_display_buffer.py new file mode 100644 index 0000000..886a8ab --- /dev/null +++ b/src/k1link/missions/live_display_buffer.py @@ -0,0 +1,140 @@ +"""Presentation-only receipt buffer. Never supplies an input to registration. + +The newest scanner receipt stays separately addressable at native source cadence. +Older half-second chunks freeze into bounded history, so the renderer never has +to replay a whole map to show a live point-cloud head. +""" + +from collections import OrderedDict +from uuid import uuid4 + +import numpy as np + +from .observation_profiles import SCENE_INPUT + +DISPLAY_POLICY = dict( + version="planning-fast-display/v2", + scene=SCENE_INPUT, + poll_s=0.1, + chunk_s=0.5, + slots=40, + chunk_points=2000, + total_points=40_000, + voxel_m=0.25, +) + + +def bounded_points(points): + if not len(points): + return np.empty((0, 3), dtype=np.float32) + _, indices = np.unique(np.floor(points / DISPLAY_POLICY["voxel_m"]), axis=0, return_index=True) + points = points[np.sort(indices)] + if len(points) > DISPLAY_POLICY["chunk_points"]: + points = points[np.linspace(0, len(points) - 1, DISPLAY_POLICY["chunk_points"], dtype=int)] + return np.asarray(points, dtype=np.float32) + + +class LiveDisplayBuffer: + def __init__(self): + self.epoch = str(uuid4()) + self.revision = 0 + self.segment = None + self.bucket = None + self.slot = -1 + self.chunks = OrderedDict() + self.versions = [0] * DISPLAY_POLICY["slots"] + self.active_points = np.empty((0, 3), dtype=np.float32) + self.current_points = np.empty((0, 3), dtype=np.float32) + self.pose = None + self.path = np.empty((0, 3)) + self.packet = None + self.frames = 0 + + def ingest(self, event, buffer): + # The numerical buffer has already validated pose continuity/identity. + if event.kind == "pose": + if event.monotonic_ns != buffer.pose_ns: + return + self.pose = dict( + sequence=event.sequence, + monotonic_ns=event.monotonic_ns, + position=list(event.position), + ) + self.path = np.asarray(buffer.path).reshape(-1, 3) + return + if event.kind != "points" or self.pose is None: + return + if not 0 <= event.monotonic_ns - self.pose["monotonic_ns"] <= 500_000_000: + return + if self.packet and event.monotonic_ns <= self.packet["monotonic_ns"]: + return + points = np.asarray(event.points) + delta = points - self.pose["position"] + points = points[ + np.isfinite(points).all(axis=1) + & (np.linalg.norm(delta, axis=1) <= SCENE_INPUT["radius_m"]) + ] + points = bounded_points(points) + if not len(points): + return + self.revision += 1 + if buffer.segment != self.segment: + self.segment = buffer.segment + self.chunks.clear() + self.versions = [self.revision] * DISPLAY_POLICY["slots"] + self.bucket = None + self.slot = -1 + self.active_points = np.empty((0, 3), dtype=np.float32) + bucket = event.monotonic_ns // int(DISPLAY_POLICY["chunk_s"] * 1_000_000_000) + if bucket != self.bucket: + if self.bucket is not None and len(self.active_points): + self.slot = (self.slot + 1) % DISPLAY_POLICY["slots"] + self.chunks.pop(self.slot, None) + self.chunks[self.slot] = self.active_points + self.versions[self.slot] = self.revision + self.bucket = bucket + self.active_points = points + else: + self.active_points = bounded_points(np.concatenate((self.active_points, points))) + self.current_points = points + while ( + sum(len(p) for p in self.chunks.values()) + len(self.active_points) + > DISPLAY_POLICY["total_points"] + ): + removed, _ = self.chunks.popitem(last=False) + self.versions[removed] = self.revision + self.frames += 1 + self.packet = dict( + sequence=event.sequence, + monotonic_ns=event.monotonic_ns, + segment=buffer.segment, + events=[], + ) + + def snapshot(self): + if self.packet is None: + return None + history = list(self.chunks.values()) + points = np.concatenate((*history, self.active_points)) if history else self.active_points + return dict( + **self.packet, + points=points, + current_points=self.current_points, + path=self.path, + cloud_revision=self.revision, + # Include tombstones: even a slow reader must remove evicted slots. + chunks=tuple( + (slot, revision, self.chunks.get(slot)) + for slot, revision in enumerate(self.versions) + ), + pose=self.pose, + ) + + def diagnostics(self): + return dict( + policy=DISPLAY_POLICY, + frames=self.frames, + revision=self.revision, + chunks=len(self.chunks), + points=sum(len(p) for p in self.chunks.values()) + len(self.active_points), + ) diff --git a/src/k1link/missions/live_limits.py b/src/k1link/missions/live_limits.py new file mode 100644 index 0000000..749e16e --- /dev/null +++ b/src/k1link/missions/live_limits.py @@ -0,0 +1,21 @@ +"""One admission and termination policy for a selected live route.""" + +import math + +LIVE_ROUTE_POLICY = dict( + version="selected-live-route/v1", + minimum_m=3.0, + maximum_m=None, + maximum_seconds=None, +) + + +def live_route_limits(length_m): + length = float(length_m) + if not math.isfinite(length) or length < LIVE_ROUTE_POLICY["minimum_m"]: + raise ValueError("Для привязки выберите участок длиной не менее 3 м.") + return dict( + route_policy=LIVE_ROUTE_POLICY.copy(), + maximum_distance_m=length, + maximum_seconds=None, + ) diff --git a/src/k1link/missions/live_presentation.py b/src/k1link/missions/live_presentation.py new file mode 100644 index 0000000..37ac2e0 --- /dev/null +++ b/src/k1link/missions/live_presentation.py @@ -0,0 +1,101 @@ +"""Accepted alignment and latest display data; never travel-heading fallback.""" + +import hashlib +import json + +import numpy as np + +from .registration import rigid + +PRESENTATION_POLICY = dict(version="accepted-alignment-view/v1", cloud_age_s=2.0, snapshot_s=0.5) + + +class LivePresentation: + def __init__(self): + self.sample = None + self.result = None + self.source_sequence = None + + def accept(self, result, sample): + # Correspondence indices belong to the fitted window, not a newer cloud. + self.result = {**result, "matched_query_indices": []} + self.sample = sample + self.source_sequence = sample["sequence"] + + def advance(self, sample, accepted, now_ns): + if ( + self.result is not None + and accepted is not None + and sample["segment"] == accepted["segment"] + and sample["monotonic_ns"] >= accepted["monotonic_ns"] + and 0 <= now_ns - sample["monotonic_ns"] <= 2_000_000_000 + and 0 <= now_ns - accepted["monotonic_ns"] <= 8_000_000_000 + ): + self.sample = sample + + def save(self, directory): + if self.sample is None or self.result is None: + return None + np.savez_compressed( + directory / "aligned-preview.npz", + points=self.sample["points"], + path=self.sample["path"], + transform=rigid(self.result["T_reference_query"]), + ) + return dict( + policy=PRESENTATION_POLICY, + alignment_source_sequence=self.source_sequence, + sample_sequence=self.sample["sequence"], + segment=self.sample["segment"], + sample_monotonic_ns=self.sample["monotonic_ns"], + historical=True, + ) + + +def stored_alignment(directory, doc): + """Hash-verified historical projection, including old runs without new artifacts. + + New runs freeze the last aligned display. Older runs expose their last + temporally accepted fit window. A rejected terminal result is never promoted. + """ + + def verified(name): + payload = (directory / name).read_bytes() + if hashlib.sha256(payload).hexdigest() != doc.get("artifacts", {}).get(name): + raise ValueError("Данные результата не прошли проверку целостности.") + return payload + + if doc.get("presentation") is not None: + verified("aligned-preview.npz") + verified("reference.npy") + with np.load(directory / "aligned-preview.npz", allow_pickle=False) as data: + matrix = rigid(data["transform"]) + return ( + np.load(directory / "reference.npy", allow_pickle=False), + dict(points=data["points"], path=data["path"]), + dict( + status="candidate", T_reference_query=matrix.tolist(), matched_query_indices=[] + ), + ) + for path in sorted(directory.glob("step-*/source.json"), reverse=True): + prefix = path.parent.name + "/" + source = json.loads(verified(prefix + "source.json")) + decision_name = prefix + "decision.json" + if decision_name in doc.get("artifacts", {}): + decision = json.loads(verified(decision_name)) + if not decision.get("temporal", {}).get("accepted"): + continue + elif source.get("sequence") != doc.get("result_source_sequence"): + continue + result = json.loads(verified(prefix + "registration-result.json")) + if result.get("status") != "candidate": + continue + rigid(result["T_reference_query"]) + verified(prefix + "registration-input.npz") + with np.load(path.parent / "registration-input.npz", allow_pickle=False) as data: + return ( + data["reference"], + dict(points=data["query"], path=np.array(source["query_path"])), + {**result, "matched_query_indices": []}, + ) + return None diff --git a/src/k1link/missions/live_scene.py b/src/k1link/missions/live_scene.py new file mode 100644 index 0000000..9111501 --- /dev/null +++ b/src/k1link/missions/live_scene.py @@ -0,0 +1,172 @@ +"""Incremental Rerun entities for the planning profile, independent of capture.""" + +import numpy as np +import rerun as rr +from rerun import blueprint as rrb + +from .registration import transform +from .registration_colors import query_colors + + +def clip_reference(points, ceiling_m): + if ceiling_m is None: + return points + return points[points[:, 2] <= ceiling_m] + + +def clip_query(points, matrix, ceiling_m): + if ceiling_m is None or not len(points): + return points + transformed = transform(points, matrix) + return points[np.isfinite(transformed).all(axis=1) & (transformed[:, 2] <= ceiling_m)] + + +def log_base(recording, reference, reference_path, options): + size = options.get("point_size", 1.8) + recording.log("world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True) + visible_reference = clip_reference(reference, options.get("ceiling_m")) + recording.log( + "world/reference", + rr.Points3D( + visible_reference if options.get("reference", True) else np.empty((0, 3)), + colors=[140, 140, 140], + radii=rr.Radius.ui_points(size), + ), + static=True, + ) + recording.log( + "world/reference_path", + rr.LineStrips3D( + [reference_path] if options.get("trajectory", True) else [], colors=[185, 185, 185] + ), + static=True, + ) + # The grid is a display entity, not a replacement blueprint. Changing its + # visibility must not replace the native viewer's operator-owned camera. + recording.log( + "world/grid", + rr.LineStrips3D( + grid_lines(reference) if options.get("grid", True) else [], + colors=[128, 128, 128, 60], radii=rr.Radius.ui_points(0.5), + ), + static=True, + ) + + +def grid_lines(reference): + """Reference-bound XY guide, unchanged by clipping or layer visibility.""" + xy = reference[:, :2] if len(reference) else np.array([[-1., -1.], [1., 1.]]) + low, high = xy.min(axis=0) - 80, xy.max(axis=0) + 80 + # Keep guides legible and bounded for kilometre-scale references. This is + # presentation density only, never a source or localization limit. + spacing = max(1., float(10 ** np.ceil(np.log10(max(high - low) / 400)))) + low, high = np.floor(low / spacing) * spacing, np.ceil(high / spacing) * spacing + return [ + [[x, low[1], 0], [x, high[1], 0]] + for x in np.arange(low[0], high[0] + spacing / 2, spacing) + ] + [ + [[low[0], y, 0], [high[0], y, 0]] + for y in np.arange(low[1], high[1] + spacing / 2, spacing) + ] + + +def log_view(recording, reference, options): + """Only initial admission and explicit camera intents may send a blueprint.""" + from k1link.sessions.overview_spatial import _camera_eye + + eye = _camera_eye(reference, options.get("mode", "3d"), 1.5) + recording.send_blueprint( + rrb.Blueprint( + rrb.Spatial3DView( + name="Планирование · эталон и новый проход", + origin="/world", + contents=["/world/**"], + line_grid=rrb.LineGrid3D(visible=False), + eye_controls=rrb.EyeControls3D( + kind=rrb.Eye3DKind.Orbital, + position=eye["position"], + look_target=eye["lookTarget"], + eye_up=eye["eyeUp"], + ), + background=[9, 10, 12, 255], + ), + auto_layout=False, + auto_views=False, + collapse_panels=True, + ) + ) + + +def log_evidence(recording, evidence, size, ceiling_m=None): + fitted, accepted = evidence + indices = np.asarray(accepted.get("matched_query_indices", []), dtype=int) + indices = indices[(indices >= 0) & (indices < len(fitted["points"]))] + if accepted["status"] == "candidate" and len(indices): + points = transform(fitted["points"][indices], np.array(accepted["T_reference_query"])) + if ceiling_m is not None: + points = points[np.isfinite(points).all(axis=1) & (points[:, 2] <= ceiling_m)] + recording.log( + "world/validated_query", + rr.Points3D( + points, + colors=query_colors( + points, {"status": "candidate", "matched_query_indices": np.arange(len(points))} + ), + radii=rr.Radius.ui_points(size), + ), + static=True, + ) + + +def scene_bytes( + run_id, + reference, + reference_path, + sample, + result=None, + *, + base=False, + options=None, + evidence=None, +): + options = options or {} + size = options.get("point_size", 1.8) + recording = rr.RecordingStream("missioncore-planning-live", recording_id=run_id) + sink = recording.binary_stream() + try: + if base: + log_base(recording, reference, reference_path, options) + log_view(recording, reference, options) + recording.log("world/query", rr.Clear(recursive=True), static=True) + recording.log("world/query_path", rr.Clear(recursive=True), static=True) + recording.log("world/validated_query", rr.Clear(recursive=True), static=True) + if ( + sample + and result + and result["status"] == "candidate" + and len(sample["points"]) + and options.get("query", True) + ): + t = np.array(result["T_reference_query"]) + points = clip_query(sample["points"], t, options.get("ceiling_m")) + recording.log( + "world/query", + rr.Points3D( + transform(points, t), + colors=query_colors(points), + radii=rr.Radius.ui_points(size), + ), + static=True, + ) + if len(sample["path"]) > 1 and options.get("trajectory", True): + recording.log( + "world/query_path", + rr.LineStrips3D([transform(sample["path"], t)], colors=[255, 175, 65]), + static=True, + ) + if evidence: + log_evidence(recording, evidence, size, options.get("ceiling_m")) + recording.flush() + return sink.read() + finally: + recording.disconnect() diff --git a/src/k1link/missions/live_scene_delta.py b/src/k1link/missions/live_scene_delta.py new file mode 100644 index 0000000..1bc2ca6 --- /dev/null +++ b/src/k1link/missions/live_scene_delta.py @@ -0,0 +1,144 @@ +"""Stateless latest-only scene deltas. A cursor is not localization authority.""" + +import base64 +import hashlib +import json + +import numpy as np +import rerun as rr + +from .live_scene import clip_query, log_base, log_evidence, log_view +from .registration_colors import query_colors + + +def encode_cursor(value): + return base64.urlsafe_b64encode(json.dumps(value, separators=(",", ":")).encode()).decode() + + +def decode_cursor(value): + try: + decoded = json.loads(base64.urlsafe_b64decode(value)) + return decoded if isinstance(decoded, dict) else {} + except (ValueError, TypeError): + return {} + + +def scene_delta( + run_id, + epoch, + reference, + reference_path, + sample, + result, + evidence, + live, + *, + cursor="", + base=False, + options=None, +): + options = options or {} + previous = decode_cursor(cursor) + visible = bool(sample and result and result["status"] == "candidate") + pose = sample.get("pose") if visible and live else None + current = dict( + epoch=epoch, + cloud=sample.get("cloud_revision", 0) if visible else 0, + alignment=result["T_reference_query"] if visible else None, + pose=pose["sequence"] if pose else None, + evidence=evidence[0]["sequence"] if evidence else None, + live=live, + camera=[options.get("mode", "3d"), options.get("reset", 0)], + options=hashlib.sha256(json.dumps(options, sort_keys=True).encode()).hexdigest()[:16], + ) + full = ( + base + or previous.get("epoch") != epoch + or previous.get("options") != current["options"] + or (previous.get("alignment") is None) != (current["alignment"] is None) + or type(previous.get("cloud")) is not int + or not 0 <= previous.get("cloud", -1) <= current["cloud"] + ) + token = encode_cursor(current) + if not full and previous == current: + return b"", token + recording = rr.RecordingStream("missioncore-planning-live", recording_id=run_id) + sink = recording.binary_stream() + size = options.get("point_size", 1.8) + try: + if previous.get("camera") != current["camera"]: + log_view(recording, reference, options) + if full: + log_base(recording, reference, reference_path, options) + for name in ("query", "query_path", "live", "validated_query"): + recording.log("world/" + name, rr.Clear(recursive=True), static=True) + if visible and options.get("query", True): + matrix = np.asarray(current["alignment"]) + if full or previous.get("alignment") != current["alignment"]: + recording.log( + "world/query", + rr.Transform3D(translation=matrix[:3, 3], mat3x3=matrix[:3, :3]), + static=True, + ) + chunks = sample.get("chunks", ((0, 0, sample["points"]),)) + for slot, revision, points in chunks: + if not full and revision <= previous["cloud"]: + continue + name = f"world/query/cloud/{slot}" + # Empty Points3D replaces every component; no temporal history. + points = np.empty((0, 3)) if points is None else points + points = clip_query(points, matrix, options.get("ceiling_m")) + recording.log( + name, + rr.Points3D( + points, colors=query_colors(points), radii=rr.Radius.ui_points(size) + ), + static=True, + ) + current_points = clip_query( + sample.get("current_points", np.empty((0, 3))), matrix, options.get("ceiling_m") + ) + if live: + # Temporal head follows the scanner receipt sequence. Frozen chunks + # remain the bounded trail; this is the only entity updated per frame. + recording.set_time("planning_source_sequence", sequence=int(sample["sequence"])) + recording.log( + "world/query/live", + rr.Points3D( + current_points, + colors=query_colors(current_points), + radii=rr.Radius.ui_points(size), + ), + ) + if ( + full + or previous.get("pose") != current["pose"] + or previous.get("cloud") != current["cloud"] + ): + recording.log( + "world/query/path", + rr.LineStrips3D( + [sample["path"]] + if options.get("trajectory", True) and len(sample["path"]) > 1 + else [], + colors=[255, 175, 65], + ), + static=True, + ) + recording.log( + "world/query/scanner", + rr.Points3D( + [pose["position"]] if pose else [], + colors=[255, 175, 65], + radii=rr.Radius.ui_points(6), + ), + static=True, + ) + if full or previous.get("evidence") != current["evidence"]: + recording.log("world/validated_query", rr.Clear(recursive=True), static=True) + if evidence and options.get("query", True): + log_evidence(recording, evidence, size, options.get("ceiling_m")) + recording.flush() + return sink.read(), token + finally: + recording.disconnect() diff --git a/src/k1link/missions/live_tests.py b/src/k1link/missions/live_tests.py new file mode 100644 index 0000000..2e00ed5 --- /dev/null +++ b/src/k1link/missions/live_tests.py @@ -0,0 +1,703 @@ +"""One diagnostic planning profile; no device command or vehicle authority.""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from contextlib import ExitStack +from uuid import UUID, uuid4 + +import numpy as np + +from k1link.artifacts import utc_now_iso + +from .causal_tracking import TRACKING_POLICY +from .drafts import DraftConflict +from .live_display_buffer import LiveDisplayBuffer +from .live_limits import live_route_limits +from .live_presentation import PRESENTATION_POLICY, LivePresentation, stored_alignment +from .live_scene import scene_bytes +from .live_scene_delta import scene_delta +from .observation_profiles import SCENE_INPUT +from .planning_browser_presentation import BrowserPresentationTelemetry +from .registration_worker import run_registration +from .route_relocalization import ROUTE_RELOCALIZATION_POLICY +from .route_relocalization_worker import run_route_relocalization +from .stationary_bootstrap import BOOTSTRAP_POLICY +from .stationary_entry import STATIONARY_POLICY +from .stationary_live import run_stationary_live + +TERMINAL = {"completed", "cancelled", "error", "interrupted"} +logger = logging.getLogger(__name__) + + +class PlanningLiveTests: + def __init__(self, drafts, sources, compute_lock): + self.drafts, self.sources, self.compute_lock = drafts, sources, compute_lock + self.root = drafts.database.parent / "live-tests" + self.root.mkdir(exist_ok=True) + self.lock = threading.RLock() + self.run = None + self.sample = None + self.accepted_sample = None + self.reference = None + self.scene_reference = None + self._height_reference = None + self._height_bounds = (None, None) + self.reference_path = None + self.cancel = threading.Event() + self.thread = None + self.revision = 0 + self.last_frame_ns = 0 + self.last_result_ns = 0 + self.source = None + self.presentation = LivePresentation() + self.display = LiveDisplayBuffer() + self.browser_presentation = BrowserPresentationTelemetry() + self.latest_pose = None + self.presentation_error = None + self.reinitialization_requested = False + active = self.root / "active.json" + if active.is_file(): + self.run = json.loads(active.read_text()) + if self.run["state"] not in TERMINAL: + self.run.update( + state="interrupted", + message=( + "Исследование прервано перезапуском сервера. Запись сохранена отдельно." + ), + ) + self.persist() + self.restore_scene() + + def restore_scene(self): + """Restore only hash-bound derived geometry, never restart capture or fitting.""" + directory = self.directory(self.run["id"]) + artifacts = self.run.get("artifacts", {}) + path = directory / "reference.npy" + if path.is_file() and hashlib.sha256(path.read_bytes()).hexdigest() == artifacts.get( + "reference.npy" + ): + self.reference = np.load(path, allow_pickle=False) + self.reference_path = np.array( + [p["position"] for p in self.run["draft"]["route"]["points"]] + ) + path = directory / "preview.npz" + if path.is_file() and hashlib.sha256(path.read_bytes()).hexdigest() == artifacts.get( + "preview.npz" + ): + with np.load(path, allow_pickle=False) as data: + self.sample = {k: data[k] for k in ("points", "path", "hint")} + try: + frozen = stored_alignment(directory, self.run) if artifacts else None + except (ValueError, OSError) as exc: + self.presentation_error = str(exc) + frozen = None + if frozen is not None: + self.reference, self.presentation.sample, self.presentation.result = frozen + scene_path = directory / "scene-reference.npy" + if scene_path.is_file() and hashlib.sha256( + scene_path.read_bytes() + ).hexdigest() == artifacts.get("scene-reference.npy"): + self.scene_reference = np.load(scene_path, allow_pickle=False) + elif self.reference is not None and hasattr(self.drafts.sources, "scene_reference_map"): + # A separate presentation derivative; historical numerical evidence + # and its hashes are never rewritten to improve the viewer. + route, zone = self.run["draft"]["route"], self.run["draft"]["zone"] + try: + self.scene_reference, _ = self.drafts.sources.scene_reference_map( + zone["session_id"], zone["generation"], route["start_index"], route["end_index"] + ) + except (ValueError, OSError): + logger.exception("Could not prepare historical presentation geometry") + + def directory(self, run_id): + return self.root / str(UUID(run_id)) + + def history(self): + paths = sorted( + self.root.glob("*/report.json"), key=lambda p: p.stat().st_mtime, reverse=True + )[:100] + items = [] + for path in paths: + doc = json.loads(path.read_text()) + if doc.get("query_session_id") or doc["state"] not in TERMINAL: + items.append( + {k: doc.get(k) for k in ("id", "state", "created_at_utc", "query_session_id")} + | {"name": doc["draft"]["name"]} + ) + return items + + def select(self, run_id): + with self.lock: + if self.run and self.run["id"] == run_id: + return self.get() + if self.thread and self.thread.is_alive(): + raise ValueError("Сначала завершите текущее исследование.") + path = self.directory(run_id) / "report.json" + if not path.is_file(): + raise KeyError(run_id) + doc = json.loads(path.read_text()) + if doc["state"] not in TERMINAL: + raise ValueError("Незавершённое исследование нельзя восстановить как живое.") + self.run = doc + self.reference = self.scene_reference = self.sample = self.accepted_sample = ( + self.source + ) = None + self.presentation = LivePresentation() + self.display = LiveDisplayBuffer() + self.browser_presentation = BrowserPresentationTelemetry() + self.latest_pose = None + self.presentation_error = None + self.last_frame_ns = self.last_result_ns = 0 + self.restore_scene() + self.revision += 1 + target = self.root / "active.json" + tmp = target.with_suffix(".tmp") + tmp.write_text(json.dumps(doc, allow_nan=False)) + os.replace(tmp, target) + return self.get() + + def persist(self): + doc = self.run + if doc is None: + return + directory = self.directory(doc["id"]) + directory.mkdir(exist_ok=True) + payload = json.dumps(doc, allow_nan=False) + for path in [directory / "report.json", self.root / "active.json"]: + tmp = path.with_suffix(".tmp") + tmp.write_text(payload) + os.replace(tmp, path) + + def update(self, **values): + with self.lock: + self.run.update(values) + self.revision += 1 + self.persist() + + def get(self): + with self.lock: + if self.run is None: + return None + result = json.loads(json.dumps(self.run)) + now = time.monotonic_ns() + result["frame_age_s"] = (now - self.last_frame_ns) / 1e9 if self.last_frame_ns else None + result["result_age_s"] = ( + (now - self.last_result_ns) / 1e9 if self.last_result_ns else None + ) + result["stale"] = not self.last_frame_ns or now - self.last_frame_ns > 8_000_000_000 + result["scene_available"] = self.reference is not None + result["scene_height_min_m"], result["scene_height_max_m"] = self.scene_height_bounds() + result["scene_revision"] = self.revision + result["presentation_state"] = ( + "live" + if self._view_live(now) + else "historical" + if self.presentation.result + else "unlocalized" + ) + result["pose_age_s"] = ( + (now - self.latest_pose["monotonic_ns"]) / 1e9 if self.latest_pose else None + ) + result["scanner_pose"] = self.latest_pose + if self.display.packet is not None or "display" not in result: + result["display"] = self.display.diagnostics() + result["browser_presentation"] = self.browser_presentation.diagnostics() + return result + + def scene_height_bounds(self): + # Reference arrays are frozen and replaced as a unit. Do not rescan + # and copy a route-wide map under the consumer lock on every UI poll. + reference = self.scene_reference if self.scene_reference is not None else self.reference + if reference is not self._height_reference: + finite = reference[np.isfinite(reference).all(axis=1)] if reference is not None else [] + self._height_bounds = ( + (float(finite[:, 2].min()), SCENE_INPUT["ceiling_m"]) + if len(finite) else (None, None) + ) + self._height_reference = reference + return self._height_bounds + + def _view_live(self, now_ns): + snapshot = self.source.snapshot() if self.source else {} + sample = self.presentation.sample + return bool( + self.run["state"] == "running" + and snapshot.get("active") + and not snapshot.get("spatial_stop_requested", False) + and (snapshot.get("session_id"), snapshot.get("session_generation")) + == (self.run["query_session_id"], self.run["query_generation"]) + and self.accepted_sample + and sample + and sample["segment"] == self.accepted_sample["segment"] + and 0 <= now_ns - sample["monotonic_ns"] <= 2_000_000_000 + and 0 <= now_ns - self.accepted_sample["monotonic_ns"] <= 8_000_000_000 + ) + + def observe_pose(self, event): + # Scanner-frame telemetry only: not a chassis pose or control contract. + with self.lock: + self.latest_pose = dict( + session_id=event.session_id, + generation=event.generation, + sequence=event.sequence, + monotonic_ns=event.monotonic_ns, + epoch_ns=event.epoch_ns, + position=list(event.position), + orientation_xyzw=list(event.orientation_xyzw) + if event.orientation_xyzw is not None + else None, + frame_id="session/" + event.session_id, + ) + + def update_sample(self, sample, now_ns): + with self.lock: + self.sample, self.last_frame_ns = sample, sample["monotonic_ns"] + if self.display.packet is None: + self.presentation.advance(sample, self.accepted_sample, now_ns) + + def observe_display(self, event, buffer, now_ns): + with self.lock: + # Identity and continuity are checked before this presentation tap. + if ( + not self.run + or (event.session_id, event.generation) + != ( + self.run.get("query_session_id"), + self.run.get("query_generation"), + ) + or self.run.get("state") != "running" + ): + return + self.display.ingest(event, buffer) + latest = self.display.snapshot() + if latest is not None: + self.presentation.advance(latest, self.accepted_sample, now_ns) + + def start(self, draft_id, revision): + with self.lock: + if self.thread and self.thread.is_alive(): + raise ValueError("Предыдущее исследование ещё выполняется.") + draft = self.drafts.get(draft_id) + if draft["revision"] != revision: + raise DraftConflict("Черновик изменён. Откройте сохранённую версию.") + limits = live_route_limits(draft["route"]["length_m"]) + detail = self.drafts.sources.store.get_session(draft["zone"]["session_id"]) + source = self.sources.get(detail.plugin_id) + if source is None: + raise ValueError("Для этой модели нет профиля исследования в реальном времени.") + initial = source.snapshot() + if initial["active"]: + raise ValueError( + "Сначала завершите текущую запись. " + "Тест требует нового проекта и отдельного прохода." + ) + run_id = str(uuid4()) + try: + # Own rollback until a worker actually starts. In particular, + # persistence and thread creation must not strand either lease. + with ExitStack() as startup: + if not self.compute_lock.acquire(blocking=False): + raise ValueError("Другой расчёт совмещения ещё выполняется.") + startup.callback(self.compute_lock.release) + try: + source.open("planning-" + run_id) + except RuntimeError as exc: + raise ValueError( + "Поток занят другим исследованием. " + "Завершите его перед выбором профиля планирования." + ) from exc + startup.callback(source.close, "planning-" + run_id) + self.cancel = threading.Event() + self.source = source + self.reference = self.reference_path = self.sample = self.accepted_sample = None + self.scene_reference = None + self.presentation = LivePresentation() + self.display = LiveDisplayBuffer() + self.browser_presentation = BrowserPresentationTelemetry() + self.latest_pose = None + self.presentation_error = None + self.reinitialization_requested = False + self.last_frame_ns = self.last_result_ns = 0 + self.revision += 1 + self.run = dict( + schema_version="missioncore.planning-live-test/v1", + id=run_id, + profile="planning", + draft=draft, + plugin_id=detail.plugin_id, + state="preparing", + created_at_utc=utc_now_iso(), + query_session_id=None, + query_generation=None, + result=None, + distance_m=0.0, + baseline_generation=initial["session_generation"], + baseline_session_id=initial["session_id"], + ingress_before=initial.get("queues", {}), + localization_confirmed=False, + vehicle_control=False, + slam_reset_verified=False, + hint=BOOTSTRAP_POLICY["version"], + entry_policy=STATIONARY_POLICY, + bootstrap_policy=BOOTSTRAP_POLICY, + route_relocalization_policy=ROUTE_RELOCALIZATION_POLICY, + planning_phase="preparing", + tracking_policy=TRACKING_POLICY, + tracking_state="acquiring", + tracking_established=False, + initialization_attempt=1, + reinitialization_count=0, + **limits, + update_interval_seconds=5, + presentation_policy=PRESENTATION_POLICY, + browser_presentation=self.browser_presentation.diagnostics(), + maximum_query_points=40_000, + message="Подготовка эталонного участка", + ) + self.persist() + self.thread = threading.Thread( + target=self.work, args=(source, run_id), name="planning-live", daemon=True + ) + response = self.get() + self.thread.start() + startup.pop_all() # The worker now owns both resources. + return response + except Exception as exc: + if self.run is not None and self.run["id"] == run_id: + self.cancel.set() + self.thread = self.source = None + self.record_failure(exc) + raise + + def record_failure(self, exc): + """Revoke live authority even when the report store itself is failing.""" + with self.lock: + self.accepted_sample = None + self.last_result_ns = 0 + self.run.update( + state="error", + planning_phase="ended", + tracking_state="lost", + planning_reason="execution-error", + tracking_reason="execution-error", + termination_reason="execution-error", + finished_at_utc=utc_now_iso(), + message=str(exc) + if isinstance(exc, ValueError) + else "Исследование не завершено. Исходная запись сохраняется отдельно.", + ) + self.revision += 1 + try: + self.persist() + (self.directory(self.run["id"]) / "failure.txt").write_text( + f"{type(exc).__name__}: {exc}" + ) + except Exception: + # A second storage error must not prevent ownership cleanup or + # leave the in-memory run looking alive. Keep it observable. + logger.exception("Could not persist failed planning run %s", self.run["id"]) + + def stop(self, run_id): + with self.lock: + if self.run is None or self.run["id"] != run_id: + raise KeyError(run_id) + self.cancel.set() + return self.get() + + def request_reinitialization(self, run_id): + """Start a new stationary location attempt without touching capture. + + This is intentionally available only after an unconfirmed route-search + failure. It cannot replace an in-flight computation, a tracked pose, + or the scanner's own stop/start authority. + """ + with self.lock: + if self.run is None or self.run["id"] != run_id: + raise KeyError(run_id) + if self.run["state"] != "running": + raise ValueError("Переинициализация доступна только пока идёт текущая запись.") + if self.reinitialization_requested: + raise ValueError("Переинициализация уже запрошена; дождитесь нового облака.") + if self.run.get("planning_phase") != "lost" or self.run.get("tracking_established"): + raise ValueError( + "Переинициализация доступна после неподтверждённой идентификации маршрута." + ) + attempt = int(self.run.get("initialization_attempt", 1)) + 1 + self.reinitialization_requested = True + self.accepted_sample = None + self.last_result_ns = 0 + self.sample = None + self.presentation = LivePresentation() + self.display = LiveDisplayBuffer() + self.latest_pose = None + self.run.update( + initialization_attempt=attempt, + reinitialization_count=int(self.run.get("reinitialization_count", 0)) + 1, + initialization_result=None, + initialization_temporal=None, + result=None, + temporal=None, + result_source_sequence=None, + result_source_events=None, + result_received_monotonic_ns=None, + planning_phase="waiting-cloud", + planning_reason="operator-reinitialize", + tracking_state="acquiring", + tracking_established=False, + tracking_reason="operator-reinitialize", + message=( + "Переинициализация запрошена. Переместите сканер в новую точку и " + "оставьте неподвижно до начала накопления данных." + ), + ) + self.revision += 1 + self.persist() + return self.get() + + def consume_reinitialization(self, run_id): + """Hand one operator retry to the live worker, exactly once.""" + with self.lock: + if self.run is None or (self.run.get("id") is not None and self.run["id"] != run_id): + raise KeyError(run_id) + if not self.reinitialization_requested: + return None + self.reinitialization_requested = False + return self.run["initialization_attempt"] + + def work(self, source, run_id): + executor = None + try: + executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="planning-fit") + draft = self.run["draft"] + route, zone = draft["route"], draft["zone"] + points, provenance = self.drafts.sources.reference_map( + zone["session_id"], + zone["generation"], + route["start_index"], + route["end_index"], + cancel_event=self.cancel, + ) + if self.cancel.is_set(): + raise InterruptedError("Reference preparation cancelled.") + scene_points, scene_provenance = points, None + if hasattr(self.drafts.sources, "scene_reference_map"): + scene_points, scene_provenance = self.drafts.sources.scene_reference_map( + zone["session_id"], + zone["generation"], + route["start_index"], + route["end_index"], + cancel_event=self.cancel, + ) + with self.lock: + self.reference = points + self.scene_reference = scene_points + self.reference_path = np.array([p["position"] for p in route["points"]]) + directory = self.directory(run_id) + np.save(directory / "reference.npy", points, allow_pickle=False) + np.save(directory / "scene-reference.npy", scene_points, allow_pickle=False) + self.update( + state="waiting", + reference=provenance, + scene_reference=scene_provenance, + planning_phase="waiting-cloud", + message=( + "Эталон готов. Сначала выполняется точная привязка у стартовой зоны; " + "при честном отказе включается поиск по выбранному маршруту. " + "После запуска требуется ожидание на месте." + ), + ) + run_stationary_live( + self, source, run_id, executor, time, run_route_relocalization, run_registration + ) + except InterruptedError: + self.update( + state="cancelled", + planning_phase="ended", + tracking_state="lost", + termination_reason="cancelled", + finished_at_utc=utc_now_iso(), + message="Подготовка отменена. Исходные записи сохранены.", + ) + except Exception as exc: + self.record_failure(exc) + finally: + try: + try: + with self.lock: + self.accepted_sample = None + self.last_result_ns = 0 + self.run["tracking_state"] = "lost" + # The bounded route-search child may run longer than a local + # tracking fit; never overlap it with a replacement run. + if executor is not None: + executor.shutdown(wait=True, cancel_futures=True) + finally: + source.close("planning-" + run_id) + if self.sample is not None and self.sample["hint"] is not None: + np.savez_compressed( + self.directory(run_id) / "preview.npz", + **{k: self.sample[k] for k in ("points", "path", "hint")}, + ) + hashes = {} + presentation = self.presentation.save(self.directory(run_id)) + for path in [ + *self.directory(run_id).glob("step-*/*"), + *self.directory(run_id).glob("reference.npy"), + *self.directory(run_id).glob("scene-reference.npy"), + *self.directory(run_id).glob("preview.npz"), + *self.directory(run_id).glob("aligned-preview.npz"), + ]: + hashes[str(path.relative_to(self.directory(run_id)))] = hashlib.sha256( + path.read_bytes() + ).hexdigest() + self.update( + artifacts=hashes, + presentation=presentation, + display=self.display.diagnostics(), + browser_presentation=self.browser_presentation.diagnostics(), + ingress_after=source.snapshot().get("queues", {}), + ) + except Exception as exc: + self.record_failure(exc) + finally: + self.compute_lock.release() + + def commit_result( + self, result, sample, temporal, tracking_state, *, phase, message, tracking_established + ): + with self.lock: + self.accepted_sample = sample if temporal["accepted"] else None + self.last_result_ns = sample["monotonic_ns"] if temporal["accepted"] else 0 + if temporal["accepted"]: + self.presentation.accept(result, sample) + latest = self.display.snapshot() or self.sample + if latest is not None: + self.presentation.advance(latest, sample, time.monotonic_ns()) + # Candidate geometry can be inspected while acquiring. Green requires + # the three-window temporal decision as well as geometric proximity. + self._scene_result = ( + result if tracking_state == "tracking" else {**result, "matched_query_indices": []} + ) + self.update( + result={k: v for k, v in result.items() if k != "matched_query_indices"}, + temporal=temporal, + tracking_state=tracking_state, + result_source_sequence=sample["sequence"], + result_source_events=sample["events"], + result_received_monotonic_ns=sample["monotonic_ns"], + planning_phase=phase, + tracking_established=tracking_established, + message=message, + ) + + def scene(self, run_id, base=False, options=None): + with self.lock: + if self.run is None or self.run["id"] != run_id: + raise KeyError(run_id) + if self.reference is None: + raise ValueError("Эталон ещё не готов.") + if self.presentation_error: + raise ValueError(self.presentation_error) + sample, result = self.presentation.sample, self.presentation.result + evidence = ( + (self.accepted_sample, self._scene_result) + if self._view_live(time.monotonic_ns()) + else None + ) + reference = self.scene_reference if self.scene_reference is not None else self.reference + reference_path = self.reference_path + return scene_bytes( + run_id, + reference, + reference_path, + sample, + result, + base=base, + options=options, + evidence=evidence, + ) + + def scene_update(self, run_id, cursor="", base=False, options=None): + with self.lock: + if self.run is None or self.run["id"] != run_id: + raise KeyError(run_id) + if self.reference is None: + raise ValueError("Эталон ещё не готов.") + if self.presentation_error: + raise ValueError(self.presentation_error) + now = time.monotonic_ns() + live = self._view_live(now) + sample, result = self.presentation.sample, self.presentation.result + evidence = (self.accepted_sample, self._scene_result) if live else None + reference = self.scene_reference if self.scene_reference is not None else self.reference + path, epoch = self.reference_path, self.display.epoch + age = (now - sample["monotonic_ns"]) / 1e9 if sample and live else None + fit_age = (now - self.accepted_sample["monotonic_ns"]) / 1e9 if live else None + cloud_revision = sample.get("cloud_revision") if sample and live else None + cloud_sequence = sample.get("sequence") if sample and live else None + display_epoch = self.display.epoch if sample and live else None + height_min_m, height_max_m = self.scene_height_bounds() + # Immutable snapshots; serialization cannot hold the receipt/fit lock. + payload, next_cursor = scene_delta( + run_id, + epoch, + reference, + path, + sample, + result, + evidence, + live, + cursor=cursor, + base=base, + options=options, + ) + return ( + payload, + next_cursor, + dict( + live=live, + cloud_age_s=age, + fit_age_s=fit_age, + cloud_revision=cloud_revision, + cloud_sequence=cloud_sequence, + display_epoch=display_epoch, + height_min_m=height_min_m, + height_max_m=height_max_m, + ), + ) + + def record_browser_presentation(self, run_id, observations): + """Keep browser timing as review evidence, outside every live decision.""" + with self.lock: + if self.run is None or self.run["id"] != run_id: + raise KeyError(run_id) + current = self.display.snapshot() + if self.run["state"] != "running" or current is None: + return {"accepted": 0, "rejected": 0, "ignored": len(observations)} + accepted = rejected = 0 + for observation in observations: + # The browser may lag; it may not claim a future or another-run + # display packet. This is identity fencing, not trust elevation. + if ( + observation["display_epoch"] != self.display.epoch + or observation["cloud_revision"] > current["cloud_revision"] + or observation["cloud_sequence"] > current["sequence"] + ): + self.browser_presentation.reject() + rejected += 1 + continue + self.browser_presentation.record(observation) + accepted += 1 + return {"accepted": accepted, "rejected": rejected, "ignored": 0} + + def close(self): + self.cancel.set() + if self.thread: + self.thread.join(timeout=125) diff --git a/src/k1link/missions/observation_profiles.py b/src/k1link/missions/observation_profiles.py new file mode 100644 index 0000000..bd9d02e --- /dev/null +++ b/src/k1link/missions/observation_profiles.py @@ -0,0 +1,4 @@ +"""Independent numerical and presentation footprints; neither limits a route.""" + +TRACKING_INPUT = dict(version="local-tracking-input/v1", radius_m=20.0) +SCENE_INPUT = dict(version="planning-scene-input/v2", radius_m=80.0, ceiling_m=80.0) diff --git a/src/k1link/missions/planning_browser_presentation.py b/src/k1link/missions/planning_browser_presentation.py new file mode 100644 index 0000000..f8fbe84 --- /dev/null +++ b/src/k1link/missions/planning_browser_presentation.py @@ -0,0 +1,83 @@ +"""Bounded browser-side presentation observations for planning live scenes. + +The browser can report native Rerun-channel admission and browser animation +frames, but neither signal is a GPU paint receipt. This module deliberately +keeps that distinction in the retained report and has no control authority. +""" + +from __future__ import annotations + +import math +from collections import deque +from typing import Any + + +BROWSER_PRESENTATION_POLICY = { + "version": "missioncore.planning-browser-presentation/v1", + "producer": "browser-rerun-admission-two-animation-frames/v1", + "sample_boundary": "browser-reported after native Rerun channel admission and up to two browser animation-frame opportunities", + "source_to_presentation_measurement": "conservative upper bound: server cloud age plus full browser request plus post-admission animation-frame delay", + "not_proved": [ + "GPU canvas paint receipt", + "physical scanner-to-pixel clock synchronization", + "registration, route-following, navigation, or safety authority", + ], + "maximum_retained_samples": 4096, + "maximum_batch_samples": 8, +} + + +def _quantile(values: list[float], fraction: float) -> float | None: + if not values: + return None + values = sorted(values) + index = min(len(values) - 1, max(0, math.ceil(len(values) * fraction) - 1)) + return values[index] + + +def _summary(samples: list[dict[str, Any]], name: str) -> dict[str, float | int | None]: + values = [float(sample[name]) for sample in samples if sample.get(name) is not None] + if not values: + return {"count": 0, "min": None, "p50": None, "p95": None, "max": None} + return { + "count": len(values), + "min": min(values), + "p50": _quantile(values, 0.5), + "p95": _quantile(values, 0.95), + "max": max(values), + } + + +class BrowserPresentationTelemetry: + """Run-bounded aggregate only; received samples never alter scene state.""" + + def __init__(self): + self.samples: deque[dict[str, Any]] = deque( + maxlen=BROWSER_PRESENTATION_POLICY["maximum_retained_samples"] + ) + self.accepted = 0 + self.rejected = 0 + + def record(self, sample: dict[str, Any]) -> None: + self.samples.append(sample) + self.accepted += 1 + + def reject(self) -> None: + self.rejected += 1 + + def diagnostics(self) -> dict[str, Any]: + samples = list(self.samples) + return { + "policy": BROWSER_PRESENTATION_POLICY, + "reported_sample_count": self.accepted, + "retained_sample_count": len(samples), + "rejected_sample_count": self.rejected, + "frame_timeout_count": sum(bool(sample["frame_timeout"]) for sample in samples), + "request_ms": _summary(samples, "request_ms"), + "rerun_admission_ms": _summary(samples, "rerun_admission_ms"), + "first_animation_frame_ms": _summary(samples, "first_animation_frame_ms"), + "second_animation_frame_ms": _summary(samples, "second_animation_frame_ms"), + "source_to_second_animation_frame_upper_bound_ms": _summary( + samples, "source_to_second_animation_frame_upper_bound_ms" + ), + } diff --git a/src/k1link/missions/projects.py b/src/k1link/missions/projects.py new file mode 100644 index 0000000..5c879cf --- /dev/null +++ b/src/k1link/missions/projects.py @@ -0,0 +1,151 @@ +"""Project catalog over frozen experiments and unstarted drafts. + +Each run keeps its own identity. Browsing never selects a live acquisition, +changes a draft, or reruns registration. +Catalog deletion is a durable tombstone, never destruction of source evidence. +""" +import hashlib +import json +import os +import sqlite3 +import threading +from uuid import UUID + +from k1link.artifacts import utc_now_iso + + +class PlanningProjects: + def __init__(self, runs, live): + self.runs, self.live = runs, live + self.scene_lock = threading.Lock() + self.catalog_database = runs.root / 'catalog-deletions.sqlite3' + + def _deleted(self): + if not self.catalog_database.is_file(): + return set() + with sqlite3.connect(self.catalog_database, timeout=10) as db: + db.execute('CREATE TABLE IF NOT EXISTS deleted_projects ' + '(key TEXT PRIMARY KEY, deleted_at TEXT NOT NULL)') + return {row[0] for row in db.execute('SELECT key FROM deleted_projects')} + + def remove(self, kind, identity, revision): + """Remove exactly one catalog entry; retain reports, drafts and captures.""" + identity = str(UUID(identity)) + if kind not in {'recorded', 'live', 'draft'}: + raise KeyError(identity) + key = f'{kind}:{identity}' + if key in self._deleted(): + return {'key': key, 'deleted': True} + doc = self._document(kind, identity) + if self._summary(kind, doc)['revision'] != revision: + raise ValueError('Проект изменён. Обновите список и повторите удаление.') + allowed = {'recorded': {'ready', 'error'}, + 'live': {'completed', 'cancelled', 'error', 'interrupted'}} + if kind != 'draft' and doc.get('state') not in allowed[kind]: + raise ValueError('Сначала завершите исследование. Выполняющийся проект удалить нельзя.') + if kind == 'draft' and not any(item['key'] == key for item in self.list()): + raise ValueError('Черновик уже связан с исследованием. Обновите список проектов.') + with sqlite3.connect(self.catalog_database, timeout=10) as db: + db.execute('CREATE TABLE IF NOT EXISTS deleted_projects ' + '(key TEXT PRIMARY KEY, deleted_at TEXT NOT NULL)') + db.execute('INSERT OR IGNORE INTO deleted_projects VALUES (?, ?)', (key, utc_now_iso())) + return {'key': key, 'deleted': True} + + def _document(self, kind, identity): + identity = str(UUID(identity)) + if kind == 'recorded': + return self.runs.get(identity) + if kind == 'live' and self.live: + path = self.live.directory(identity) / 'report.json' + if path.is_file(): + return json.loads(path.read_text()) + if kind == 'draft': + return self.runs.drafts.get(identity) + raise KeyError(identity) + + def _summary(self, kind, doc): + draft = doc if kind == 'draft' else doc['draft'] + return dict(key=kind+':'+doc['id'], kind=kind, id=doc['id'], name=draft['name'], + created_at_utc=doc.get('created_at_utc', doc.get('updated_at_utc')), + state=doc.get('state', 'draft'), result_status=(doc.get('result') or {}).get('status'), + reference_label=draft['zone']['label'], + query_label=(doc.get('query') or {}).get('label'), + draft_id=draft['id'], revision=draft['revision']) + + def list(self): + items, used = [], set() + for kind, owner in [('recorded', self.runs), ('live', self.live)]: + if owner is None: + continue + for path in owner.root.glob('*/report.json'): + doc = json.loads(path.read_text()) + # Preparation-only probes have no independent passage evidence. + if kind == 'live' and not doc.get('query_session_id') and doc['state'] in {'completed', 'cancelled', 'error', 'interrupted'}: + continue + items.append(self._summary(kind, doc)) + used.add(doc['draft']['id']) + for draft in self.runs.drafts.list(): + if draft['id'] not in used: + items.append(self._summary('draft', draft)) + deleted = self._deleted() + return sorted((item for item in items if item['key'] not in deleted), + key=lambda item: item['created_at_utc'], reverse=True) + + def get(self, kind, identity): + identity = str(UUID(identity)) + if f'{kind}:{identity}' in self._deleted(): + raise KeyError(identity) + doc = self._document(kind, identity) + summary = self._summary(kind, doc) + result = doc.get('result') + # Keep large correspondence arrays out of the UI report. + result = {k: v for k, v in result.items() if k != 'matched_query_indices'} if result else None + return dict(**summary, draft=doc if kind == 'draft' else doc['draft'], result=result, + message=doc.get('message'), evidence_relation=doc.get('evidence_relation'), + scene_note=('Историческая сцена использует последнюю принятую привязку. ' + 'Показатели относятся к последнему расчёту.' if kind == 'live' else None), + elapsed_seconds=doc.get('elapsed_seconds'), request=doc.get('request'), + reference=doc.get('reference'), query=doc.get('query'), + scene_url=(doc.get('scene_url') if kind == 'recorded' and doc['state'] == 'ready' else + f'/api/v1/mission-planner/projects/live/{identity}/scene.rrd' + if kind == 'live' and result and doc['state'] in {'completed', 'cancelled', 'error', 'interrupted'} else None), + localization_confirmed=False, vehicle_control=False) + + def live_scene(self, identity): + """Present the committed causal fit, never the terminal unregistered preview. + + Derived view cache is separate from immutable inputs/reports. All its + inputs are checked on every open; no fitting or live selection occurs. + """ + import numpy as np + + from .live_presentation import stored_alignment + from .registration_scene import write_scene + doc = self._document('live', identity) + if not doc.get('result') or doc['state'] not in {'completed', 'cancelled', 'error', 'interrupted'}: + raise ValueError('В этом исследовании нет сохранённого результата совмещения.') + directory = self.live.directory(identity) + selected = stored_alignment(directory, doc) + if selected is None: + raise ValueError('В этом исследовании нет принятой привязки для сохранённой сцены.') + reference, sample, result = selected + digest = hashlib.sha256(('accepted-alignment-view/v1'+json.dumps(doc, sort_keys=True)).encode()).hexdigest() + cache = directory / 'views'; cache.mkdir(exist_ok=True) + target = cache / f'{digest}.rrd' + with self.scene_lock: + if not target.is_file(): + temporary = cache / f'{digest}.tmp' + write_scene(temporary, identity, reference, sample['points'], result, + np.array([p['position'] for p in doc['draft']['route']['points']]), + sample['path']) + os.replace(temporary, target) + return target + + def verified_scene(self, identity): + doc = self.runs.get(identity) + if doc['state'] != 'ready': + raise ValueError('Совмещение ещё не завершено.') + path = self.runs.directory(identity) / 'scene.rrd' + if hashlib.sha256(path.read_bytes()).hexdigest() != doc.get('artifacts', {}).get('scene.rrd'): + raise ValueError('Сохранённое облако не прошло проверку целостности.') + return path diff --git a/src/k1link/missions/reference_map.py b/src/k1link/missions/reference_map.py new file mode 100644 index 0000000..dbce851 --- /dev/null +++ b/src/k1link/missions/reference_map.py @@ -0,0 +1,90 @@ +"""Bounded route context, independent of the selected path to follow.""" + +from contextlib import nullcontext + +import numpy as np + +REFERENCE_POLICY = dict( + version="route-context-map/v2", + margin_m=20.0, + tile_length_m=40.0, + voxel_m=0.25, +) + + +def reference_intervals(poses, start, end): + if not 0 <= start < end < len(poses): + raise ValueError("Некорректный интервал эталона.") + distances = np.array([p["distance_m"] for p in poses], dtype=float) + if not np.isfinite(distances).all() or (np.diff(distances) < 0).any(): + raise ValueError("Некорректная дистанция эталонной записи.") + lower = int( + np.searchsorted(distances, distances[start] - REFERENCE_POLICY["margin_m"], side="left") + ) + upper = min( + len(poses) - 1, + int(np.searchsorted(distances, distances[end] + REFERENCE_POLICY["margin_m"], side="right")) + - 1, + ) + tiles = [] + cursor = lower + while cursor < upper: + stop = min( + upper, + int( + np.searchsorted( + distances, distances[cursor] + REFERENCE_POLICY["tile_length_m"], side="right" + ) + ) + - 1, + ) + if stop <= cursor: + raise ValueError("Недостаточная непрерывность эталонной карты.") + tiles.append((cursor, stop)) + cursor = stop + return tiles + + +def build_reference_map( + sources, session_id, generation, start, end, *, cancel_event=None, presentation=False +): + planning = sources.bound(session_id, generation) + tiles = reference_intervals(planning["poses"], start, end) + points, evidence = np.empty((0, 3)), [] + options = {"presentation": True} if presentation else {} + prepared = ( + sources.prepared_submaps(session_id, generation, cancel_event=cancel_event, **options) + if hasattr(sources, "prepared_submaps") + else nullcontext( + lambda first, last: sources.submap(session_id, generation, first, last, **options) + ) + ) + with prepared as extract: + for first, last in tiles: + if cancel_event is not None and cancel_event.is_set(): + raise InterruptedError("Reference preparation cancelled.") + chunk, provenance = extract(first, last) + # Keep one deduplicated map plus one tile, not every overlapping tile. + points = np.concatenate([points, chunk]) + _, indices = np.unique( + np.floor(points / REFERENCE_POLICY["voxel_m"]).astype(np.int64), + axis=0, + return_index=True, + ) + points = points[np.sort(indices)] + evidence.append(provenance) + if cancel_event is not None and cancel_event.is_set(): + raise InterruptedError("Reference preparation cancelled.") + return points, dict( + **{ + k: v + for k, v in evidence[0].items() + if k in {"session_id", "generation", "label", "frame_id", "units", "source_digests"} + }, + policy=REFERENCE_POLICY, + purpose="presentation" if presentation else "registration", + route_interval=[start, end], + map_interval=[tiles[0][0], tiles[-1][1]], + tiles=evidence, + voxel_points=len(points), + ) diff --git a/src/k1link/missions/reference_window.py b/src/k1link/missions/reference_window.py new file mode 100644 index 0000000..78f3b88 --- /dev/null +++ b/src/k1link/missions/reference_window.py @@ -0,0 +1,103 @@ +"""Bound a numerical target without thinning the route-wide reference map.""" + +import hashlib +from itertools import product + +import numpy as np + +from .registration import transform + +WINDOW_POLICY = dict(version="local-reference-window/v2", maximum_points=None, margin_m=10.0) + + +class ReferenceCoverageError(ValueError): + """No usable local target; this is not a terminal recording failure.""" + + +class ReferenceWindowIndex: + """One run-owned spatial index; exact source order and density are retained. + + The reference is immutable for the lifetime of a planning run. Only source + indices are stored, not another point-cloud copy or a reduced map. This + index must never be reused with a replacement reference array. + """ + + def __init__(self, reference, *, cell_m=10.0): + if not np.isfinite(cell_m) or cell_m <= 0: + raise ValueError("Invalid reference index cell size.") + if reference.ndim != 2 or reference.shape[1] != 3 or not np.isfinite(reference).all(): + raise ValueError("Invalid reference index geometry.") + self.reference = reference + self.cell_m = cell_m + cells = np.floor(reference / cell_m).astype(np.int64) + keys, inverse, counts = np.unique(cells, axis=0, return_inverse=True, return_counts=True) + self.order = np.argsort(inverse, kind="stable") + boundaries = np.r_[0, np.cumsum(counts)] + self.slices = { + tuple(key): (int(boundaries[i]), int(boundaries[i + 1])) for i, key in enumerate(keys) + } + + def crop(self, center, radius): + if not np.isfinite(center).all() or not np.isfinite(radius) or radius <= 0: + raise ValueError("Invalid reference window.") + lower = np.floor((center - radius) / self.cell_m).astype(np.int64) + upper = np.floor((center + radius) / self.cell_m).astype(np.int64) + pieces = [] + ranges = tuple(range(int(a), int(b) + 1) for a, b in zip(lower, upper, strict=True)) + # Very broad diagnostic crops should not enumerate empty space. + keys = ( + product(*ranges) + if np.prod([len(r) for r in ranges]) <= len(self.slices) + else ( + key + for key in self.slices + if all(a <= v <= b for v, a, b in zip(key, lower, upper, strict=True)) + ) + ) + for key in keys: + bounds = self.slices.get(key) + if bounds is not None: + pieces.append(self.order[slice(*bounds)]) + indices = ( + np.sort(np.concatenate(pieces), kind="stable") + if pieces + else np.empty(0, dtype=np.int64) + ) + candidates = self.reference[indices] + mask = np.linalg.norm(candidates - center, axis=1) <= radius + selected = candidates[mask] + if len(selected) == len(self.reference): + selected = self.reference + return selected, len(candidates) + + +def reference_window(reference, sample, hint, *, initializing=False, index=None): + anchor = sample["path"][0 if initializing else -1] + center = transform(np.asarray(anchor)[None, :], hint)[0] + # The local observation footprint bounds work spatially, not by treating + # an arbitrary point count as evidence that localisation was lost. + radius = ( + float(np.linalg.norm(sample["points"] - anchor, axis=1).max()) + WINDOW_POLICY["margin_m"] + ) + if index is None: + mask = np.linalg.norm(reference - center, axis=1) <= radius + points = reference if mask.all() else reference[mask] + examined = len(reference) + else: + if index.reference is not reference: + raise ValueError("Reference index belongs to another map.") + points, examined = index.crop(center, radius) + if len(points) < 300: + raise ReferenceCoverageError( + "Недостаточное покрытие локальной области эталона для проверки привязки." + ) + return points, dict( + policy=WINDOW_POLICY, + map_points=len(reference), + target_points=len(points), + center=center.tolist() if center is not None else None, + radius_m=radius, + lookup="spatial-index/v1" if index is not None else "full-scan", + examined_points=examined, + target_sha256=hashlib.sha256(np.ascontiguousarray(points).tobytes()).hexdigest(), + ) diff --git a/src/k1link/missions/registration.py b/src/k1link/missions/registration.py new file mode 100644 index 0000000..9e5f612 --- /dev/null +++ b/src/k1link/missions/registration.py @@ -0,0 +1,211 @@ +"""Bounded CPU registration. A geometric candidate never grants vehicle authority.""" + +from __future__ import annotations + +import math +import time +from importlib.metadata import version + +import numpy as np + +POLICY = { + "version": "local-gicp-candidate/v1", + "voxel_m": 0.25, + "threads": 1, + "iterations": 40, + "correspondence_m": 1.5, + "evaluation_m": 0.5, + "minimum_overlap": 0.55, + "maximum_rmse_m": 0.25, + "maximum_correction_m": 3.0, + "maximum_correction_deg": 30.0, + "minimum_shape_ratio": 0.002, + "minimum_information_ratio": 0.0001, +} + + +def rigid(value): + t = np.asarray(value, dtype=np.float64) + if ( + t.shape != (4, 4) + or not np.isfinite(t).all() + or not np.allclose(t[3], [0, 0, 0, 1], atol=1e-7) + or not np.allclose(t[:3, :3].T @ t[:3, :3], np.eye(3), atol=1e-6) + or not np.isclose(np.linalg.det(t[:3, :3]), 1.0, atol=1e-6) + ): + raise ValueError("Начальная привязка должна быть жёстким преобразованием.") + return t + + +def transform(points, t): + return np.asarray(points) @ t[:3, :3].T + t[:3, 3] + + +def cloud(value): + p = np.ascontiguousarray(value, dtype=np.float64) + if ( + p.ndim != 2 + or p.shape[1] != 3 + or len(p) < 300 + or not np.isfinite(p).all() + or np.abs(p).max() > 100_000 + ): + raise ValueError("Для совмещения требуется не менее 300 конечных точек в метрах.") + return p + + +def angle_deg(rotation): + return math.degrees(math.acos(float(np.clip((np.trace(rotation) - 1) / 2, -1, 1)))) + + +def path_hint(reference_path, query_path): + """Explicit hypothesis: first query pose is at route entry, headings agree. + + Translation/heading come from the operator-selected paths, not recognition. + Roll/pitch start at zero and are refined by full 6-DoF registration. + """ + + def heading(path): + p = np.asarray(path, dtype=float) + for point in p[1:]: + d = point - p[0] + if np.linalg.norm(d[:2]) >= 3: + return math.atan2(d[1], d[0]) + raise ValueError("Для начального направления нужен участок длиной не менее 3 м.") + + yaw = heading(reference_path) - heading(query_path) + c, s = math.cos(yaw), math.sin(yaw) + t = np.eye(4) + t[:3, :3] = [[c, -s, 0], [s, c, 0], [0, 0, 1]] + t[:3, 3] = np.asarray(reference_path[0]) - t[:3, :3] @ np.asarray(query_path[0]) + return t + + +class PreparedReference: + """One immutable target/tree shared by sequential seeds in a single worker.""" + + def __init__(self, reference): + try: + import small_gicp as gicp + except ImportError as exc: + raise ValueError("Модуль совмещения не установлен на сервере.") from exc + target = cloud(reference) + self.origin = np.median(target, axis=0) + self.target, self.tree = gicp.preprocess_points( + target - self.origin, downsampling_resolution=0.25, num_threads=1 + ) + self.gicp = gicp + + def register(self, query, initial, *, policy=POLICY): + """Fit a query against this immutable target under an explicit policy. + + The default remains the short-range tracking policy. Route-wide + relocalisation supplies a separate, recorded policy: it may start from + a less exact hypothesis, but it never changes the acceptance criteria + of a normal tracking update by accident. + """ + return _register(self, query, initial, policy=policy) + + +def register(reference, query, initial, *, policy=POLICY): + started = time.monotonic() + result = PreparedReference(reference).register(query, initial, policy=policy) + result["registration_seconds"] = time.monotonic() - started + return result + + +def _register(prepared, query, initial, *, policy=POLICY): + source, initial = cloud(query), rigid(initial) + started = time.monotonic() + # Keep seed-dependent voxelization and patch-centre correction identical to + # the single-fit path. Only invariant target preprocessing is shared. + gicp, origin = prepared.gicp, prepared.origin + tgt, tree = prepared.target, prepared.tree + seeded = transform(source, initial) - origin + src, _ = gicp.preprocess_points(seeded, downsampling_resolution=0.25, num_threads=1) + if min(tgt.size(), src.size()) < 300: + raise ValueError("После прореживания недостаточно точек для совмещения.") + result = gicp.align( + tgt, + src, + tree, + registration_type="GICP", + num_threads=1, + max_iterations=40, + max_correspondence_distance=policy["correspondence_m"], + ) + delta = rigid(result.T_target_source) + transformed = transform(src.points()[:, :3], delta) + _, sq = tree.batch_nearest_neighbor_search(transformed, num_threads=1) + distances = np.sqrt(np.asarray(sq)) + inside = distances <= policy["evaluation_m"] + overlap = float(np.mean(inside)) + rmse = float(np.sqrt(np.mean(distances[inside] ** 2))) if inside.any() else None + shape = np.linalg.eigvalsh(np.cov(src.points()[:, :3].T)) + shape_ratio = float(max(0, shape[0]) / max(shape[-1], 1e-12)) + # Scale the information matrix by its diagonal: otherwise metres/radians and + # point count dominate a raw Hessian condition number. + h = np.asarray(result.H) + scale = np.sqrt(np.maximum(np.diag(h), 1e-12)) + eigen = np.linalg.eigvalsh(h / np.outer(scale, scale)) + information_ratio = float(max(0, eigen[0]) / max(eigen[-1], 1e-12)) + center = np.median(seeded, axis=0) + correction = float(np.linalg.norm(transform(center[None, :], delta)[0] - center)) + rotation = angle_deg(delta[:3, :3]) + reasons = [] + for failed, reason in [ + (not result.converged, "Расчёт не сошёлся."), + (overlap < policy["minimum_overlap"], "Недостаточное совпадение поверхностей."), + ( + rmse is None or rmse > policy["maximum_rmse_m"], + "Большое расстояние между поверхностями.", + ), + ( + correction > policy["maximum_correction_m"] + or rotation > policy["maximum_correction_deg"], + "Уточнение вышло за пределы начальной подсказки.", + ), + ( + shape_ratio < policy["minimum_shape_ratio"] + or information_ratio < policy["minimum_information_ratio"], + "Недостаточно пространственных ориентиров для устойчивой привязки.", + ), + ]: + if failed: + reasons.append(reason) + c = np.eye(4) + c[:3, 3] = origin + final = c @ delta @ np.linalg.inv(c) @ initial + _, display_sq = tree.batch_nearest_neighbor_search( + transform(source, final) - origin, num_threads=1 + ) + matched = ( + np.flatnonzero(np.asarray(display_sq) <= policy["evaluation_m"] ** 2).tolist() + if not reasons + else [] + ) + return { + "matched_query_indices": matched, + "correspondence_colors": "accepted-distance-v1", + "status": "rejected" if reasons else "candidate", + "reasons": reasons, + "policy": policy, + "algorithm": "small_gicp/GICP", + "algorithm_version": version("small-gicp"), + "T_reference_query": rigid(final).tolist(), + "initial_T_reference_query": initial.tolist(), + "overlap": overlap, + "inlier_rmse_m": rmse, + "evaluation_points": len(distances), + "reference_points": tgt.size(), + "query_points": src.size(), + "converged": bool(result.converged), + "iterations": int(result.iterations), + "correction_m": correction, + "correction_deg": rotation, + "shape_ratio": shape_ratio, + "information_ratio": information_ratio, + "registration_seconds": time.monotonic() - started, + "localization_confirmed": False, + "vehicle_control": False, + } diff --git a/src/k1link/missions/registration_colors.py b/src/k1link/missions/registration_colors.py new file mode 100644 index 0000000..344fb32 --- /dev/null +++ b/src/k1link/missions/registration_colors.py @@ -0,0 +1,14 @@ +"""Green encodes accepted per-point geometric proximity, never all query points.""" +import numpy as np + +def query_colors(points, result=None): + points = np.asarray(points) + if not len(points): return np.empty((0,3), dtype=np.uint8) + z = points[:, 2] + v = np.clip((z-np.min(z))/max(float(np.ptp(z)), .01), 0, 1) + colors = np.column_stack((70+185*v, 120-60*v, 255-65*v)).astype(np.uint8) + if result and result.get('status') == 'candidate': + indices = np.asarray(result.get('matched_query_indices', []), dtype=int) + indices = indices[(indices >= 0) & (indices < len(points))] + colors[indices] = [154, 235, 75] + return colors diff --git a/src/k1link/missions/registration_runs.py b/src/k1link/missions/registration_runs.py new file mode 100644 index 0000000..29f49fb --- /dev/null +++ b/src/k1link/missions/registration_runs.py @@ -0,0 +1,129 @@ +"""One bounded background calculation at a time, persisted with source provenance.""" +from __future__ import annotations + +import hashlib +import json +import os +import platform +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from uuid import UUID, uuid4 + +import numpy as np +from k1link.artifacts import utc_now_iso +from .drafts import DraftConflict +from .registration import path_hint +from .registration_worker import run_registration +from .registration_scene import write_scene + + +class RegistrationRuns: + def __init__(self, drafts): + self.drafts = drafts + self.root = drafts.database.parent / 'registration-runs' + self.root.mkdir(parents=True, exist_ok=True) + self.lock = threading.Lock() + self.executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix='registration') + for path in self.root.glob('*/report.json'): + doc = json.loads(path.read_text()) + if doc['state'] in {'queued', 'running'}: + self.write({**doc, 'state': 'error', 'message': 'Расчёт прерван перезапуском сервера.'}) + + def directory(self, run_id): + return self.root / str(UUID(run_id)) + + def write(self, doc): + directory = self.directory(doc['id']); directory.mkdir(exist_ok=True) + candidate = directory / 'report.tmp' + candidate.write_text(json.dumps(doc, allow_nan=False)) + os.replace(candidate, directory / 'report.json') + + def get(self, run_id): + path = self.directory(run_id) / 'report.json' + if not path.is_file(): + raise KeyError(run_id) + return json.loads(path.read_text()) + + def list(self, draft_id): + self.drafts.get(draft_id) + items = [json.loads(p.read_text()) for p in self.root.glob('*/report.json')] + return sorted([{'id': d['id'], 'created_at_utc': d['created_at_utc'], 'state': d['state'], + 'revision': d['revision'], 'query_session_id': d['request']['session_id']} + for d in items if d['draft_id'] == draft_id], key=lambda d: d['created_at_utc'], reverse=True)[:50] + + def start(self, draft_id, request): + if not self.lock.acquire(blocking=False): + raise ValueError('Другой расчёт совмещения ещё выполняется.') + try: + draft = self.drafts.get(draft_id) + if draft['revision'] != request['revision']: + raise DraftConflict('Черновик изменён. Откройте сохранённую версию.') + route = draft['route'] + if not 3 <= route['length_m'] <= 40: + raise ValueError('Для совмещения выберите маршрут длиной от 3 до 40 м.') + query = self.drafts.sources.bound(request['session_id'], request['generation']) + start, end = request['start_index'], request['end_index'] + if not 0 <= start < end < len(query['poses']): + raise ValueError('Некорректный интервал повторной записи.') + distance = query['poses'][end]['distance_m'] - query['poses'][start]['distance_m'] + if not 3 <= distance <= 40: + raise ValueError('Для повторного прохода выберите участок длиной от 3 до 40 м.') + same = draft['zone']['session_id'] == request['session_id'] + if same and max(start, route['start_index']) <= min(end, route['end_index']): + raise ValueError('Эталонный и проверочный участки одной записи не должны пересекаться.') + doc = {'schema_version': 'missioncore.registration-run/v1', 'id': str(uuid4()), + 'draft_id': draft_id, 'revision': draft['revision'], 'draft': draft, + 'request': request, 'state': 'queued', 'created_at_utc': utc_now_iso(), + 'evidence_relation': 'same_recording' if same else 'different_recordings', + 'localization_confirmed': False, 'vehicle_control': False} + self.write(doc) + self.executor.submit(self.calculate, doc) + return doc + except Exception: + self.lock.release() + raise + + def calculate(self, doc): + started = time.monotonic_ns() + directory = self.directory(doc['id']) + try: + doc = {**doc, 'state': 'running', 'started_at_utc': utc_now_iso(), 'started_monotonic_ns': started} + self.write(doc) + draft, request = doc['draft'], doc['request'] + route, zone = draft['route'], draft['zone'] + sources = self.drafts.sources + doc['progress_label'] = 'Подготовка эталонного участка'; self.write(doc) + reference, ref_meta = sources.submap(zone['session_id'], zone['generation'], route['start_index'], route['end_index']) + doc['progress_label'] = 'Подготовка повторного прохода'; self.write(doc) + query, query_meta = sources.submap(request['session_id'], request['generation'], request['start_index'], request['end_index']) + qdoc = sources.bound(request['session_id'], request['generation']) + ref_path = np.array([p['position'] for p in route['points']]) + query_path = np.array([p['position'] for p in qdoc['poses'][request['start_index']:request['end_index']+1]]) + initial = path_hint(ref_path, query_path) + doc['progress_label'] = 'Расчёт совмещения'; self.write(doc) + result = run_registration(directory, reference, query, initial) + doc['progress_label'] = 'Сохранение результата'; self.write(doc) + np.savez_compressed(directory / 'clouds.npz', reference=reference, query=query, + reference_path=ref_path, query_path=query_path) + write_scene(directory / 'scene.rrd', doc['id'], reference, query, result, ref_path, query_path) + artifacts = {name: hashlib.sha256((directory / name).read_bytes()).hexdigest() + for name in ['clouds.npz', 'scene.rrd', 'registration-input.npz', 'registration-result.json']} + doc.update(state='ready', result=result, reference=ref_meta, query=query_meta, + hint='route-entry-and-travel-heading', artifacts=artifacts, + scene_url='/api/v1/mission-planner/registration-runs/'+doc['id']+'/scene.rrd', + runtime={'system': platform.system(), 'machine': platform.machine(), 'python': platform.python_version()}) + except Exception as exc: + # Details stay in private evidence; paths and native errors do not enter the UI. + (directory / 'failure.txt').write_text(f'{type(exc).__name__}: {exc}') + doc.update(state='error', message=str(exc) if isinstance(exc, ValueError) + else 'Не удалось завершить совмещение. Исходные записи сохранены.') + finally: + doc.update(finished_at_utc=utc_now_iso(), elapsed_seconds=(time.monotonic_ns()-started)/1e9) + try: + self.write(doc) + finally: + self.lock.release() + + def close(self): + self.executor.shutdown(wait=True, cancel_futures=True) diff --git a/src/k1link/missions/registration_scene.py b/src/k1link/missions/registration_scene.py new file mode 100644 index 0000000..2278d5c --- /dev/null +++ b/src/k1link/missions/registration_scene.py @@ -0,0 +1,26 @@ +"""Static spatial evidence for one immutable registration run.""" +import numpy as np +import rerun as rr +from rerun import blueprint as rrb +from .registration import transform +from .registration_colors import query_colors + + +def write_scene(path, run_id, reference, query, result, reference_path, query_path): + recording = rr.RecordingStream('missioncore-registration', recording_id=run_id) + recording.save(path) + try: + recording.log('world', rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True) + for name, xyz, color in [('reference', reference, [140, 140, 140]), + ('query', transform(query, np.array(result['T_reference_query'])), query_colors(query, result))]: + recording.log('world/'+name, rr.Points3D(xyz, colors=color, radii=rr.Radius.ui_points(1.5)), static=True) + for name, xyz, color in [('reference_path', reference_path, [120, 160, 255]), + ('query_path', transform(query_path, np.array(result['T_reference_query'])), [255, 190, 70])]: + recording.log('world/'+name, rr.LineStrips3D([xyz], colors=color), static=True) + recording.send_blueprint(rrb.Blueprint( + rrb.Spatial3DView(name='Совмещение проходов', origin='/world', contents=['/world/**'], + background=[9, 10, 12, 255]), + auto_layout=False, auto_views=False, collapse_panels=True)) + recording.flush() + finally: + recording.disconnect() diff --git a/src/k1link/missions/registration_worker.py b/src/k1link/missions/registration_worker.py new file mode 100644 index 0000000..b04b3ae --- /dev/null +++ b/src/k1link/missions/registration_worker.py @@ -0,0 +1,36 @@ +"""Short-lived numeric worker: native library lifetime is separate from the API.""" +from __future__ import annotations +import json +import os +import subprocess +import sys +from pathlib import Path +import numpy as np + + +def run_registration(directory, reference, query, initial): + source, destination = directory / 'registration-input.npz', directory / 'registration-result.json' + np.savez_compressed(source, reference=reference, query=query, initial=initial) + environment = {**os.environ, 'OMP_NUM_THREADS': '1', 'OPENBLAS_NUM_THREADS': '1', + 'VECLIB_MAXIMUM_THREADS': '1'} + with (directory / 'calculation.log').open('wb') as log: + try: + subprocess.run([sys.executable, '-m', 'k1link.missions.registration_worker', + str(source), str(destination)], env=environment, + stdout=log, stderr=log, timeout=30, check=True) + except subprocess.TimeoutExpired as exc: + raise ValueError('Превышено время совмещения. Выберите более короткий участок.') from exc + except subprocess.CalledProcessError as exc: + raise ValueError('Расчёт совмещения завершился с ошибкой. Исходные записи сохранены.') from exc + return json.loads(destination.read_text()) + + +def main(): + from .registration import register + with np.load(Path(sys.argv[1]), allow_pickle=False) as data: + result = register(data['reference'], data['query'], data['initial']) + Path(sys.argv[2]).write_text(json.dumps(result, allow_nan=False)) + + +if __name__ == '__main__': + main() diff --git a/src/k1link/missions/replay_faults.py b/src/k1link/missions/replay_faults.py new file mode 100644 index 0000000..1c67757 --- /dev/null +++ b/src/k1link/missions/replay_faults.py @@ -0,0 +1,25 @@ +"""Explicit laboratory receipt loss; surviving events are never retimed.""" + +import math + + +def drop_receipts(events, start_s, end_s, audit): + if not all(math.isfinite(x) for x in (start_s, end_s)) or not 0 < start_s < end_s <= 120: + raise ValueError("Invalid bounded receipt-loss interval.") + audit.update(version="receipt-drop/v1", interval_s=[start_s, end_s], dropped=[]) + origin = None + for event in events: + if origin is None: + origin = event.monotonic_ns + elapsed = (event.monotonic_ns - origin) / 1e9 + if start_s <= elapsed < end_s: + audit["dropped"].append( + dict( + sequence=event.sequence, + kind=event.kind, + time_s=elapsed, + monotonic_ns=event.monotonic_ns, + ) + ) + else: + yield event diff --git a/src/k1link/missions/route_relocalization.py b/src/k1link/missions/route_relocalization.py new file mode 100644 index 0000000..f4b9838 --- /dev/null +++ b/src/k1link/missions/route_relocalization.py @@ -0,0 +1,721 @@ +"""Staged stationary localisation before the normal fresh-data tracking gate. + +A known start is the reliable laboratory path, so it first receives a dense +multi-start fit. Only its honest rejection permits retrieval over the entire +selected route. That preserves a repeatable start while retaining an auditable +recovery path for a restarted rover that must look for *where it is*. + +Neither stage grants tracking or vehicle authority: both only produce a +provisional hypothesis for the separate, disjoint fresh-data gate. +""" + +from __future__ import annotations + +import math +import time +from copy import deepcopy +from dataclasses import dataclass +from itertools import product + +import numpy as np + +from .entry_acquisition import acquire_entry +from .observation_profiles import TRACKING_INPUT +from .reference_window import reference_window +from .registration import POLICY as TRACKING_POLICY +from .registration import PreparedReference, angle_deg, cloud, rigid, transform +from .stationary_entry import STATIONARY_POLICY + +ROUTE_RELOCALIZATION_POLICY = dict( + version="route-relocalization/v6", + scope="selected-route", + strategy="dense-start-first-then-route-recovery/v1", + # Local geometry is independent of the 80-m presentation envelope. + query_radius_m=TRACKING_INPUT["radius_m"], + anchor_spacing_m=5.0, + spatial_cell_m=10.0, + # Candidate retrieval stays local and distinctive. The chosen candidate is + # then matched against the high-resolution local tracking footprint. + descriptor_context_m=28.0, + descriptor_radial_bins=7, + descriptor_height_bins=6, + descriptor_height_low_m=-4.0, + descriptor_height_high_m=8.0, + polar_angle_bins=24, + # A batch controls scheduling, never eligibility. Ranking must not discard + # the real place merely because a coarse descriptor prefers an endpoint. + candidate_batch_size=6, + yaw_candidates_per_place=3, + yaw_step_deg=30.0, + target_context_margin_m=12.0, + target_maximum_points=None, + descriptor_voxel_m=0.5, + cluster_position_m=0.75, + cluster_rotation_deg=8.0, + ambiguity_overlap_margin=0.05, + ambiguity_rmse_margin_m=0.03, + # Keep the stationary prefix younger than the bootstrap's 40-s source-age + # fence. A late exhaustive calculation is an explicit incomplete search, + # never a stale provisional position. + deadline_s=30.0, + maximum_search_wall_s=35.0, + # This is a numerical convergence envelope, not an operator start-radius + # admission rule. Reaching its wall deadline is reported as incomplete. + registration_policy={ + **TRACKING_POLICY, + "version": "route-relocalization-gicp/v1", + "maximum_correction_m": 25.0, + "maximum_correction_deg": 180.0, + }, +) + + +def _valid_path(path): + path = np.asarray(path, dtype=float) + if path.ndim != 2 or path.shape[1] != 3 or len(path) < 2 or not np.isfinite(path).all(): + raise ValueError("Для поиска по маршруту нужен конечный маршрут минимум из двух точек.") + lengths = np.linalg.norm(np.diff(path, axis=0), axis=1) + if not np.isfinite(lengths).all() or float(lengths.sum()) <= 0: + raise ValueError("Маршрут не содержит достаточной геометрии для поиска.") + return path, lengths + + +def route_reference_cloud(value): + """Validate the complete route atlas without applying GICP's target cap. + + A selected kilometre route is not one target: it is indexed here and only a + local, separately checked target is handed to GICP later. + """ + points = np.ascontiguousarray(value, dtype=np.float64) + if ( + points.ndim != 2 + or points.shape[1] != 3 + or len(points) < 300 + or not np.isfinite(points).all() + or np.abs(points).max() > 100_000 + ): + raise ValueError("Полная карта маршрута содержит недостаточно конечных точек в метрах.") + return points + + +def route_anchors(path, *, spacing_m=ROUTE_RELOCALIZATION_POLICY["anchor_spacing_m"]): + """Resample the complete path; no endpoint or intermediate segment is skipped.""" + if not 0 < spacing_m <= 25: + raise ValueError("Некорректный шаг индекса маршрута.") + path, lengths = _valid_path(path) + cumulative = np.r_[0.0, np.cumsum(lengths)] + distances = np.r_[np.arange(0.0, cumulative[-1], spacing_m), cumulative[-1]] + positions = [] + for distance in distances: + segment = min( + int(np.searchsorted(cumulative, distance, side="right") - 1), len(lengths) - 1 + ) + fraction = (distance - cumulative[segment]) / lengths[segment] + positions.append(path[segment] + fraction * (path[segment + 1] - path[segment])) + return np.asarray(positions), distances + + +def _voxel(points, *, voxel_m): + if len(points) == 0: + return points + _, index = np.unique(np.floor(points / voxel_m).astype(np.int64), axis=0, return_index=True) + return points[np.sort(index)] + + +class ReferenceGrid: + """Read-only spatial index for a full route map. + + It prevents every atlas anchor from scanning every point in a kilometre + route. The grid is local to one isolated search process and is never + reused as a mutable tracking map. + """ + + def __init__(self, reference, *, cell_m=ROUTE_RELOCALIZATION_POLICY["spatial_cell_m"]): + if not 1.0 <= cell_m <= 25.0: + raise ValueError("Некорректный размер ячейки карты маршрута.") + self.reference = route_reference_cloud(reference) + self.cell_m = float(cell_m) + cells = np.floor(self.reference / self.cell_m).astype(np.int64) + keys, inverse = np.unique(cells, axis=0, return_inverse=True) + order = np.argsort(inverse, kind="stable") + counts = np.bincount(inverse, minlength=len(keys)) + boundaries = np.r_[0, np.cumsum(counts)] + self.ordered = self.reference[order] + self.slices = { + tuple(key): (int(boundaries[index]), int(boundaries[index + 1])) + for index, key in enumerate(keys) + } + + def crop(self, center, radius_m): + center = np.asarray(center, dtype=float).reshape(3) + if not np.isfinite(center).all() or not 0 < radius_m <= 100: + raise ValueError("Некорректная локальная область маршрута.") + lower = np.floor((center - radius_m) / self.cell_m).astype(int) + upper = np.floor((center + radius_m) / self.cell_m).astype(int) + pieces = [] + ranges = tuple(range(first, last + 1) for first, last in zip(lower, upper, strict=True)) + for key in product(*ranges): + bounds = self.slices.get(key) + if bounds is not None: + pieces.append(self.ordered[slice(*bounds)]) + if not pieces: + return np.empty((0, 3), dtype=float) + points = np.concatenate(pieces) + return points[np.linalg.norm(points - center, axis=1) <= radius_m] + + +def local_submap(reference, center, radius_m, *, maximum_points): + """Radial crop. Production verification preserves the source resolution. + + An explicit point budget is available only to descriptor/test callers. + It is never a density threshold for declaring tracking lost. + """ + if isinstance(reference, ReferenceGrid): + points = reference.crop(center, radius_m) + else: + full = route_reference_cloud(reference) + points = full[np.linalg.norm(full - center, axis=1) <= radius_m] + if maximum_points is not None and len(points) > maximum_points: + original = points + voxel_m = ROUTE_RELOCALIZATION_POLICY["descriptor_voxel_m"] + while len(points) > maximum_points: + reduced = _voxel(original, voxel_m=voxel_m) + if voxel_m > radius_m * 2.0: + return np.empty((0, 3), dtype=float) + points = reduced + voxel_m *= 2.0 + if len(points) < 300: + return np.empty((0, 3), dtype=float) + return points + + +def radial_height_descriptor(points, center, *, policy=ROUTE_RELOCALIZATION_POLICY): + relative = np.asarray(points, dtype=float) - np.asarray(center, dtype=float) + radial = np.linalg.norm(relative[:, :2], axis=1) + histogram, _ = np.histogramdd( + np.column_stack([radial, relative[:, 2]]), + bins=( + policy["descriptor_radial_bins"], + policy["descriptor_height_bins"], + ), + range=( + (0.0, policy["descriptor_context_m"]), + (policy["descriptor_height_low_m"], policy["descriptor_height_high_m"]), + ), + ) + flat = histogram.reshape(-1) + norm = float(np.linalg.norm(flat)) + return flat / norm if norm else flat + + +def polar_descriptor( + points, + center, + *, + bins=ROUTE_RELOCALIZATION_POLICY["polar_angle_bins"], + context_m=ROUTE_RELOCALIZATION_POLICY["descriptor_context_m"], +): + relative = np.asarray(points, dtype=float) - np.asarray(center, dtype=float) + angle = np.mod(np.arctan2(relative[:, 1], relative[:, 0]), 2 * math.pi) + radial = np.linalg.norm(relative[:, :2], axis=1) + # Four equally sized radial rings prevent one distant, unrelated wall + # from deciding yaw while retaining the full declared context. + rings = np.minimum((radial / (context_m / 4.0)).astype(int), 3) + output = np.zeros((4, bins), dtype=float) + angles = np.minimum((angle / (2 * math.pi) * bins).astype(int), bins - 1) + np.add.at(output, (rings, angles), 1.0) + norm = float(np.linalg.norm(output)) + return output / norm if norm else output + + +def _yaw_candidates(query, target, query_center, target_center, *, policy): + q = polar_descriptor( + query, + query_center, + bins=policy["polar_angle_bins"], + context_m=policy["descriptor_context_m"], + ) + t = polar_descriptor( + target, + target_center, + bins=policy["polar_angle_bins"], + context_m=policy["descriptor_context_m"], + ) + candidates = [] + for yaw in np.arange(0.0, 360.0, policy["yaw_step_deg"]): + shift = int(round(yaw / 360.0 * policy["polar_angle_bins"])) + candidates.append((float(np.linalg.norm(t - np.roll(q, shift, axis=1))), float(yaw))) + return [yaw for _, yaw in sorted(candidates)[: policy["yaw_candidates_per_place"]]] + + +@dataclass(frozen=True) +class RouteCandidate: + index: int + position: np.ndarray + progress_m: float + descriptor_distance: float + + +def rank_route_candidates( + reference, reference_path, query, *, policy=ROUTE_RELOCALIZATION_POLICY, grid=None +): + """Rank every resampled route position against the stationary query cloud.""" + reference, query = route_reference_cloud(reference), cloud(query) + grid = grid or ReferenceGrid(reference, cell_m=policy["spatial_cell_m"]) + anchors, progress = route_anchors(reference_path, spacing_m=policy["anchor_spacing_m"]) + query_center = np.median(query, axis=0) + query_descriptor = radial_height_descriptor(query, query_center, policy=policy) + ranked = [] + for index, (position, distance) in enumerate(zip(anchors, progress, strict=True)): + target = local_submap( + grid, + position, + policy["descriptor_context_m"], + maximum_points=policy["target_maximum_points"], + ) + if len(target) < 300: + continue + descriptor = radial_height_descriptor(target, np.median(target, axis=0), policy=policy) + ranked.append( + RouteCandidate( + index=index, + position=position, + progress_m=float(distance), + descriptor_distance=float(np.linalg.norm(query_descriptor - descriptor)), + ) + ) + ranked.sort(key=lambda candidate: (candidate.descriptor_distance, candidate.index)) + return ranked, dict( + route_anchor_count=len(anchors), + descriptor_covered_anchor_count=len(ranked), + descriptor_candidate_count=len(ranked), + descriptor_scope="entire-selected-route", + ) + + +def _seed(query_center, target_center, yaw_deg): + angle = math.radians(yaw_deg) + rotation = np.array( + [ + [math.cos(angle), -math.sin(angle), 0.0], + [math.sin(angle), math.cos(angle), 0.0], + [0, 0, 1], + ], + dtype=float, + ) + matrix = np.eye(4) + matrix[:3, :3] = rotation + matrix[:3, 3] = np.asarray(target_center) - rotation @ np.asarray(query_center) + return matrix + + +def _rejected_attempt(message, initial): + return dict( + status="rejected", + reasons=[message], + T_reference_query=rigid(initial).tolist(), + initial_T_reference_query=rigid(initial).tolist(), + overlap=0.0, + inlier_rmse_m=None, + matched_query_indices=[], + localization_confirmed=False, + vehicle_control=False, + registration_seconds=0.0, + ) + + +def _distance(first, second, query_entry): + a, b = np.asarray(first), np.asarray(second) + position = float( + np.linalg.norm( + transform(np.asarray(query_entry).reshape(1, 3), a) + - transform(np.asarray(query_entry).reshape(1, 3), b) + ) + ) + return position, angle_deg(a[:3, :3] @ b[:3, :3].T) + + +def choose_route_location(attempts, query_entry, *, complete, policy=ROUTE_RELOCALIZATION_POLICY): + """Accept one well-separated route location, or expose why we did not.""" + candidates, diagnostics = [], [] + for attempt in attempts: + result = attempt["result"] + diagnostic = {k: v for k, v in attempt.items() if k != "result"} + diagnostic["result"] = {k: v for k, v in result.items() if k != "matched_query_indices"} + diagnostics.append(diagnostic) + if result["status"] == "candidate": + candidates.append(attempt) + candidates.sort( + key=lambda attempt: ( + -attempt["result"]["overlap"], + attempt["result"]["inlier_rmse_m"], + attempt["candidate"]["index"], + attempt["yaw_deg"], + ) + ) + clusters = [] + for attempt in candidates: + for cluster in clusters: + if all( + _distance( + attempt["result"]["T_reference_query"], + other["result"]["T_reference_query"], + query_entry, + )[0] + <= policy["cluster_position_m"] + and _distance( + attempt["result"]["T_reference_query"], + other["result"]["T_reference_query"], + query_entry, + )[1] + <= policy["cluster_rotation_deg"] + for other in cluster + ): + cluster.append(attempt) + break + else: + clusters.append([attempt]) + # Keep the remaining distinct hypotheses for disjoint fresh confirmation. + # Their ambiguity is evaluated again relative to the remaining queue, not + # inherited from the best hypothesis after it has been rejected. + queue = [] + for index, cluster in enumerate(clusters): + best = cluster[0] + ambiguous = any( + alternative[0]["result"]["overlap"] + >= best["result"]["overlap"] - policy["ambiguity_overlap_margin"] + and alternative[0]["result"]["inlier_rmse_m"] + <= best["result"]["inlier_rmse_m"] + policy["ambiguity_rmse_margin_m"] + for alternative in clusters[index + 1 :] + ) + queue.append(dict( + candidate_index=best["candidate"]["index"], + route_progress_m=best["candidate"]["progress_m"], + T_reference_query=best["result"]["T_reference_query"], + overlap=best["result"]["overlap"], + inlier_rmse_m=best["result"]["inlier_rmse_m"], + ambiguous=ambiguous, + )) + reason = None + if not complete: + reason = "incomplete-route-search" + elif not clusters: + reason = "no-route-location" + elif queue[0]["ambiguous"]: + # Distinctness comes from fitted SE(3), not the retrieval anchor: two + # seeds at one anchor can converge to different places or directions. + reason = "ambiguous-route-location" + selected = ( + dict(clusters[0][0]["result"]) + if clusters + else _rejected_attempt( + "Ни один кандидат маршрута не прошёл геометрическую проверку.", np.eye(4) + ) + ) + selected.update( + status="rejected" if reason else "candidate", + reasons=[reason] if reason else [], + matched_query_indices=[] if reason else selected.get("matched_query_indices", []), + localization_confirmed=False, + vehicle_control=False, + ) + selected["initialization"] = dict( + policy=policy, + scope=policy["scope"], + complete=complete, + reason=reason, + expected_attempts=len(attempts), + attempts=diagnostics, + candidate_queue=queue if complete else [], + selected_candidate_index=clusters[0][0]["candidate"]["index"] if clusters else None, + selected_route_progress_m=(clusters[0][0]["candidate"]["progress_m"] if clusters else None), + clusters=[ + dict( + candidate_indices=sorted({item["candidate"]["index"] for item in cluster}), + route_progress_m=cluster[0]["candidate"]["progress_m"], + support=len(cluster), + overlap=cluster[0]["result"]["overlap"], + rmse_m=cluster[0]["result"]["inlier_rmse_m"], + ) + for cluster in clusters + ], + ) + selected["registration_seconds"] = sum( + item["result"].get("registration_seconds", 0.0) for item in attempts + ) + return selected + + +def relocalize_route( + reference, + reference_path, + query, + query_entry, + *, + clock=time.monotonic, + policy=ROUTE_RELOCALIZATION_POLICY, +): + """Run complete candidate retrieval and qualification against a selected route.""" + started = clock() + reference, query = route_reference_cloud(reference), cloud(query) + query_entry = np.asarray(query_entry, dtype=float).reshape(3) + grid = ReferenceGrid(reference, cell_m=policy["spatial_cell_m"]) + ranked, coverage = rank_route_candidates( + reference, reference_path, query, policy=policy, grid=grid + ) + attempts, evaluated, batches = [], [], [] + query_center = np.median(query, axis=0) + radius = max( + policy["descriptor_context_m"], + float(np.linalg.norm(query - query_center, axis=1).max()) + + policy["target_context_margin_m"], + ) + expected = len(ranked) * policy["yaw_candidates_per_place"] + batch_size = policy["candidate_batch_size"] + for candidate in ranked: + if clock() - started > policy["deadline_s"]: + break + if len(evaluated) % batch_size == 0: + batches.append([]) + target = local_submap( + grid, candidate.position, radius, maximum_points=policy["target_maximum_points"] + ) + if len(target) < 300: + # Descriptor-admitted geometry unexpectedly disappeared. Do not + # call this a complete negative search or silently skip the place. + break + target_center = np.median(target, axis=0) + count_before = len(attempts) + prepared = None + for yaw_deg in _yaw_candidates( + query, target, query_center, target_center, policy=policy + ): + if clock() - started > policy["deadline_s"]: + break + initial = _seed(query_center, target_center, yaw_deg) + try: + # Target preprocessing is independent of yaw. Keep one tree + # per place; all seeds and all eligibility checks stay intact. + if prepared is None: + prepared = PreparedReference(target) + result = prepared.register( + query, initial, policy=policy["registration_policy"] + ) + except ValueError as exc: + result = _rejected_attempt(str(exc), initial) + attempts.append( + dict( + candidate=dict( + index=candidate.index, + position=candidate.position.tolist(), + progress_m=candidate.progress_m, + descriptor_distance=candidate.descriptor_distance, + ), + yaw_deg=yaw_deg, + result=result, + ) + ) + if len(attempts) - count_before != policy["yaw_candidates_per_place"]: + break + evaluated.append(candidate.index) + batches[-1].append(candidate.index) + complete = len(evaluated) == len(ranked) and clock() - started <= policy["deadline_s"] + result = choose_route_location(attempts, query_entry, complete=complete, policy=policy) + result["initialization"].update( + coverage, + elapsed_s=clock() - started, + expected_attempts=expected, + evaluated_candidate_indices=evaluated, + remaining_candidate_indices=[c.index for c in ranked if c.index not in evaluated], + candidate_batches=batches, + candidate_queue_exhausted=complete, + ) + return result + + +def _route_start_context(reference, reference_path, query, query_entry, reference_position=None): + """Prepare the established dense start target without shrinking the scene. + + The selected route's first point is still a valuable, explicitly chosen + laboratory datum. After loss, the last confirmed place takes its role. + This target preserves the precise local map representation used + by the successful start-area runs instead of voxelising a broad whole-route + crop before GICP has a chance to converge. + """ + reference, query = route_reference_cloud(reference), cloud(query) + path, _lengths = _valid_path(reference_path) + entry = np.asarray(query_entry, dtype=float).reshape(3) + initial = np.eye(4) + anchor = path[0] if reference_position is None else np.asarray(reference_position, dtype=float) + if anchor.shape != (3,) or not np.isfinite(anchor).all(): + raise ValueError("Некорректная область восстановления привязки.") + initial[:3, 3] = anchor - entry + forward = next( + (point - path[0] for point in path[1:] if np.linalg.norm((point - path[0])[:2]) >= 3), + None, + ) + if forward is None: + raise ValueError("Reference lacks a usable route basis.") + target, window = reference_window( + reference, + dict(points=query, path=np.asarray([entry])), + initial, + initializing=True, + ) + return target, query, initial, entry, forward, window + + +def _stage_attempts(stage, initialization): + """Keep every fit auditable while retaining its stage of the hybrid search.""" + return [dict(stage=stage, **attempt) for attempt in initialization.get("attempts", [])] + + +def _hybrid_initialization(policy, start_result, route_result=None): + """Normalize two numerical stages for StationaryBootstrap's strict gate.""" + start = start_result["initialization"] + attempts = _stage_attempts("dense-start", start) + expected = start.get("expected_attempts", len(attempts)) + stages = [ + dict( + name="dense-start", + status=start_result["status"], + reason=start.get("reason"), + complete=start.get("complete", False), + elapsed_s=start.get("elapsed_s"), + expected_attempts=expected, + target_window=start.get("target_window"), + reference_position=start.get("reference_position"), + ) + ] + selected = dict( + selected_candidate_index=0 if start_result["status"] == "candidate" else None, + selected_route_progress_m=start.get("route_progress_m", 0.0) + if start_result["status"] == "candidate" + else None, + ) + reason = start.get("reason") + complete = bool(start.get("complete")) + if route_result is not None: + route = route_result["initialization"] + attempts.extend(_stage_attempts("route-recovery", route)) + expected += route.get("expected_attempts", len(route.get("attempts", []))) + stages.append( + dict( + name="route-recovery", + status=route_result["status"], + reason=route.get("reason"), + complete=route.get("complete", False), + elapsed_s=route.get("elapsed_s"), + expected_attempts=len(route.get("attempts", [])), + descriptor_scope=route.get("descriptor_scope"), + expected_attempts_total=route.get("expected_attempts"), + evaluated_candidate_indices=route.get("evaluated_candidate_indices"), + remaining_candidate_indices=route.get("remaining_candidate_indices"), + candidate_batches=route.get("candidate_batches"), + ) + ) + selected = dict( + selected_candidate_index=route.get("selected_candidate_index"), + selected_route_progress_m=route.get("selected_route_progress_m"), + candidate_queue=route.get("candidate_queue", []), + ) + reason = route.get("reason") + complete = bool(route.get("complete")) + return dict( + policy=policy, + scope=policy["scope"], + strategy=policy["strategy"], + complete=complete, + reason=reason, + expected_attempts=expected, + attempts=attempts, + stages=stages, + **selected, + ) + + +def relocalize_start_then_route( + reference, + reference_path, + query, + query_entry, + *, + clock=time.monotonic, + policy=ROUTE_RELOCALIZATION_POLICY, + reference_position=None, + route_only=False, +): + """Use the proven start-area fit first, then a bounded route fallback. + + This is deliberately not a looser acceptance rule. The dense start fit + runs every stationary multi-start seed against its high-resolution local + target. Only an honest rejection enters whole-route retrieval, whose + result remains provisional until the existing fresh-data gate confirms it. + """ + started = clock() + if route_only: + # A dense-start prior failed fresh confirmation. Recollect first, then + # search the route without repeatedly retrying that unconfirmed start. + return relocalize_route(reference, reference_path, query, query_entry, + clock=clock, policy=policy) + target, query, initial, entry, forward, window = _route_start_context( + reference, reference_path, query, query_entry, reference_position + ) + start_result = acquire_entry( + target, + query, + initial, + entry, + forward, + clock=clock, + policy=STATIONARY_POLICY, + ) + start_result["initialization"].update( + scope=policy["scope"], + target_window=window, + query_radius_m=policy["query_radius_m"], + reference_position=(np.asarray(query_entry) + initial[:3, 3]).tolist(), + route_progress_m=float( + np.r_[0.0, np.cumsum(np.linalg.norm(np.diff(reference_path, axis=0), axis=1))][ + np.argmin( + np.linalg.norm( + np.asarray(reference_path) - (np.asarray(query_entry) + initial[:3, 3]), + axis=1, + ) + ) + ] + ), + ) + if start_result["status"] == "candidate": + start_result["initialization"] = _hybrid_initialization(policy, start_result) + return start_result + if not start_result["initialization"].get("complete"): + # Compute exhaustion is not evidence that this place did not match. + start_result["initialization"] = _hybrid_initialization(policy, start_result) + return start_result + + # A failed standard start may still be a valid mid-route or recovery + # position. Give retrieval only the fresh-prefix time remaining: it must + # never turn a late calculation into an apparently usable prior. + remaining = policy["maximum_search_wall_s"] - (clock() - started) + if remaining <= 0: + route_result = choose_route_location([], entry, complete=False, policy=policy) + route_result["initialization"].update( + elapsed_s=0.0, worker_timeout_reason="start-stage-timeout" + ) + else: + recovery_policy = deepcopy(policy) + recovery_policy["deadline_s"] = min(policy["deadline_s"], remaining) + route_result = relocalize_route( + reference, + reference_path, + query, + entry, + clock=clock, + policy=recovery_policy, + ) + route_result["initialization"] = _hybrid_initialization(policy, start_result, route_result) + route_result["registration_seconds"] = start_result.get( + "registration_seconds", 0.0 + ) + route_result.get("registration_seconds", 0.0) + return route_result diff --git a/src/k1link/missions/route_relocalization_worker.py b/src/k1link/missions/route_relocalization_worker.py new file mode 100644 index 0000000..f51f6b5 --- /dev/null +++ b/src/k1link/missions/route_relocalization_worker.py @@ -0,0 +1,99 @@ +"""Isolated CPU child for complete selected-route relocalisation.""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +import numpy as np + +from .route_relocalization import ROUTE_RELOCALIZATION_POLICY + + +def incomplete_result(reason): + identity = np.eye(4).tolist() + return dict( + status="rejected", + reasons=[reason], + T_reference_query=identity, + initial_T_reference_query=identity, + matched_query_indices=[], + overlap=0.0, + inlier_rmse_m=None, + localization_confirmed=False, + vehicle_control=False, + initialization=dict( + policy=ROUTE_RELOCALIZATION_POLICY, + scope="selected-route", + complete=False, + reason="incomplete-route-search", + expected_attempts=0, + attempts=[], + worker_timeout_reason=reason, + ), + ) + + +def run_route_relocalization( + directory, reference, reference_path, query, query_entry, *, reference_position=None, + route_only=False, +): + source = directory / "route-relocalization-input.npz" + destination = directory / "route-relocalization-result.json" + np.savez_compressed( + source, + reference=reference, + reference_path=reference_path, + query=query, + query_entry=query_entry, + route_only=route_only, + **({"reference_position": reference_position} if reference_position is not None else {}), + ) + environment = { + **os.environ, + "OMP_NUM_THREADS": "1", + "OPENBLAS_NUM_THREADS": "1", + "VECLIB_MAXIMUM_THREADS": "1", + } + with (directory / "calculation.log").open("wb") as log: + try: + subprocess.run( + [ + sys.executable, + "-m", + "k1link.missions.route_relocalization_worker", + str(source), + str(destination), + ], + env=environment, + stdout=log, + stderr=log, + timeout=ROUTE_RELOCALIZATION_POLICY["maximum_search_wall_s"] + 5, + check=True, + ) + except subprocess.TimeoutExpired: + # A process timeout says nothing about whether the scanner is at a + # known place. Return a normal, persisted incomplete-search result + # so the UI can distinguish it from a geometric rejection. + destination.write_text(json.dumps(incomplete_result("worker-timeout"), allow_nan=False)) + return json.loads(destination.read_text()) + + +def main(): + from .route_relocalization import relocalize_start_then_route + + with np.load(Path(sys.argv[1]), allow_pickle=False) as data: + result = relocalize_start_then_route( + data["reference"], + data["reference_path"], + data["query"], + data["query_entry"], + reference_position=data.get("reference_position"), + route_only=bool(data.get("route_only", False)), + ) + Path(sys.argv[2]).write_text(json.dumps(result, allow_nan=False)) + + +if __name__ == "__main__": + main() diff --git a/src/k1link/missions/sources.py b/src/k1link/missions/sources.py new file mode 100644 index 0000000..0d42783 --- /dev/null +++ b/src/k1link/missions/sources.py @@ -0,0 +1,171 @@ +"""Bounded, immutable planning-source cache contributed by device plugins.""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import threading +from contextlib import contextmanager +from uuid import uuid4 + +from k1link.sessions.recording import ( + RecordingMaterializationCancelled, + _stage_replay_prefix, + _validate_source, + _validate_source_state, + _validated_artifact_digests, +) + +SCHEMA = "missioncore.planning-source/v1" + + +class PlanningSources: + def __init__(self, store, exporters, submap_extractors=None, scene_submap_extractors=None): + self.store = store + self.exporters = exporters + self.submap_extractors = submap_extractors or {} + self.scene_submap_extractors = scene_submap_extractors or {} + self.root = store.data_dir / "planning-sources" + self.root.mkdir(parents=True, exist_ok=True) + self.lock = threading.Lock() + self._scene_cache = None + + def get(self, session_id: str) -> dict: + detail = self.store.get_session(session_id) + exporter = self.exporters.get(detail.plugin_id) + if not detail.summary.replayable or detail.summary.lab is not None or exporter is None: + raise ValueError("В этой записи нет поддерживаемой пространственной зоны.") + source = _validate_source(self.store.prepare_replay(session_id)) + generation = hashlib.sha256( + json.dumps([SCHEMA, session_id, source.identity], default=str).encode() + ).hexdigest() + with self.lock: + path = self.root / (generation + ".json") + if path.is_file() and path.stat().st_size < 32 * 1024 * 1024: + try: + doc = json.loads(path.read_text()) + if doc["schema_version"] == SCHEMA and doc["generation"] == generation: + return doc + except (ValueError, KeyError): + pass + stage = None + candidate = self.root / ("." + uuid4().hex + ".json") + try: + digests = _validated_artifact_digests(source) + stage, primary, _ = _stage_replay_prefix(self.root, source) + exporter(primary, candidate) + if candidate.stat().st_size > 30 * 1024 * 1024: + raise ValueError("Траектория превышает размер поддерживаемой зоны.") + result = json.loads(candidate.read_text()) + if source.identity != _validate_source_state( + source + ).identity or digests != _validated_artifact_digests(source): + raise ValueError("Исходная запись изменилась во время подготовки.") + doc = { + **result, + "schema_version": SCHEMA, + "session_id": session_id, + "label": detail.as_dict()["display_name"], + "generation": generation, + "units": "m", + "frame_id": "session/" + session_id, + "source_digests": digests, + } + candidate.write_text(json.dumps(doc, allow_nan=False)) + os.replace(candidate, path) + return doc + finally: + candidate.unlink(missing_ok=True) + if stage is not None: + shutil.rmtree(stage, ignore_errors=True) + + def bound(self, session_id: str, generation: str) -> dict: + doc = self.get(session_id) + if doc["generation"] != generation: + raise ValueError("Исходная запись изменилась. Требуется повторный выбор зоны.") + return doc + + def verify(self, session_id: str, generation: str) -> dict: + doc = self.bound(session_id, generation) + source = _validate_source(self.store.prepare_replay(session_id)) + if _validated_artifact_digests(source) != doc["source_digests"]: + raise ValueError("Контрольные суммы исходной записи изменились.") + return doc + + def submap(self, session_id, generation, start, end, *, presentation=False): + with self.prepared_submaps(session_id, generation, presentation=presentation) as extract: + return extract(start, end) + + @contextmanager + def prepared_submaps(self, session_id, generation, *, presentation=False, cancel_event=None): + """One verified private snapshot for all tiles of one map build. + + Callers may assemble tiles inside this context, but must not publish + the map until exit has revalidated the original source. Cancellation + and failures discard staging without publishing a partial atlas. + """ + if cancel_event is not None and cancel_event.is_set(): + raise InterruptedError("Reference preparation cancelled.") + doc = self.verify(session_id, generation) + detail = self.store.get_session(session_id) + extractors = self.scene_submap_extractors if presentation else self.submap_extractors + extractor = extractors.get(detail.plugin_id) + if extractor is None: + raise ValueError("Эта запись не поддерживает подготовку облака для совмещения.") + source = _validate_source(self.store.prepare_replay(session_id)) + stage = None + try: + stage, primary, _ = _stage_replay_prefix(self.root, source, cancel_event=cancel_event) + + def extract(start, end): + if cancel_event is not None and cancel_event.is_set(): + raise InterruptedError("Reference preparation cancelled.") + points, evidence = extractor(primary, doc, start, end) + return points, { + **evidence, + **{ + k: doc[k] + for k in ( + "session_id", + "generation", + "label", + "frame_id", + "units", + "source_digests", + ) + }, + } + + yield extract + if cancel_event is not None and cancel_event.is_set(): + raise InterruptedError("Reference preparation cancelled.") + if source.identity != _validate_source_state(source).identity: + raise ValueError("Запись изменилась во время подготовки облака.") + self.verify(session_id, generation) + except RecordingMaterializationCancelled as exc: + raise InterruptedError("Reference preparation cancelled.") from exc + finally: + if stage is not None: + shutil.rmtree(stage, ignore_errors=True) + + def reference_map(self, session_id, generation, start, end, *, cancel_event=None): + from .reference_map import build_reference_map + + return build_reference_map( + self, session_id, generation, start, end, cancel_event=cancel_event + ) + + def scene_reference_map(self, session_id, generation, start, end, *, cancel_event=None): + from .reference_map import build_reference_map + + self.verify(session_id, generation) + key = (session_id, generation, start, end) + if self._scene_cache is not None and self._scene_cache[0] == key: + return self._scene_cache[1] + result = build_reference_map( + self, session_id, generation, start, end, cancel_event=cancel_event, presentation=True + ) + self._scene_cache = (key, result) # One display atlas, not an unbounded route cache. + return result diff --git a/src/k1link/missions/stationary_bootstrap.py b/src/k1link/missions/stationary_bootstrap.py new file mode 100644 index 0000000..b0d092a --- /dev/null +++ b/src/k1link/missions/stationary_bootstrap.py @@ -0,0 +1,294 @@ +"""Ranked stationary hypotheses followed by disjoint, fresh geometric checks. + +A prior is a hypothesis, never a CausalTracking result. Receipt continuity does +not prove SLAM frame continuity; this remains a laboratory-only protocol. +""" + +import numpy as np + +from .causal_tracking import CausalTracking +from .live_buffer import LiveCloudBuffer +from .registration import rigid +from .stationary_entry import STATIONARY_POLICY, StationaryPrefix + +BOOTSTRAP_POLICY = dict( + version="stationary-fresh-bootstrap/v3", + prefix_seconds=10.0, + maximum_motion_m=0.10, + maximum_search_wall_s=30.0, + maximum_prior_source_age_s=40.0, + maximum_pre_ready_gaps=1, + maximum_pre_validation_gaps=1, + prior_lifetime_s=10.0, + minimum_fresh_span_s=2.0, + check_interval_s=5.0, + maximum_initializations=1, + trials_per_hypothesis=1, + allow_travel_heading_fallback=False, +) + + +class StationaryBootstrap: + def __init__( + self, reference_path, *, initialization_policy=STATIONARY_POLICY, point_radius_m=20.0 + ): + self.reference_path = np.asarray(reference_path) + self.point_radius_m = point_radius_m + self.prefix = StationaryPrefix(reference_path, point_radius_m=point_radius_m) + self.initialization_policy = initialization_policy + self.gate = CausalTracking() + self.phase = "collecting" + self.reason = "prefix" + self.identity = None + self.last_event_ns = None + self.last_sequence = -1 + self.origin = None + self.segment = 0 + self.initialization_sample = None + self.search_started_ns = None + self.ready_ns = None + self.prior = None + self.fresh = None + self.floor_ns = None + self.last_check_ns = None + self.validation_pending = False + self.tracking_established = False + self.candidate_queue = [] + self.candidate_trial = 0 + self.candidate_index = None + self.dense_start_prior = False + self.retry_route_search = False + + def stop(self, reason): + self.prior = None + self.fresh = None + self.validation_pending = False + self.candidate_queue = [] + self.retry_route_search = False + self.gate.clear(reason) + self.phase = "lost" + self.reason = reason + + def ingest(self, event, segment): + identity = (event.session_id, event.generation) + if self.identity is None: + self.identity, self.origin = identity, event.monotonic_ns + if identity != self.identity: + self.stop("identity-changed") + raise ValueError("Session or generation changed during stationary bootstrap.") + if self.last_event_ns is not None and ( + event.monotonic_ns < self.last_event_ns or event.sequence <= self.last_sequence + ): + self.stop("source-order-changed") + raise ValueError("Source sequence or receipt clock regressed.") + self.last_event_ns, self.last_sequence = event.monotonic_ns, event.sequence + if segment != self.segment: + # Worker completion is not a data receipt. A gap straddling that + # instant may finish before the first fresh cloud. No validation has + # used the prior yet: start a new continuous window without extending + # its lifetime or allowing more gaps than the original prefix budget. + awaiting_first_cloud = ( + self.phase == "refreshing" + and self.prior is not None + and not self.validation_pending + and not self.fresh.events + and 0 + <= segment - self.initialization_sample["segment"] + <= BOOTSTRAP_POLICY["maximum_pre_validation_gaps"] + ) + if awaiting_first_cloud: + self.segment = segment + self._reset_fresh(self.floor_ns) + elif self.phase in {"refreshing", "validating", "tracking"}: + self.stop("receipt-gap") + self.segment = segment + if self.phase == "collecting": + self.prefix.ingest(event) + elif self.fresh is not None and event.monotonic_ns > self.floor_ns: + self.fresh.ingest(event) + + def tick(self, now_ns, segment): + self.gate.tick(now_ns, segment) + if self.phase in {"validating", "tracking"} and self.gate.reason == "stale": + self.stop("stale") + if self.phase == "refreshing" and ( + now_ns - self.ready_ns > BOOTSTRAP_POLICY["prior_lifetime_s"] * 1e9 + ): + self.stop("prior-expired") + + def start_search(self, now_ns): + if self.phase != "collecting" or self.origin is None: + return None + if (now_ns - self.origin) / 1e9 < BOOTSTRAP_POLICY["prefix_seconds"]: + return None + sample, initial, forward, meta = self.prefix.freeze() + sample["segment"] = self.segment + self.initialization_sample = sample + self.search_started_ns = now_ns + self.phase, self.reason = "searching", "bounded-entry-search" + return sample, initial, forward, meta + + def offer_prior(self, result, now_ns, segment): + if self.phase != "searching": + return dict(accepted=False, reason="inactive-initialization", provisional=False) + sample = self.initialization_sample + age = (now_ns - sample["monotonic_ns"]) / 1e9 + initialization = result.get("initialization", {}) + reason = None + if result["status"] != "candidate": + reason = { + "incomplete-search": "initialization-incomplete", + "incomplete-route-search": "initialization-incomplete", + "ambiguous-route-location": "initialization-ambiguous", + "no-route-location": "initialization-no-route-location", + }.get(initialization.get("reason"), "initialization-rejected") + elif ( + not initialization.get("complete") + or initialization.get("policy") != self.initialization_policy + ): + reason = "initialization-incomplete" + elif self.initialization_policy is STATIONARY_POLICY and len( + initialization.get("attempts", []) + ) != 108: + # Preserve the strict evidence count for the existing local-start + # protocol and use an explicit dynamic count for route retrieval. + reason = "initialization-incomplete" + elif self.initialization_policy.get("scope") == "selected-route" and ( + initialization.get("scope") != "selected-route" + or initialization.get("expected_attempts") != len(initialization.get("attempts", [])) + ): + reason = "initialization-incomplete" + elif not 0 <= (now_ns - self.search_started_ns) / 1e9 <= self.initialization_policy.get( + "maximum_search_wall_s", BOOTSTRAP_POLICY["maximum_search_wall_s"] + ): + reason = "initialization-expired" + elif not 0 <= age <= BOOTSTRAP_POLICY["maximum_prior_source_age_s"]: + reason = "prior-source-expired" + elif not 0 <= segment - sample["segment"] <= BOOTSTRAP_POLICY["maximum_pre_ready_gaps"]: + reason = "too-many-receipt-gaps" + if reason: + self.stop(reason) + return dict(accepted=False, reason=reason, age_s=age, provisional=False) + queue = initialization.get("candidate_queue", []) + if queue and queue[0].get("ambiguous", True): + self.stop("initialization-ambiguous") + return dict(accepted=False, reason=self.reason, provisional=False, age_s=age) + if queue and not np.allclose(rigid(queue[0]["T_reference_query"]), + rigid(result["T_reference_query"])): + self.stop("initialization-incomplete") + return dict(accepted=False, reason=self.reason, provisional=False, age_s=age) + self.candidate_queue = [dict(item) for item in queue[1:]] + self.candidate_trial = 1 + self.candidate_index = initialization.get("selected_candidate_index") + stages = initialization.get("stages", []) + self.dense_start_prior = bool(stages and len(stages) == 1 + and stages[0]["name"] == "dense-start") + self.prior = rigid(result["T_reference_query"]).copy() + self.ready_ns = now_ns + self.segment = segment + self._reset_fresh(now_ns) + self.phase, self.reason = "refreshing", "provisional-prior" + # Deliberately never call gate.accept with the old initialization sample. + return dict( + accepted=False, + reason=self.reason, + age_s=age, + provisional=True, + source_segment=sample["segment"], + validation_segment=segment, + ) + + def _reset_fresh(self, floor_ns): + self.floor_ns = floor_ns + self.fresh = LiveCloudBuffer(self.reference_path, point_radius_m=self.point_radius_m) + self.fresh.segment = self.segment + + def _advance_candidate(self, now_ns): + """A different hypothesis gets a new receipt fence, never a reused fit. + + This is available only before tracking. Once tracking is established, + loss must use the ordinary last-confirmed-place recovery instead. + The original prefix's source-age fence is never extended by retries. + """ + age = (now_ns - self.initialization_sample["monotonic_ns"]) / 1e9 + if not 0 <= age <= BOOTSTRAP_POLICY["maximum_prior_source_age_s"]: + self.stop("prior-source-expired") + return + candidate = self.candidate_queue.pop(0) + if candidate["ambiguous"]: + self.stop("initialization-ambiguous") + return + self.prior = rigid(candidate["T_reference_query"]).copy() + self.candidate_trial += 1 + self.candidate_index = candidate["candidate_index"] + self.ready_ns = now_ns + self.last_check_ns = None + self.validation_pending = False + self.gate.clear("next-candidate") + self._reset_fresh(now_ns) + self.phase, self.reason = "refreshing", "provisional-prior" + + def validation(self, now_ns, distance): + self.tick(now_ns, self.segment) + if self.phase not in {"refreshing", "validating", "tracking"} or self.validation_pending: + return None + if self.last_check_ns is not None and ( + now_ns - self.last_check_ns < BOOTSTRAP_POLICY["check_interval_s"] * 1e9 + ): + return None + if not self.fresh.events or ( + self.fresh.sample_ns - self.fresh.events[0]["monotonic_ns"] + < BOOTSTRAP_POLICY["minimum_fresh_span_s"] * 1e9 + ): + return None + sample = self.fresh.snapshot() + if len(sample["points"]) < 300: + return None + seed = self.prior if self.phase == "refreshing" else self.gate.matrix + if seed is None: + self.stop("missing-fresh-seed") + return None + sample["distance"] = distance + sample["fresh_floor_ns"] = self.floor_ns + self.validation_pending = True + self.last_check_ns = now_ns + self.prior = None # One trial per hypothesis; never reseed the failed one. + self._reset_fresh(sample["monotonic_ns"]) + return sample, seed.copy() + + def accept_fresh(self, result, sample, now_ns, segment): + self.tick(now_ns, segment) + if self.phase not in {"refreshing", "validating", "tracking"}: + return dict(accepted=False, reason=self.reason) + self.validation_pending = False + if not sample["events"] or any( + e["monotonic_ns"] <= sample["fresh_floor_ns"] for e in sample["events"] + ): + self.stop("pre-validation-data") + return dict(accepted=False, reason=self.reason) + temporal = self.gate.accept(result, sample, now_ns, segment) + if not temporal["accepted"]: + geometric_rejection = temporal["reason"] in { + "registration-rejected", "inconsistent-candidate" + } + if not self.tracking_established and geometric_rejection and self.candidate_queue: + failed_trial = self.candidate_trial + self._advance_candidate(now_ns) + temporal.update( + rejected_candidate_trial=failed_trial, + next_candidate_trial=self.candidate_trial if self.prior is not None else None, + continuation_reason=self.reason, + ) + else: + retry_route_search = ( + not self.tracking_established and geometric_rejection and self.dense_start_prior + ) + self.stop(temporal["reason"]) + self.retry_route_search = retry_route_search + else: + self.phase = "tracking" if self.gate.state == "tracking" else "validating" + self.reason = self.gate.reason + self.tracking_established |= self.phase == "tracking" + if self.tracking_established: + self.candidate_queue = [] + return temporal diff --git a/src/k1link/missions/stationary_entry.py b/src/k1link/missions/stationary_entry.py new file mode 100644 index 0000000..a0b24c8 --- /dev/null +++ b/src/k1link/missions/stationary_entry.py @@ -0,0 +1,94 @@ +"""Prefix-only stationary acquisition shared by live planning and archive probes.""" + +import numpy as np + +from .entry_acquisition import ENTRY_POLICY +from .live_buffer import LiveCloudBuffer + +STATIONARY_POLICY = { + **ENTRY_POLICY, + "version": "stationary-entry/v2", + "yaw_degrees": list(range(0, 360, 30)), + "maximum_entry_rotation_deg": 180.0, +} + + +class StationaryPrefix: + """Incremental prefix collector: no future events or raw-cloud retention.""" + + def __init__( + self, reference_path, *, seconds=10.0, maximum_motion_m=0.10, point_radius_m=20.0 + ): + if not 0 < seconds <= 30 or not 0 < maximum_motion_m <= 0.10: + raise ValueError("Stationary probe exceeds fixed bounds.") + self.buffer = LiveCloudBuffer(reference_path, point_radius_m=point_radius_m) + self.seconds = seconds + self.maximum_motion_m = maximum_motion_m + self.origin = None + self.identity = None + self.first_position = None + self.maximum_motion = 0.0 + self.last_elapsed = 0.0 + self.source_events = [] + + def ingest(self, event): + if self.origin is None: + self.origin = event.monotonic_ns + self.identity = (event.session_id, event.generation) + elapsed = (event.monotonic_ns - self.origin) / 1e9 + if elapsed > self.seconds: + return False + if elapsed < self.last_elapsed or self.identity != (event.session_id, event.generation): + raise ValueError("Stationary prefix identity or clock changed.") + if len(self.source_events) >= 2048: + raise ValueError("Stationary prefix exceeds event budget.") + if event.kind == "pose": + position = np.asarray(event.position, dtype=float) + if self.first_position is None: + self.first_position = position.copy() + self.maximum_motion = max( + self.maximum_motion, float(np.linalg.norm(position - self.first_position)) + ) + if self.maximum_motion > self.maximum_motion_m: + raise ValueError("Prefix is not stationary within the declared motion limit.") + self.buffer.ingest(event) + if self.buffer.gaps: + raise ValueError("Stationary prefix contains a receipt gap.") + self.last_elapsed = elapsed + self.source_events.append(dict(sequence=event.sequence, kind=event.kind, time_s=elapsed)) + return True + + def freeze(self): + if self.first_position is None or self.last_elapsed < self.seconds - 0.5: + raise ValueError("Incomplete stationary prefix.") + sample = self.buffer.snapshot() + if len(sample["points"]) < 300: + raise ValueError("Insufficient stationary geometry.") + path = self.buffer.reference_path + initial = np.eye(4) + initial[:3, 3] = path[0] - self.first_position + forward = next( + (p - path[0] for p in path[1:] if np.linalg.norm((p - path[0])[:2]) >= 3), None + ) + if forward is None: + raise ValueError("Reference lacks a usable route basis.") + return ( + sample, + initial, + forward, + dict( + seconds=self.seconds, + last_elapsed_s=self.last_elapsed, + maximum_motion_m=self.maximum_motion, + event_count=len(self.source_events), + source_events=list(self.source_events), + ), + ) + + +def stationary_prefix(events, reference_path, *, seconds=10.0, maximum_motion_m=0.10): + collector = StationaryPrefix(reference_path, seconds=seconds, maximum_motion_m=maximum_motion_m) + for event in events: + if not collector.ingest(event): + break + return collector.freeze() diff --git a/src/k1link/missions/stationary_live.py b/src/k1link/missions/stationary_live.py new file mode 100644 index 0000000..c0f2421 --- /dev/null +++ b/src/k1link/missions/stationary_live.py @@ -0,0 +1,629 @@ +"""Stationary bootstrap on the existing exclusive, read-only planning ingress.""" + +import json +import subprocess + +import numpy as np + +from k1link.artifacts import utc_now_iso + +from .live_buffer import LiveCloudBuffer, PoseDiscontinuity +from .live_presentation import PRESENTATION_POLICY +from .reference_window import ReferenceCoverageError, ReferenceWindowIndex, reference_window +from .route_relocalization import ROUTE_RELOCALIZATION_POLICY +from .stationary_bootstrap import BOOTSTRAP_POLICY, StationaryBootstrap + +PHASE_MESSAGE = { + "waiting-cloud": "Ожидание облака точек после подготовки сканера.", + "collecting": "Накопление данных. Сканер должен оставаться неподвижным.", + "searching": ( + "Точная привязка у стартовой зоны; при честном отказе — поиск по выбранному " + "маршруту. Ожидание на месте." + ), + "refreshing": "Подтверждение привязки по новым кадрам. Ожидание на месте.", + "validating": "Подтверждение привязки по новым кадрам. Ожидание на месте.", + "tracking": "Привязка подтверждена. Можно начинать проверочный проход.", + "lost": "Привязка потеряна. Остановитесь; поиск по новым данным продолжается.", +} + + +def phase_message(boot): + if boot.phase == "lost" and not boot.tracking_established: + if boot.reason == "initialization-ambiguous": + return ( + "Синхронизация маршрута не выполнена: найдены несколько похожих участков. " + "Останьтесь на месте, измените обзор сцены или выберите более различимый участок." + ) + if boot.reason == "initialization-no-route-location": + return ( + "Синхронизация маршрута не выполнена: облако не дало устойчивого совпадения " + "с выбранным маршрутом. Можно переместить сканер в другую точку, остановить " + "его там и затем нажать «Переинициализировать»." + ) + if boot.reason in {"initialization-incomplete", "initialization-expired"}: + return ( + "Синхронизация маршрута не завершилась. Остановите устройство и запись, " + "затем начните новое исследование и дождитесь неподвижной калибровки." + ) + return ( + "Синхронизация маршрута не выполнена. Убедитесь, что сканер находится " + "у исследованного участка; можно выбрать другую различимую точку, остановиться " + "и затем нажать «Переинициализировать»." + ) + return PHASE_MESSAGE[boot.phase] + + +def run_stationary_live(service, source, run_id, executor, clock, initialize, calculate): + """Own calculations only. Device start/stop and capture remain plugin-owned.""" + directory = service.directory(run_id) + query_radius_m = ROUTE_RELOCALIZATION_POLICY["query_radius_m"] + buffer = LiveCloudBuffer(service.reference_path, point_radius_m=query_radius_m) + reference_index = ReferenceWindowIndex(service.reference) + boot = None + latest_pose = None + query_key = None + future = pending = None + sequence = 0 + last_snapshot = 0.0 + last_phase = None + transitions = [] + end_reason = "cancelled" + ever_tracking = False + recovering = False + waiting_retry = False + recovery_attempt = 0 + recovery_position = None + route_search_only = False + + def begin_recovery(reason): + # Retain only the last confirmed place as a SEARCH HINT. Neither the + # old matrix nor old receipts may grant tracking in this new attempt. + nonlocal boot, latest_pose, last_phase, recovering, recovery_attempt + nonlocal route_search_only + route_search_only = False + boot = None + latest_pose = None + last_phase = None + recovering = True + recovery_attempt += 1 + with service.lock: + service.accepted_sample = None + service.last_result_ns = 0 + service.update( + planning_phase="recovering", + planning_reason=reason, + tracking_state="lost", + tracking_established=ever_tracking, + tracking_reason=reason, + recovery_attempt=recovery_attempt, + recovery_reference_position=recovery_position, + message="Остановитесь. Восстанавливаем привязку по новым данным; запись продолжается.", + ) + + def initialization_attempt(): + # The worker owns a stable run snapshot. Do not invoke the projected + # UI view merely to stamp internal evidence for one calculation. + return int(service.run.get("initialization_attempt", 1)) + + def reset_initialization(attempt): + """Discard only derived evidence after an operator-directed retry. + + The source session remains open and keeps recording. The following + usable pose/cloud pair starts a completely new stationary prefix, so a + failed location hypothesis can never leak into the next attempt. + """ + nonlocal buffer, boot, latest_pose, last_snapshot, last_phase, waiting_retry + nonlocal route_search_only + route_search_only = False + waiting_retry = False + buffer = LiveCloudBuffer(service.reference_path, point_radius_m=query_radius_m) + boot = None + latest_pose = None + last_snapshot = 0.0 + last_phase = None + transitions.append( + dict( + phase="waiting-cloud", + reason="operator-reinitialize", + tracking_state="acquiring", + streak=0, + segment=0, + initialization_attempt=attempt, + monotonic_ns=clock.monotonic_ns(), + at_utc=utc_now_iso(), + ) + ) + service.update( + state="running", + planning_phase="waiting-cloud", + planning_reason="operator-reinitialize", + tracking_state="acquiring", + tracking_established=False, + tracking_reason="operator-reinitialize", + phase_transitions=transitions[-100:], + message=( + "Предыдущая попытка привязки отброшена. Переинициализация начинается " + "в выбранной точке: оставьте сканер неподвижно до окончания накопления." + ), + ) + + def retry_prefix_interrupted(exc): + """Return a moved retry to the actionable lost state without stopping capture.""" + nonlocal buffer, boot, latest_pose, last_snapshot, last_phase, waiting_retry + reason = str(exc) + attempt = initialization_attempt() + transitions.append( + dict( + phase="lost", + reason="retry-prefix-interrupted", + tracking_state="lost", + streak=0, + segment=buffer.segment, + initialization_attempt=attempt, + diagnostic=reason, + monotonic_ns=clock.monotonic_ns(), + at_utc=utc_now_iso(), + ) + ) + buffer = LiveCloudBuffer(service.reference_path, point_radius_m=query_radius_m) + boot = None + latest_pose = None + last_snapshot = 0.0 + last_phase = None + waiting_retry = True + service.update( + state="running", + planning_phase="lost", + planning_reason="retry-prefix-interrupted", + tracking_state="lost", + tracking_established=False, + tracking_reason="retry-prefix-interrupted", + reinitialization_diagnostic=reason, + phase_transitions=transitions[-100:], + message=( + "Переинициализация не началась: сканер сдвинулся или поток прервался во время " + "накопления. Остановите его в выбранной точке " + "и нажмите «Переинициализировать» ещё раз." + ), + ) + + def publish_phase(*, force=False): + nonlocal last_phase, ever_tracking, recovering + if boot is None: + return + ever_tracking |= boot.tracking_established + if boot.phase == "tracking": + recovering = False + phase = "recovering" if recovering else boot.phase + state = (phase, boot.reason, boot.gate.state, boot.gate.reason) + if state == last_phase and not force: + return + last_phase = state + transitions.append( + dict( + phase=phase, + recovery_stage=boot.phase if recovering else None, + recovery_attempt=recovery_attempt, + reason=boot.reason, + tracking_state=boot.gate.state, + streak=boot.gate.streak, + candidate_trial=boot.candidate_trial, + candidate_index=boot.candidate_index, + segment=buffer.segment, + monotonic_ns=clock.monotonic_ns(), + at_utc=utc_now_iso(), + ) + ) + if boot.gate.matrix is None: + with service.lock: + service.accepted_sample = None + service.last_result_ns = 0 + service.update( + planning_phase=phase, + planning_reason=boot.reason, + tracking_state=boot.gate.state, + tracking_established=ever_tracking, + tracking_reason=boot.gate.reason, + phase_transitions=transitions[-100:], + message=( + "Остановитесь. Восстанавливаем привязку по новым данным; запись продолжается." + if recovering + else phase_message(boot) + ), + ) + + def stage(sample, role, extra=None): + nonlocal sequence + sequence += 1 + target = directory / f"step-{sequence:03d}" + target.mkdir() + info = dict( + session_id=query_key[0], + generation=query_key[1], + role=role, + events=sample["events"], + query_path=sample["path"].tolist(), + sequence=sample["sequence"], + segment=sample["segment"], + distance_m=sample["distance"], + fresh_floor_ns=sample.get("fresh_floor_ns"), + requested_monotonic_ns=clock.monotonic_ns(), + sampled_at_utc=utc_now_iso(), + raw_capture_owned_by="observation-session-recorder", + point_radius_m=sample.get("point_radius_m"), + initialization_attempt=initialization_attempt(), + recovery_attempt=recovery_attempt, + recovery_reference_position=recovery_position if recovering else None, + candidate_trial=boot.candidate_trial if boot else None, + candidate_index=boot.candidate_index if boot else None, + **(extra or {}), + ) + (target / "source.json").write_text(json.dumps(info, allow_nan=False)) + return target, (sample, role, target, info) + + def finish(*, active=True): + nonlocal future, recovery_position + if future is None or (active and not future.done()): + return + try: + result = future.result() + except (ValueError, OSError, subprocess.SubprocessError) as exc: + future = None + sample, role, target, info = pending + (target / "decision.json").write_text( + json.dumps( + dict( + temporal=dict(accepted=False, reason="calculation-unavailable"), + diagnostic=f"{type(exc).__name__}: {exc}", + completed_monotonic_ns=clock.monotonic_ns(), + ) + ) + ) + current = source.snapshot() + if active and current["active"] and not current.get("spatial_stop_requested", False): + boot.stop("calculation-unavailable") + publish_phase(force=True) + return + future = None + sample, role, target, info = pending + current = source.snapshot() + valid = ( + active + and not service.cancel.is_set() + and current["active"] + and not current.get("spatial_stop_requested", False) + and (current["session_id"], current["session_generation"]) == query_key + ) + now = clock.monotonic_ns() + if not valid: + reason = ( + "spatial-stop-requested" + if current.get("spatial_stop_requested", False) + else "input-ended" + ) + temporal = dict(accepted=False, reason=reason) + boot.stop(reason) + elif role == "stationary-initialization": + temporal = boot.offer_prior(result, now, buffer.segment) + else: + temporal = boot.accept_fresh(result, sample, now, buffer.segment) + if temporal["accepted"] and boot.gate.state == "tracking": + pose = np.asarray(sample["path"][-1]) + matrix = boot.gate.matrix + recovery_position = (matrix[:3, :3] @ pose + matrix[:3, 3]).tolist() + if valid and role == "stationary-initialization": + service.update( + initialization_result={ + k: v for k, v in result.items() if k != "matched_query_indices" + }, + initialization_temporal=temporal, + ) + elif valid: + service.commit_result( + result, + sample, + temporal, + boot.gate.state, + phase=boot.phase, + message=phase_message(boot), + tracking_established=ever_tracking or boot.tracking_established, + ) + (target / "decision.json").write_text( + json.dumps( + dict( + completed_monotonic_ns=now, + completed_at_utc=utc_now_iso(), + temporal=temporal, + planning_phase=boot.phase, + tracking_state=boot.gate.state, + streak=boot.gate.streak, + worker_wall_s=(now - info["requested_monotonic_ns"]) / 1e9, + ), + allow_nan=False, + ) + ) + if valid: + publish_phase(force=True) + + try: + while not service.cancel.is_set(): + current = source.snapshot() + if query_key: + if (current["session_id"], current["session_generation"]) != query_key: + raise ValueError( + "Сессия сканера сменилась. Для нового прохода требуется новое исследование." + ) + if current.get("spatial_stop_requested", False) or not current["active"]: + end_reason = ( + "spatial-stop-requested" + if current.get("spatial_stop_requested", False) + else "input-ended" + ) + break + retry_attempt = service.consume_reinitialization(run_id) + if retry_attempt is not None: + if future is not None: + # The public operation admits only a terminal initial + # failure. Keep this fence in case a caller races an + # internal state update. + raise RuntimeError("Нельзя переинициализировать во время расчёта привязки.") + reset_initialization(retry_attempt) + continue + now = clock.monotonic() + if boot is not None: + boot.tick(clock.monotonic_ns(), buffer.segment) + finish() + current = source.snapshot() + if not current["active"] or current.get("spatial_stop_requested", False): + continue + publish_phase() + if boot.phase == "lost" and ever_tracking and future is None: + begin_recovery(boot.reason) + elif boot.phase == "lost" and boot.retry_route_search and future is None: + # Fresh confirmation disproved the dense-start hypothesis. + # Recollect in the same recording before whole-route search; + # neither an old prefix nor a failed transform is reused. + route_search_only = True + boot = None + latest_pose = None + last_phase = None + event = source.take("planning-" + run_id) + current = source.snapshot() + if query_key and (current["session_id"], current["session_generation"]) != query_key: + raise ValueError( + "Сессия сканера сменилась. Для нового прохода требуется новое исследование." + ) + if query_key and not current["active"]: + end_reason = "input-ended" + break + if query_key and current.get("spatial_stop_requested", False): + end_reason = "spatial-stop-requested" + break + if event is not None: + if event.generation <= service.run["baseline_generation"]: + continue + if event.session_id in { + service.run["draft"]["zone"]["session_id"], + service.run["baseline_session_id"], + }: + raise ValueError("Повторный проход должен иметь новую идентичность записи.") + key = (event.session_id, event.generation) + if query_key is None: + query_key = key + service.update( + state="running", + query_session_id=key[0], + query_generation=key[1], + planning_phase="waiting-cloud", + message=PHASE_MESSAGE["waiting-cloud"], + ) + if key != query_key: + raise ValueError("Изменилась идентичность входного потока.") + if event.kind in {"session-end", "spatial-stop-requested"}: + end_reason = ( + "spatial-stop-requested" + if event.kind == "spatial-stop-requested" + else "input-ended" + ) + break + if event.kind not in {"pose", "points"}: + continue + if event.kind == "pose": + service.observe_pose(event) + # A rejected location is intentionally a non-terminal state. + # Keep raw capture running, but do not retain numerical + # receipts while the operator carries the scanner to a better + # point before requesting the next stationary prefix. + if waiting_retry or ( + boot is not None and boot.phase == "lost" and not ever_tracking + ): + try: + buffer.ingest(event) + except PoseDiscontinuity: + buffer = LiveCloudBuffer( + service.reference_path, point_radius_m=query_radius_m + ) + buffer.ingest(event) + service.observe_display(event, buffer, clock.monotonic_ns()) + continue + try: + if boot is None: + if event.kind == "pose": + latest_pose = event + continue + if ( + latest_pose is None + or not 0 <= event.monotonic_ns - latest_pose.monotonic_ns <= 500_000_000 + ): + continue + # Hardware calibration may precede the first usable cloud by + # many seconds. It is not part of our ten-second prefix. + if not recovering: + buffer = LiveCloudBuffer( + service.reference_path, point_radius_m=query_radius_m + ) + buffer.ingest(latest_pose) + buffer.ingest(event) + if len(buffer.snapshot()["points"]) < 300: + continue + boot = StationaryBootstrap( + service.reference_path, + initialization_policy=ROUTE_RELOCALIZATION_POLICY, + point_radius_m=query_radius_m, + ) + service.observe_display(latest_pose, buffer, clock.monotonic_ns()) + boot.ingest(latest_pose, buffer.segment) + boot.ingest(event, buffer.segment) + else: + buffer.ingest(event) + boot.ingest(event, buffer.segment) + except ValueError as exc: + if ever_tracking and ( + isinstance(exc, PoseDiscontinuity) + or (boot is not None and boot.phase == "collecting") + ): + distance = buffer.distance + segment = buffer.segment + 1 + buffer = LiveCloudBuffer( + service.reference_path, point_radius_m=query_radius_m + ) + buffer.distance = distance # Do not add the coordinate jump as travel. + buffer.segment = segment + begin_recovery(str(exc)) + continue + if boot is not None and boot.phase == "collecting": + retry_prefix_interrupted(exc) + continue + raise + service.observe_display(event, buffer, clock.monotonic_ns()) + boot.tick(clock.monotonic_ns(), buffer.segment) + publish_phase() + if ( + event.kind == "points" + and now - last_snapshot >= PRESENTATION_POLICY["snapshot_s"] + ): + sample = buffer.snapshot() + service.update_sample(sample, clock.monotonic_ns()) + service.update( + distance_m=sample["distance"], + query_points=len(sample["points"]), + receipt_gaps=sample["gaps"], + ) + last_snapshot = now + if buffer.distance >= service.run["maximum_distance_m"]: + # A pose can reach the limit before the next cloud snapshot. + service.update_sample(buffer.snapshot(), clock.monotonic_ns()) + service.update(distance_m=buffer.distance) + end_reason = "distance-limit" + break + if boot is None or future is not None: + continue + # Do not freeze ahead of an already queued prefix receipt. + if ( + event is None + or event.monotonic_ns > boot.origin + BOOTSTRAP_POLICY["prefix_seconds"] * 1e9 + ): + try: + acquisition = boot.start_search(clock.monotonic_ns()) + except ValueError as exc: + # The retry may be moved while no new receipt arrives. Its + # prefix is then incomplete at the freeze boundary; that is + # actionable operator feedback, not a terminal planning + # failure and must leave raw recording running. + if ever_tracking: + begin_recovery(str(exc)) + continue + retry_prefix_interrupted(exc) + continue + if acquisition is not None: + sample, _hint, _forward, meta = acquisition + target, pending = stage( + sample, + "stationary-initialization", + { + "prefix": meta, + "initialization_scope": "last-confirmed-place-first" + if recovering + else "route-only" if route_search_only else "start-first", + "initialization_policy": ROUTE_RELOCALIZATION_POLICY, + }, + ) + future = executor.submit( + initialize, + target, + service.reference, + service.reference_path, + sample["points"], + sample["path"][0], + **({"reference_position": recovery_position} if recovering else {}), + **({"route_only": True} if route_search_only else {}), + ) + publish_phase() + continue + if event is not None and event.kind == "points": + validation = boot.validation(clock.monotonic_ns(), buffer.distance) + if validation is not None: + sample, hint = validation + try: + reference, window = reference_window( + service.reference, sample, hint, index=reference_index + ) + except ReferenceCoverageError as exc: + target, _ = stage( + sample, "fresh-validation", {"reference_window_error": str(exc)} + ) + (target / "decision.json").write_text( + json.dumps( + dict( + temporal=dict(accepted=False, reason="reference-coverage"), + diagnostic=str(exc), + completed_monotonic_ns=clock.monotonic_ns(), + ) + ) + ) + boot.stop("reference-coverage") + publish_phase(force=True) + continue + target, pending = stage( + sample, "fresh-validation", {"reference_window": window} + ) + future = executor.submit(calculate, target, reference, sample["points"], hint) + if boot is not None: + boot.stop(end_reason) + with service.lock: + service.accepted_sample = None + service.last_result_ns = 0 + service.update( + state="cancelled" if service.cancel.is_set() else "completed", + planning_phase="ended", + planning_reason=end_reason, + tracking_state="lost", + tracking_reason=end_reason, + termination_reason=end_reason, + message=( + "Достигнут предел проверочного прохода. " + "Запись управляется штатными кнопками сканера." + if end_reason == "distance-limit" + else "Исследование завершено. Запись управляется штатными кнопками сканера." + ), + finished_at_utc=utc_now_iso(), + ) + # Publish ended before waiting for an already-running bounded fit. + # Late numerical output is retained as rejected evidence, never live. + if future and not service.cancel.is_set(): + finish(active=False) + except ValueError as exc: + # Technical reasons are retained in failure.txt; operator copy is neutral. + text = str(exc) + if "not stationary" in text: + raise ValueError( + "Сканер перемещён до завершения накопления. " + "Для повторной проверки начните новый проход и дождитесь подтверждения на месте." + ) from exc + if "stationary prefix" in text.lower() or "stationary geometry" in text.lower(): + service.update(planning_diagnostic=text) + raise ValueError( + "Недостаточно непрерывных данных для привязки. " + "Завершите проход и начните повторную проверку." + ) from exc + raise diff --git a/src/k1link/missions/stationary_replay.py b/src/k1link/missions/stationary_replay.py new file mode 100644 index 0000000..3cb862c --- /dev/null +++ b/src/k1link/missions/stationary_replay.py @@ -0,0 +1,230 @@ +"""Bounded 1x replay of stationary collection, provisional search and fresh checks.""" + +import json +import time +from concurrent.futures import ThreadPoolExecutor + +import numpy as np + +from k1link.artifacts import utc_now_iso + +from .causal_replay import digest +from .causal_tracking import TRACKING_POLICY +from .entry_acquisition_worker import run_entry_acquisition +from .live_buffer import LiveCloudBuffer +from .registration import POLICY +from .registration_worker import run_registration +from .stationary_bootstrap import BOOTSTRAP_POLICY, StationaryBootstrap +from .stationary_entry import STATIONARY_POLICY + + +def replay_stationary( + events, + reference, + reference_path, + directory, + *, + calculate=run_registration, + initialize=run_entry_acquisition, + max_seconds=65.0, + max_distance=40.0, +): + if not 0 < max_seconds <= 120 or not 0 < max_distance <= 40: + raise ValueError("Replay exceeds functional probe bounds.") + iterator = iter(events) + event = next(iterator, None) + if event is None: + raise ValueError("Empty replay.") + directory.mkdir(parents=True, exist_ok=False) + origin, started = event.monotonic_ns, time.monotonic_ns() + boot, buffer = StationaryBootstrap(reference_path), LiveCloudBuffer(reference_path) + deliveries, steps, transitions = [], [], [] + report = dict( + schema_version="missioncore.stationary-causal-replay/v1", + created_at_utc=utc_now_iso(), + started_monotonic_ns=started, + query_origin_monotonic_ns=origin, + pace=1, + bootstrap_policy=BOOTSTRAP_POLICY, + registration_policy=POLICY, + stationary_policy=STATIONARY_POLICY, + tracking_policy=TRACKING_POLICY, + maximum_seconds=max_seconds, + maximum_distance_m=max_distance, + vehicle_control=False, + localization_confirmed=False, + slam_reset_verified=False, + first_prior_s=None, + first_candidate_s=None, + first_tracking_s=None, + steps=steps, + transitions=transitions, + ) + future, pending, last_state = None, None, None + + def source_now(): + return origin + time.monotonic_ns() - started + + def observe(now): + nonlocal last_state + state = (boot.phase, boot.reason, boot.gate.state, boot.gate.reason, buffer.segment) + if state != last_state: + transitions.append( + dict( + time_s=(now - origin) / 1e9, + phase=boot.phase, + reason=boot.reason, + tracking_state=boot.gate.state, + tracking_reason=boot.gate.reason, + streak=boot.gate.streak, + segment=buffer.segment, + ) + ) + last_state = state + + def finish(now, *, active=True): + nonlocal future, pending + if future is None or not future.done(): + return + sample, info = pending + try: + result = future.result() + except (ValueError, RuntimeError) as exc: + result = dict(status="rejected", reasons=["worker-error"], error=str(exc)) + future = None + if not active: + temporal = dict(accepted=False, reason="input-ended") + elif info["role"] == "stationary-initialization": + temporal = boot.offer_prior(result, now, buffer.segment) + if temporal.get("provisional"): + report["first_prior_s"] = (now - origin) / 1e9 + else: + temporal = boot.accept_fresh(result, sample, now, buffer.segment) + info.update( + completed_s=(now - origin) / 1e9, + worker_wall_s=(now - info.pop("_started_ns")) / 1e9, + temporal=temporal, + phase=boot.phase, + tracking_state=boot.gate.state, + streak=boot.gate.streak, + result={k: v for k, v in result.items() if k != "matched_query_indices"}, + ) + steps.append(info) + if temporal["accepted"] and report["first_candidate_s"] is None: + report["first_candidate_s"] = info["completed_s"] + if boot.gate.state == "tracking" and report["first_tracking_s"] is None: + report["first_tracking_s"] = info["completed_s"] + observe(now) + + def stage(sample, now, role, extra=None): + step = len(steps) + 1 + target = directory / f"step-{step:03d}" + target.mkdir() + info = dict( + step=step, + role=role, + requested_s=(now - origin) / 1e9, + sample_s=(sample["monotonic_ns"] - origin) / 1e9, + sequence=sample["sequence"], + segment=sample["segment"], + distance_m=sample["distance"], + points=len(sample["points"]), + source_events=sample["events"], + fresh_floor_ns=sample.get("fresh_floor_ns"), + **(extra or {}), + ) + (target / "source.json").write_text(json.dumps(info, allow_nan=False)) + np.save(target / "query-path.npy", sample["path"], allow_pickle=False) + return target, (sample, {**info, "_started_ns": now}) + + pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="stationary-replay-fit") + try: + while event is not None: + due = (event.monotonic_ns - origin) / 1e9 + if due > max_seconds: + report["end_reason"] = "time-bound" + break + now = source_now() + if (now - origin) / 1e9 > max_seconds + 35: + raise ValueError("Replay exceeded wall-clock allowance.") + boot.tick(now, buffer.segment) + finish(now) + # All prefix receipts have been delivered before freezing. The pending + # event contributes only its due time, never its future pose or points. + if future is None and due > BOOTSTRAP_POLICY["prefix_seconds"]: + acquisition = boot.start_search(now) + if acquisition is not None: + sample, initial, basis, meta = acquisition + target, pending = stage( + sample, now, "stationary-initialization", {"prefix": meta} + ) + future = pool.submit( + initialize, + target, + reference, + sample["points"], + initial, + sample["path"][0], + basis, + mode="stationary", + ) + observe(now) + if now < event.monotonic_ns: + time.sleep(min(0.02, (event.monotonic_ns - now) / 1e9)) + continue + before = time.monotonic_ns() + buffer.ingest(event) + boot.ingest(event, buffer.segment) + deliveries.append( + dict( + sequence=event.sequence, + kind=event.kind, + time_s=due, + lateness_s=max(0, (now - event.monotonic_ns) / 1e9), + ingest_s=(time.monotonic_ns() - before) / 1e9, + ) + ) + boot.tick(source_now(), buffer.segment) + observe(source_now()) + if buffer.distance >= max_distance: + report["end_reason"] = "distance-bound" + break + if future is None and event.kind == "points" and len(steps) < 24: + now = source_now() + validation = boot.validation(now, buffer.distance) + if validation is not None: + sample, hint = validation + target, pending = stage(sample, now, "fresh-validation") + future = pool.submit(calculate, target, reference, sample["points"], hint) + event = next(iterator, None) + report.setdefault("end_reason", "input-ended") + report["input_end_s"] = (source_now() - origin) / 1e9 + boot.stop("input-ended") + observe(source_now()) + while future is not None: + finish(source_now(), active=False) + if future is not None: + time.sleep(0.02) + report["state"] = "completed" + except Exception as exc: + boot.stop("replay-error") + observe(source_now()) + report.update(state="error", error=f"{type(exc).__name__}: {exc}") + raise + finally: + pool.shutdown(wait=True, cancel_futures=True) + close = getattr(iterator, "close", None) + if close: + close() + report.update( + finished_at_utc=utc_now_iso(), + elapsed_s=(time.monotonic_ns() - started) / 1e9, + gaps=buffer.gaps, + distance_m=buffer.distance, + ) + (directory / "deliveries.json").write_text(json.dumps(deliveries)) + report["artifacts"] = { + str(p.relative_to(directory)): digest(p) for p in directory.rglob("*") if p.is_file() + } + (directory / "report.json").write_text(json.dumps(report, allow_nan=False, indent=2)) + return report diff --git a/src/k1link/sessions/live_planning.py b/src/k1link/sessions/live_planning.py new file mode 100644 index 0000000..b844ff3 --- /dev/null +++ b/src/k1link/sessions/live_planning.py @@ -0,0 +1,30 @@ +"""Read-only, vendor-neutral preview input. No capture or device commands.""" + +from dataclasses import dataclass +from typing import Protocol + + +@dataclass(frozen=True) +class PlanningLiveEvent: + session_id: str + generation: int + sequence: int + monotonic_ns: int + epoch_ns: int + kind: str + points: object = None + position: object = None + orientation_xyzw: object = None + + +class PlanningLiveSource(Protocol): + """Return one receipt per take, or None only when no receipt is available. + + Auxiliary modalities use kind='ignored' with identity/timestamps preserved + and no payload. They are neither geometry nor an empty-queue signal. + """ + + def snapshot(self) -> dict: ... + def open(self, consumer_id: str) -> None: ... + def take(self, consumer_id: str) -> PlanningLiveEvent | None: ... + def close(self, consumer_id: str) -> None: ... diff --git a/src/k1link/sessions/overview.py b/src/k1link/sessions/overview.py new file mode 100644 index 0000000..9ce195f --- /dev/null +++ b/src/k1link/sessions/overview.py @@ -0,0 +1,128 @@ +"""On-demand, source-bound overview cache. No catalog-wide decoding.""" +from __future__ import annotations + +import hashlib +import json +import logging +import os +import shutil +import threading +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Mapping +from uuid import uuid4 + +from .store import SessionStore +from .plugin_contract import RecordingExporter +from .recording import (_validate_source, _validate_source_state, + _validated_artifact_digests, _stage_replay_prefix) + +SCHEMA = 'missioncore.session-overview/v1' + + +class SessionOverviewService: + def __init__(self, store: SessionStore, exporters: Mapping[str, RecordingExporter]): + self.store = store + self.exporters = exporters + self.root = store.data_dir / 'session-overviews' + self.root.mkdir(parents=True, exist_ok=True) + self.executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix='session-overview') + self.guard = threading.RLock() + self.cancel = threading.Event() + self.jobs: dict[str, dict] = {} + + def close(self) -> None: + self.cancel.set() + self.executor.shutdown(wait=True, cancel_futures=True) + + def get(self, session_id: str, *, start: bool = True) -> dict: + detail = self.store.get_session(session_id) + base = {'schema_version': SCHEMA, 'session': detail.as_dict()} + exporter = self.exporters.get(detail.plugin_id) + if not detail.summary.replayable or detail.summary.lab is not None or exporter is None: + return {**base, 'state': 'ready', 'metrics': None, 'scene_url': None} + command = self.store.prepare_replay(session_id) + source = _validate_source(command) + identity = hashlib.sha256(json.dumps([SCHEMA, session_id, source.identity], default=str).encode()).hexdigest() + directory = self.root / identity + cached = self._cached(directory) + if cached: + return {**base, **cached, 'generation': identity, 'scene_url': f'/api/v1/observation-sessions/{session_id}/overview/scene.rrd?generation={identity}'} + with self.guard: + if identity in self.jobs: + return {**base, **self.jobs[identity]} + if not start: + return {**base, 'state': 'missing'} + if sum(j['state'] in {'queued', 'preparing'} for j in self.jobs.values()) >= 8: + return {**base, 'state': 'error', 'message': 'Подготовка занята. Повторите позже.'} + self.jobs[identity] = {'state': 'queued', 'messages_processed': 0} + self.executor.submit(self._build, identity, source, exporter) + return {**base, **self.jobs[identity]} + + def retry(self, session_id: str) -> dict: + detail = self.store.get_session(session_id) + if detail.summary.replayable and detail.summary.lab is None: + source = _validate_source(self.store.prepare_replay(session_id)) + identity = hashlib.sha256(json.dumps([SCHEMA, session_id, source.identity], default=str).encode()).hexdigest() + with self.guard: + if self.jobs.get(identity, {}).get('state') == 'error': + self.jobs.pop(identity, None) + return self.get(session_id) + + def scene(self, session_id: str, generation: str) -> Path: + current = self.get(session_id, start=False) + if current.get('state') != 'ready' or current.get('generation') != generation: + raise ValueError('overview generation is unavailable') + return self.root / generation / 'scene.rrd' + + def _cached(self, directory: Path) -> dict | None: + try: + report = directory / 'overview.json' + if report.stat().st_size > 2 * 1024 * 1024: + return None + doc = json.loads(report.read_text()) + stat = (directory / 'scene.rrd').stat() + if doc['schema_version'] != SCHEMA or [stat.st_size, stat.st_mtime_ns] != doc['scene_stat']: + return None + return {'state': 'ready', 'metrics': doc['metrics'], 'scene_sha256': doc['scene_sha256']} + except (OSError, ValueError, KeyError, TypeError): + return None + + def _build(self, identity: str, source, exporter: RecordingExporter) -> None: + directory = self.root / identity + directory.mkdir(exist_ok=True) + candidate = directory / ('.' + uuid4().hex + '.rrd') + staged = None + try: + with self.guard: + self.jobs[identity] = {'state': 'preparing', 'messages_processed': 0} + digests = _validated_artifact_digests(source) + staged, primary, _ = _stage_replay_prefix(directory, source, cancel_event=self.cancel) + def pulse(): + with self.guard: + self.jobs[identity]['messages_processed'] += 100 + metrics = dict(exporter(primary, candidate, cancel_event=self.cancel, activity_callback=pulse)) + if self.cancel.is_set() or source.identity != _validate_source_state(source).identity: + raise ValueError('overview source changed') + if digests != _validated_artifact_digests(source): + raise ValueError('overview source changed') + if candidate.stat().st_size > 32 * 1024 * 1024: + raise ValueError('overview exceeded display budget') + digest = hashlib.sha256(candidate.read_bytes()).hexdigest() + os.replace(candidate, directory / 'scene.rrd') + stat = (directory / 'scene.rrd').stat() + document = {'schema_version': SCHEMA, 'metrics': metrics, 'source_digests': digests, + 'scene_sha256': digest, 'scene_stat': [stat.st_size, stat.st_mtime_ns]} + temporary = directory / '.overview.json' + temporary.write_text(json.dumps(document, allow_nan=False)) + os.replace(temporary, directory / 'overview.json') + with self.guard: + self.jobs.pop(identity, None) + except Exception: + logging.getLogger(__name__).exception('Session overview preparation failed') + with self.guard: + self.jobs[identity] = {'state': 'error', 'message': 'Не удалось подготовить обзор записи.'} + finally: + candidate.unlink(missing_ok=True) + if staged is not None: + shutil.rmtree(staged, ignore_errors=True) diff --git a/src/k1link/sessions/overview_spatial.py b/src/k1link/sessions/overview_spatial.py new file mode 100644 index 0000000..d844f5a --- /dev/null +++ b/src/k1link/sessions/overview_spatial.py @@ -0,0 +1,86 @@ +"""View-only height clipping and camera presets for bounded overview RRDs.""" +from functools import lru_cache +from pathlib import Path +from typing import Literal + +import numpy as np +import rerun as rr +from rerun import blueprint as rrb +from rerun.experimental import RrdReader + + +@lru_cache(maxsize=1) +def _geometry(path: Path, size: int, modified: int): + if size > 32 * 1024 * 1024: + raise ValueError('overview exceeds display budget') + reader = RrdReader(path) + entry = reader.recordings()[0] + xyz = np.empty((0, 3), dtype=np.float32) + colors = np.empty(0, dtype=np.uint32) + for chunk in reader.stream(): + if chunk.entity_path == '/world/cloud': + batch = chunk.to_record_batch() + xyz = batch.column('Points3D:positions')[0].values.values.to_numpy().reshape(-1, 3) + colors = batch.column('Points3D:colors')[0].values.to_numpy() + if len(xyz) > 180_000 or not np.isfinite(xyz).all(): + raise ValueError('overview geometry is invalid') + return entry.application_id, entry.recording_id, xyz, colors + + +def geometry(path: Path): + stat = path.stat() + return _geometry(path, stat.st_size, stat.st_mtime_ns) + + +def spatial_metadata(path: Path) -> dict: + _, _, xyz, _ = geometry(path) + return {'height_min_m': float(xyz[:, 2].min()) if len(xyz) else None, + 'height_max_m': float(xyz[:, 2].max()) if len(xyz) else None, + 'sample_points': len(xyz)} + + +def _camera_eye(xyz: np.ndarray, mode: Literal['3d', 'top'], aspect: float) -> dict: + points = xyz.astype(np.float64) if len(xyz) else np.array([[-1., -1., -1.], [1., 1., 1.]]) + center = (points.min(axis=0) + points.max(axis=0)) / 2 + centered = points - center + # Align an elongated survey with the width of the viewport, regardless of K1's initial yaw. + _, axes = np.linalg.eigh(centered[:, :2].T @ centered[:, :2]) + along = axes[:, -1] + if along[np.argmax(np.abs(along))] < 0: + along = -along + side = np.array([-along[1], along[0], 0.]) + direction = np.array([0., 0., 1.]) if mode == 'top' else side * .8 + np.array([0., 0., .75]) + direction /= np.linalg.norm(direction) + up = side if mode == 'top' else np.array([0., 0., 1.]) + right = np.cross(-direction, up) + right /= np.linalg.norm(right) + screen_up = np.cross(right, -direction) + # Conservative 45-degree vertical field of view, with space around the cloud. + tangent = np.tan(np.pi / 8) + depth = centered @ direction + required = np.maximum(np.abs(centered @ right) / (aspect * tangent), + np.abs(centered @ screen_up) / tangent) + depth + distance = max(2., float(required.max()) * 1.15) + return {'position': (center + direction * distance).tolist(), + 'lookTarget': center.tolist(), 'eyeUp': up.tolist()} + + +def render_spatial_update(path: Path, ceiling_m: float | None, mode: Literal['3d', 'top'] | None, aspect: float = 1.5): + app_id, recording_id, xyz, colors = geometry(path) + selected = xyz[:, 2] <= ceiling_m if ceiling_m is not None else np.ones(len(xyz), dtype=bool) + recording = rr.RecordingStream(app_id, recording_id=recording_id, send_properties=False) + sink = rr.binary_stream(recording) + eye = None + try: + # Static replacement changes only the display derivative; poses and source metrics are untouched. + recording.log('world/cloud', rr.Points3D(xyz[selected], colors=colors[selected], radii=rr.Radius.ui_points(1.5)), static=True) + if mode is not None: + eye = _camera_eye(xyz, mode, aspect) + view = rrb.Spatial3DView(name='Облако и траектория', origin='/world', contents=['/world/**'], + background=[9, 10, 12, 255], eye_controls=rrb.EyeControls3D(kind=rrb.Eye3DKind.Orbital, + position=eye['position'], look_target=eye['lookTarget'], eye_up=eye['eyeUp'])) + recording.send_blueprint(rrb.Blueprint(view, auto_layout=False, auto_views=False, collapse_panels=True)) + data = sink.read(flush=True) + return data, int(selected.sum()), eye + finally: + recording.disconnect() diff --git a/src/k1link/sessions/plugin_contract.py b/src/k1link/sessions/plugin_contract.py index 6db8f6a..05d3557 100644 --- a/src/k1link/sessions/plugin_contract.py +++ b/src/k1link/sessions/plugin_contract.py @@ -14,9 +14,11 @@ from pathlib import Path from typing import Literal, Protocol from .models import ObservationSessionCandidate, ReplayCommand +from .live_planning import PlanningLiveSource RecordingProgressPulse = Callable[[], None] RecordingExportResult = Mapping[str, object] +SubmapExtractor = Callable[[Path, dict, int, int], tuple[object, dict]] class PluginRecordingExportError(RuntimeError): @@ -80,3 +82,8 @@ class ObservationRuntimeContribution: archives: tuple[ObservationArchiveSource, ...] recording_exporter: RecordingExporter point_color_renderer: RecordedPointColorRenderer | None = None + overview_exporter: RecordingExporter | None = None + planning_exporter: RecordingExporter | None = None + submap_extractor: SubmapExtractor | None = None + live_planning_source: PlanningLiveSource | None = None + scene_submap_extractor: SubmapExtractor | None = None diff --git a/src/k1link/web/app.py b/src/k1link/web/app.py index 75441ca..06d3172 100644 --- a/src/k1link/web/app.py +++ b/src/k1link/web/app.py @@ -230,6 +230,13 @@ from k1link.web.runtime_readiness import ( build_runtime_readiness, ) from k1link.web.session_api import build_session_router +from k1link.web.session_overview_api import build_session_overview_router +from k1link.sessions.overview import SessionOverviewService +from k1link.missions.sources import PlanningSources +from k1link.missions.drafts import MissionDrafts +from k1link.missions.registration_runs import RegistrationRuns +from k1link.web.mission_registration_api import build_mission_registration_router +from k1link.web.mission_planner_api import build_mission_planner_router from k1link.web.simulation_projects_api import build_simulation_projects_router from k1link.web.simulation_world_provider_api import build_simulation_world_provider_router from k1link.web.system_telemetry_api import build_system_telemetry_router @@ -463,6 +470,14 @@ session_recording_materializer = SessionRecordingMaterializer( exporters=plugin_environment.recording_exporters, artifact_gateway=session_artifact_gateway, ) +session_overview_service = SessionOverviewService(session_store, plugin_environment.overview_exporters) +mission_drafts = MissionDrafts(session_store.data_dir / 'missions', PlanningSources( + session_store, plugin_environment.planning_exporters, plugin_environment.submap_extractors, + plugin_environment.scene_submap_extractors)) +mission_registration_runs = RegistrationRuns(mission_drafts) +from k1link.missions.live_tests import PlanningLiveTests +from k1link.web.planning_live_api import build_planning_live_router +planning_live_tests = PlanningLiveTests(mission_drafts, plugin_environment.live_planning_sources, mission_registration_runs.lock) session_recorded_media_inspector = RecordedMediaInspector( session_store.data_dir / "recorded-media-preparations" ) @@ -956,6 +971,9 @@ async def app_lifespan(application: FastAPI) -> AsyncIterator[None]: with suppress(asyncio.CancelledError): await publication_reconciler await asyncio.to_thread(session_recording_preparation_manager.close) + await asyncio.to_thread(session_overview_service.close) + await asyncio.to_thread(planning_live_tests.close) + await asyncio.to_thread(mission_registration_runs.close) await asyncio.to_thread(lidar_local_surface_read_service.close) plugin_environment.close() @@ -1128,6 +1146,11 @@ if session_artifact_gateway is not None and _ffmpeg is not None: for legacy_router in plugin_environment.legacy_routers: app.include_router(legacy_router) +app.include_router(build_session_overview_router(session_overview_service)) +app.include_router(build_mission_planner_router(mission_drafts)) +app.include_router(build_planning_live_router(planning_live_tests)) +app.include_router(build_mission_registration_router(mission_registration_runs, planning_live_tests)) + app.include_router( build_session_router( session_store, diff --git a/src/k1link/web/device_plugin_composition.py b/src/k1link/web/device_plugin_composition.py index 5b98eb9..0a0bc1b 100644 --- a/src/k1link/web/device_plugin_composition.py +++ b/src/k1link/web/device_plugin_composition.py @@ -14,6 +14,7 @@ from k1link.sessions.plugin_contract import ( ObservationArchiveSource, RecordedPointColorRenderer, RecordingExporter, + SubmapExtractor, ) from k1link.web.plugin_catalog import DevicePluginCatalog, DevicePluginManifest from k1link.web.plugin_runtime import ( @@ -52,6 +53,47 @@ class InstalledDevicePluginEnvironment: if contribution.observation is not None } + @property + def overview_exporters(self) -> dict[str, RecordingExporter]: + return { + contribution.runtime.descriptor.plugin_id: exporter + for contribution in self._contributions + if contribution.observation is not None + if (exporter := contribution.observation.overview_exporter) is not None + } + + @property + def planning_exporters(self) -> dict[str, RecordingExporter]: + return { + contribution.runtime.descriptor.plugin_id: exporter + for contribution in self._contributions + if contribution.observation is not None + if (exporter := contribution.observation.planning_exporter) is not None + } + + @property + def live_planning_sources(self): + return {c.runtime.descriptor.plugin_id: c.observation.live_planning_source + for c in self._contributions if c.observation is not None + and c.observation.live_planning_source is not None} + + @property + def submap_extractors(self) -> dict[str, SubmapExtractor]: + return { + contribution.runtime.descriptor.plugin_id: extractor + for contribution in self._contributions + if contribution.observation is not None + if (extractor := contribution.observation.submap_extractor) is not None + } + + @property + def scene_submap_extractors(self) -> dict[str, SubmapExtractor]: + return { + c.runtime.descriptor.plugin_id: c.observation.scene_submap_extractor + for c in self._contributions + if c.observation is not None and c.observation.scene_submap_extractor is not None + } + @property def point_color_renderers(self) -> dict[str, RecordedPointColorRenderer]: return { diff --git a/src/k1link/web/mission_planner_api.py b/src/k1link/web/mission_planner_api.py new file mode 100644 index 0000000..9e86885 --- /dev/null +++ b/src/k1link/web/mission_planner_api.py @@ -0,0 +1,64 @@ +"""Mission planning API: immutable recorded sources, draft persistence, data checks.""" +from uuid import UUID +from typing import Literal +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, ConfigDict, Field +from starlette.concurrency import run_in_threadpool +from k1link.sessions.models import SessionNotFoundError, SessionStoreError +from k1link.sessions.recording import RecordingMaterializationError +from k1link.missions.drafts import MissionDrafts + + +class DraftRequest(BaseModel): + model_config = ConfigDict(extra='forbid') + id: UUID | None = None + revision: int = Field(default=0, ge=0) + name: str = Field(min_length=1, max_length=120) + session_id: str = Field(pattern=r'^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$') + generation: str = Field(pattern='^[a-f0-9]{64}$') + start_index: int = Field(ge=0, strict=True) + end_index: int = Field(ge=1, strict=True) + direction: Literal['forward', 'reverse'] = 'forward' + + +class CheckRequest(BaseModel): + model_config = ConfigDict(extra='forbid') + revision: int = Field(ge=1, strict=True) + + +def build_mission_planner_router(drafts: MissionDrafts) -> APIRouter: + router = APIRouter(prefix='/api/v1/mission-planner') + + async def call(operation, *args): + try: + return await run_in_threadpool(operation, *args) + except (KeyError, SessionNotFoundError) as exc: + raise HTTPException(404, 'Запись или черновик не найдены.') from exc + except (SessionStoreError, RecordingMaterializationError, OSError) as exc: + raise HTTPException(409, 'Исходная запись недоступна или изменилась.') from exc + except ValueError as exc: + raise HTTPException(409, str(exc)) from exc + + @router.get('/sources/{session_id}') + async def source(session_id: str): + return await call(drafts.sources.get, session_id) + + @router.get('/drafts') + async def list_drafts(): + # Catalog excludes the full route geometry; detail is loaded on demand. + items = await call(drafts.list) + return {'items': [{k: v for k, v in item.items() if k != 'route'} for item in items]} + + @router.get('/drafts/{draft_id}') + async def get_draft(draft_id: UUID): + return await call(drafts.get, str(draft_id)) + + @router.post('/drafts') + async def save_draft(request: DraftRequest): + return await call(drafts.save, request) + + @router.post('/drafts/{draft_id}/checks') + async def check_draft(draft_id: UUID, request: CheckRequest): + return await call(drafts.check, str(draft_id), request.revision) + + return router diff --git a/src/k1link/web/mission_registration_api.py b/src/k1link/web/mission_registration_api.py new file mode 100644 index 0000000..69c3f53 --- /dev/null +++ b/src/k1link/web/mission_registration_api.py @@ -0,0 +1,79 @@ +"""Recorded cloud comparison, separate from data-only route checks.""" +import sqlite3 +from uuid import UUID + +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse +from pydantic import BaseModel, ConfigDict, Field +from starlette.concurrency import run_in_threadpool + +from k1link.sessions.models import SessionNotFoundError, SessionStoreError +from k1link.sessions.recording import RecordingMaterializationError + + +class RegistrationRequest(BaseModel): + model_config = ConfigDict(extra='forbid') + revision: int = Field(ge=1, strict=True) + session_id: str = Field(pattern=r'^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$') + generation: str = Field(pattern='^[a-f0-9]{64}$') + start_index: int = Field(ge=0, strict=True) + end_index: int = Field(ge=1, strict=True) + + +class DeleteProjectRequest(BaseModel): + model_config = ConfigDict(extra='forbid') + revision: int = Field(ge=1, strict=True) + + +def build_mission_registration_router(runs, live=None): + from k1link.missions.projects import PlanningProjects + projects = PlanningProjects(runs, live) + router = APIRouter(prefix='/api/v1/mission-planner') + async def call(operation, *args): + try: + return await run_in_threadpool(operation, *args) + except (KeyError, SessionNotFoundError) as exc: + raise HTTPException(404, 'Запись или результат не найдены.') from exc + except (SessionStoreError, RecordingMaterializationError, OSError) as exc: + raise HTTPException(409, 'Исходная запись недоступна или изменилась.') from exc + except ValueError as exc: + raise HTTPException(409, str(exc)) from exc + except sqlite3.Error as exc: + raise HTTPException( + 409, 'Каталог проектов недоступен. Повторите попытку.' + ) from exc + + @router.get('/projects') + async def project_catalog(): + return {'items': await call(projects.list)} + + @router.get('/projects/{kind}/{project_id}') + async def project(kind: str, project_id: UUID): + return await call(projects.get, kind, str(project_id)) + + @router.delete('/projects/{kind}/{project_id}') + async def delete_project(kind: str, project_id: UUID, request: DeleteProjectRequest): + return await call(projects.remove, kind, str(project_id), request.revision) + + @router.get('/projects/live/{project_id}/scene.rrd') + async def live_project_scene(project_id: UUID): + path = await call(projects.live_scene, str(project_id)) + return FileResponse(path, media_type='application/octet-stream') + + @router.post('/drafts/{draft_id}/registration-runs') + async def start(draft_id: UUID, request: RegistrationRequest): + return await call(runs.start, str(draft_id), request.model_dump()) + + @router.get('/drafts/{draft_id}/registration-runs') + async def list_runs(draft_id: UUID): + return {'items': await call(runs.list, str(draft_id))} + + @router.get('/registration-runs/{run_id}') + async def report(run_id: UUID): + return await call(runs.get, str(run_id)) + + @router.get('/registration-runs/{run_id}/scene.rrd') + async def scene(run_id: UUID): + path = await call(projects.verified_scene, str(run_id)) + return FileResponse(path, media_type='application/octet-stream') + return router diff --git a/src/k1link/web/planning_live_api.py b/src/k1link/web/planning_live_api.py new file mode 100644 index 0000000..0682444 --- /dev/null +++ b/src/k1link/web/planning_live_api.py @@ -0,0 +1,158 @@ +"""Planning-profile preparation and preview; never an acquisition endpoint.""" + +from typing import Literal +from uuid import UUID + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import BaseModel, ConfigDict, Field +from starlette.concurrency import run_in_threadpool + + +class LiveTestRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + draft_id: UUID + revision: int = Field(ge=1, strict=True) + + +class BrowserPresentationSample(BaseModel): + model_config = ConfigDict(extra="forbid") + cloud_revision: int = Field(ge=1, le=1_000_000_000, strict=True) + cloud_sequence: int = Field(ge=1, le=1_000_000_000, strict=True) + display_epoch: str = Field(min_length=36, max_length=36) + request_ms: float = Field(ge=0, le=5_000) + rerun_admission_ms: float = Field(ge=0, le=5_000) + first_animation_frame_ms: float | None = Field(default=None, ge=0, le=5_000) + second_animation_frame_ms: float | None = Field(default=None, ge=0, le=5_000) + frame_timeout: bool + source_to_second_animation_frame_upper_bound_ms: float | None = Field( + default=None, ge=0, le=15_000 + ) + + +class BrowserPresentationObservationRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + schema_version: Literal["missioncore.planning-browser-presentation/v1"] + samples: list[BrowserPresentationSample] = Field(min_length=1, max_length=8) + + +def build_planning_live_router(service): + router = APIRouter(prefix="/api/v1/mission-planner/live-tests") + + async def call(fn, *args): + try: + return await run_in_threadpool(fn, *args) + except KeyError as exc: + raise HTTPException(404, "Исследование не найдено.") from exc + except (ValueError, RuntimeError) as exc: + raise HTTPException(409, str(exc)) from exc + + @router.get("/active") + async def active(): + return await call(service.get) + + @router.get("") + async def history(): + return {"items": await call(service.history)} + + @router.post("/{run_id}/select") + async def select(run_id: UUID): + return await call(service.select, str(run_id)) + + @router.post("") + async def start(body: LiveTestRequest): + return await call(service.start, str(body.draft_id), body.revision) + + @router.post("/{run_id}/stop") + async def stop(run_id: UUID): + return await call(service.stop, str(run_id)) + + @router.post("/{run_id}/reinitialize") + async def reinitialize(run_id: UUID): + return await call(service.request_reinitialization, str(run_id)) + + @router.post("/{run_id}/presentation-observations", status_code=204) + async def presentation_observations(run_id: UUID, body: BrowserPresentationObservationRequest): + await call( + service.record_browser_presentation, + str(run_id), + [sample.model_dump() for sample in body.samples], + ) + return Response(status_code=204) + + @router.get("/{run_id}/scene.rrd") + async def scene( + run_id: UUID, + base: bool = False, + mode: Literal["3d", "top"] = "3d", + reference: bool = True, + query: bool = True, + trajectory: bool = True, + grid: bool = True, + point_size: float = Query(1.8, ge=0.5, le=12), + ceiling_m: float | None = Query(default=None, allow_inf_nan=False), + ): + options = dict( + mode=mode, + reference=reference, + query=query, + trajectory=trajectory, + grid=grid, + point_size=point_size, + ceiling_m=ceiling_m, + ) + return Response( + await call(service.scene, str(run_id), base, options), + media_type="application/octet-stream", + headers={"Cache-Control": "no-store"}, + ) + + @router.get("/{run_id}/scene-delta.rrd") + async def scene_delta( + run_id: UUID, + cursor: str = Query("", max_length=2048), + base: bool = False, + mode: Literal["3d", "top"] = "3d", + reset: int = Query(0, ge=0), + reference: bool = True, + query: bool = True, + trajectory: bool = True, + grid: bool = True, + point_size: float = Query(1.8, ge=0.5, le=12), + ceiling_m: float | None = Query(default=None, allow_inf_nan=False), + ): + options = dict( + mode=mode, + reset=reset, + reference=reference, + query=query, + trajectory=trajectory, + grid=grid, + point_size=point_size, + ceiling_m=ceiling_m, + ) + payload, next_cursor, info = await call( + service.scene_update, str(run_id), cursor, base, options + ) + return Response( + payload, + status_code=200 if payload else 204, + media_type="application/octet-stream", + headers={ + "Cache-Control": "no-store", + "X-Planning-Scene-Cursor": next_cursor, + "X-Planning-Presentation": "live" if info["live"] else "historical", + "X-Planning-Cloud-Age": str(info["cloud_age_s"] or 0), + "X-Planning-Fit-Age": str(info["fit_age_s"] or 0), + "X-Planning-Cloud-Revision": str(info["cloud_revision"] or 0), + "X-Planning-Cloud-Sequence": str(info["cloud_sequence"] or 0), + "X-Planning-Display-Epoch": str(info["display_epoch"] or ""), + "X-Planning-Height-Min": ( + str(info["height_min_m"]) if info["height_min_m"] is not None else "" + ), + "X-Planning-Height-Max": ( + str(info["height_max_m"]) if info["height_max_m"] is not None else "" + ), + }, + ) + + return router diff --git a/src/k1link/web/session_overview_api.py b/src/k1link/web/session_overview_api.py new file mode 100644 index 0000000..c9f2b6f --- /dev/null +++ b/src/k1link/web/session_overview_api.py @@ -0,0 +1,59 @@ +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse, Response +from pydantic import BaseModel, ConfigDict, Field +from typing import Literal +import json +from starlette.concurrency import run_in_threadpool +from k1link.sessions.models import SessionNotFoundError, SessionStoreError +from k1link.sessions.recording import RecordingMaterializationError +from k1link.sessions.overview import SessionOverviewService +from k1link.sessions.overview_spatial import spatial_metadata, render_spatial_update + + +class OverviewSpatialRequest(BaseModel): + model_config = ConfigDict(extra='forbid') + generation: str = Field(pattern='^[a-f0-9]{64}$') + ceiling_m: float | None = Field(default=None, allow_inf_nan=False) + mode: Literal['3d', 'top'] | None = None + aspect: float = Field(default=1.5, ge=.1, le=20, allow_inf_nan=False) + + +def build_session_overview_router(service: SessionOverviewService) -> APIRouter: + router = APIRouter(prefix='/api/v1/observation-sessions') + + async def call(operation, *args): + try: + return await run_in_threadpool(operation, *args) + except SessionNotFoundError as exc: + raise HTTPException(404, 'Запись не найдена.') from exc + except (ValueError, OSError, SessionStoreError, RecordingMaterializationError) as exc: + raise HTTPException(409, 'Исходные данные записи недоступны или изменились.') from exc + + @router.get('/{session_id}/overview') + async def overview(session_id: str): + return await call(service.get, session_id) + + @router.post('/{session_id}/overview/retry') + async def retry(session_id: str): + return await call(service.retry, session_id) + + @router.get('/{session_id}/overview/scene.rrd') + async def scene(session_id: str, generation: str = Query(pattern='^[a-f0-9]{64}$')): + path = await call(service.scene, session_id, generation) + return FileResponse(path, media_type='application/octet-stream', headers={'Cache-Control': 'private, no-cache, no-transform'}) + + @router.get('/{session_id}/overview/spatial') + async def spatial(session_id: str, generation: str = Query(pattern='^[a-f0-9]{64}$')): + path = await call(service.scene, session_id, generation) + return await call(spatial_metadata, path) + + @router.post('/{session_id}/overview/spatial') + async def spatial_update(session_id: str, request: OverviewSpatialRequest): + path = await call(service.scene, session_id, request.generation) + data, visible, eye = await call(render_spatial_update, path, request.ceiling_m, request.mode, request.aspect) + headers = {'Cache-Control': 'no-store', 'X-Overview-Visible-Points': str(visible)} + if eye is not None: + headers['X-Overview-Eye'] = json.dumps(eye) + return Response(data, media_type='application/octet-stream', headers=headers) + + return router diff --git a/tests/test_causal_planning_replay.py b/tests/test_causal_planning_replay.py new file mode 100644 index 0000000..a6e1c81 --- /dev/null +++ b/tests/test_causal_planning_replay.py @@ -0,0 +1,160 @@ +"""Tiny deterministic fixtures: temporal authority and causality, not load.""" + +import json +from dataclasses import replace + +import numpy as np +import pytest + +from k1link.missions.causal_replay import replay +from k1link.missions.causal_tracking import CausalTracking +from k1link.sessions.live_planning import PlanningLiveEvent + + +def sample(t, segment=0): + return dict(monotonic_ns=int(t * 1e9), segment=segment, path=np.array([[0.0, 0, 0], [3, 0, 0]])) + + +def result(x=0, status="candidate"): + matrix = np.eye(4) + matrix[0, 3] = x + return dict(status=status, T_reference_query=matrix.tolist()) + + +def test_three_consistent_candidates_and_timeout(): + gate = CausalTracking() + for t in (1, 6, 11): + assert gate.accept(result(), sample(t), int((t + 1) * 1e9), 0)["accepted"] + assert gate.state == "tracking" and gate.streak == 3 + gate.tick(20_000_000_000, 0) + assert gate.state == "lost" and gate.matrix is None and gate.reason == "stale" + + +def test_inconsistent_candidate_is_not_seeded_or_counted(): + gate = CausalTracking() + gate.accept(result(), sample(1), 2_000_000_000, 0) + rejection = gate.accept(result(0.6), sample(6), 7_000_000_000, 0) + assert not rejection["accepted"] and rejection["reason"] == "inconsistent-candidate" + assert gate.matrix is None and gate.streak == 0 + gate.accept(result(0.6), sample(11), 12_000_000_000, 0) + assert gate.streak == 1 and gate.state == "acquiring" + + +def test_old_segment_result_does_not_override_current_candidate(): + gate = CausalTracking() + gate.accept(result(), sample(1), 2_000_000_000, 0) + gate.tick(3_000_000_000, 1) + assert gate.matrix is None + gate.accept(result(0.1), sample(4, 1), 5_000_000_000, 1) + old = gate.accept(result(10), sample(2), 6_000_000_000, 1) + assert old["reason"] == "old-segment" and not old["accepted"] + assert gate.streak == 1 and gate.matrix[0, 3] == pytest.approx(0.1) + + +@pytest.mark.parametrize( + "status,stamp,now,reason", + [ + ("rejected", 1, 2, "registration-rejected"), + ("candidate", 1, 10, "stale-result"), + ("candidate", 3, 2, "stale-result"), + ], +) +def test_bad_or_stale_fit_cannot_establish_tracking(status, stamp, now, reason): + gate = CausalTracking() + assert not gate.accept(result(status=status), sample(stamp), int(now * 1e9), 0)["accepted"] + assert gate.reason == reason and gate.matrix is None + + +@pytest.mark.parametrize("mode", ["baseline", "acquisition"]) +def test_replay_fit_never_contains_future_points(tmp_path, mode): + points = np.random.default_rng(11).uniform([0, -3, -1], [8, 3, 3], (1500, 3)) + start = PlanningLiveEvent("B", 1, 1, 1_000_000_000, 1, "pose", position=(0, 0, 0)) + events = [ + start, + replace(start, sequence=2, monotonic_ns=1_001_000_000, position=(3, 0, 0)), + replace(start, kind="points", sequence=3, monotonic_ns=1_002_000_000, points=points), + replace(start, sequence=4, monotonic_ns=1_100_000_000, position=(3.1, 0, 0)), + replace(start, kind="points", sequence=5, monotonic_ns=1_110_000_000, points=points + 100), + ] + calls = [] + + def calculate(directory, ref, query, hint): + calls.append(query.copy()) + return result() + + report = replay( + iter(events), + points, + np.array([[0, 0, 0], [30, 0, 0]]), + tmp_path / "replay", + calculate=calculate, + initialize=lambda directory, ref, query, hint, anchor, forward: calculate( + directory, ref, query, hint + ), + mode=mode, + max_seconds=0.2, + ) + assert len(calls) == 1 and calls[0].max() < 20 + assert report["steps"][0]["sequence"] == 3 + assert max(e["sequence"] for e in report["steps"][0]["source_events"]) == 3 + assert report["transitions"][-1]["reason"] == "input-ended" + assert json.loads((tmp_path / "replay/report.json").read_text())["vehicle_control"] is False + + +def test_empty_or_oversized_replay_fails(tmp_path): + with pytest.raises(ValueError, match="bounds"): + replay([], [], [], tmp_path / "too-long", max_seconds=121) + with pytest.raises(ValueError, match="Empty"): + replay([], [], [], tmp_path / "empty") + + +def test_job_finishing_after_input_end_is_historical_only(tmp_path): + import time + + points = np.random.default_rng(21).uniform([0, -3, -1], [8, 3, 3], (1500, 3)) + start = PlanningLiveEvent("B", 1, 1, 1_000_000_000, 1, "pose", position=(0, 0, 0)) + events = [ + start, + replace(start, sequence=2, monotonic_ns=1_001_000_000, position=(3, 0, 0)), + replace(start, kind="points", sequence=3, monotonic_ns=1_002_000_000, points=points), + ] + + def delayed(directory, reference, query, hint): + time.sleep(0.05) + return result() + + report = replay( + iter(events), + points, + np.array([[0, 0, 0], [30, 0, 0]]), + tmp_path / "ended", + calculate=delayed, + max_seconds=0.2, + ) + assert report["first_candidate_s"] is None + assert report["steps"][0]["temporal"]["reason"] == "input-ended" + assert not report["steps"][0]["temporal"]["accepted"] + assert report["transitions"][-1]["time_s"] < report["steps"][0]["completed_s"] + + +def test_archived_adapter_requires_real_clock_and_reuses_live_decoder(tmp_path): + from test_stream_summary import _pose_payload + from test_viewer_replay import _write_native + + from k1link.device_plugins.xgrids_k1.planning_replay import iter_planning_events + + source = tmp_path / "mqtt.raw.k1mqtt" + _write_native(source, "x/lio_pose", _pose_payload((5, 1, 2))) + with pytest.raises(ValueError, match="metadata"): + list(iter_planning_events(source, "B")) + meta = dict( + record_type="message", sequence=1, received_at_epoch_ns=10, received_monotonic_ns=20 + ) + source.with_name("mqtt.metadata.jsonl").write_text(json.dumps(meta) + "\n") + events = list(iter_planning_events(source, "B")) + assert len(events) == 1 and events[0].position == (5.0, 1.0, 2.0) + assert events[0].monotonic_ns == 20 and events[0].epoch_ns == 10 + del meta["received_monotonic_ns"] + source.with_name("mqtt.metadata.jsonl").write_text(json.dumps(meta) + "\n") + with pytest.raises(ValueError, match="clock"): + list(iter_planning_events(source, "B")) diff --git a/tests/test_entry_acquisition.py b/tests/test_entry_acquisition.py new file mode 100644 index 0000000..0473435 --- /dev/null +++ b/tests/test_entry_acquisition.py @@ -0,0 +1,158 @@ +"""Deterministic entry hypotheses, ambiguity and bounded numeric qualification.""" + +import numpy as np +import pytest + +from k1link.missions.entry_acquisition import acquire_entry, choose_entry, entry_seeds +from k1link.missions.registration import angle_deg, transform + + +def attempts(matrix=None): + matrix = np.eye(4) if matrix is None else matrix + return [ + dict( + index=i, + along_m=(i // 9 - 1) * 3, + across_m=(i // 3 % 3 - 1) * 3, + yaw_deg=(i % 3 - 1) * 15, + result=dict( + status="candidate", + T_reference_query=matrix.tolist(), + overlap=0.95, + inlier_rmse_m=0.1, + matched_query_indices=[0], + registration_seconds=0.01, + ), + ) + for i in range(27) + ] + + +def test_seeds_rotate_about_query_entry_and_span_route_basis(): + initial = np.eye(4) + initial[:3, 3] = [10, 20, 1] + anchor = np.array([100, 200, 3.0]) + seeds = list(entry_seeds(initial, anchor, [0, 4, 0])) + assert len(seeds) == 27 + for seed in seeds: + expected = transform(anchor[None], initial)[0] + [-seed["across_m"], seed["along_m"], 0] + assert np.allclose(transform(anchor[None], seed["matrix"])[0], expected) + assert np.linalg.det(seed["matrix"][:3, :3]) == pytest.approx(1) + with pytest.raises(ValueError): + list(entry_seeds(initial, anchor, [0, 0, 0])) + + +def test_single_supported_solution_does_not_relax_local_rejection(): + data = attempts() + data[0]["result"]["status"] = "rejected" + result = choose_entry(data, np.eye(4), [0, 0, 0]) + assert result["status"] == "candidate" + assert result["initialization"]["clusters"][0]["support"] == 26 + assert not result["initialization"]["attempts"][0]["entry_admitted"] + + +def test_two_near_equal_place_solutions_are_ambiguous_even_with_unequal_support(): + data = attempts() + alternative = np.eye(4) + alternative[0, 3] = 2 + data[-1]["result"].update( + T_reference_query=alternative.tolist(), overlap=0.94, inlier_rmse_m=0.11 + ) + result = choose_entry(data, np.eye(4), [0, 0, 0]) + assert result["status"] == "rejected" and result["reasons"] == ["ambiguous-entry"] + assert result["matched_query_indices"] == [] + + +def test_cluster_is_pairwise_not_a_chain_between_distant_places(): + data = attempts() + for i, item in enumerate(data): + matrix = np.eye(4) + matrix[0, 3] = (i % 3) * 0.4 + item["result"]["T_reference_query"] = matrix.tolist() + result = choose_entry(data, np.eye(4), [0, 0, 0]) + assert len(result["initialization"]["clusters"]) == 2 + assert result["reasons"] == ["ambiguous-entry"] + + +@pytest.mark.parametrize("offset", [[6, 0, 0], [0, 0, 1.1]]) +def test_solution_outside_entry_region_is_rejected(offset): + matrix = np.eye(4) + matrix[:3, 3] = offset + result = choose_entry(attempts(matrix), np.eye(4), [0, 0, 0]) + assert result["reasons"] == ["no-admissible-entry"] + + +def test_angles_and_multiple_translation_starts_required(): + data = attempts() + for item in data[3:]: + item["result"]["status"] = "rejected" + assert choose_entry(data, np.eye(4), [0, 0, 0])["reasons"] == [ + "insufficient-multistart-support" + ] + matrix = np.eye(4) + a = np.radians(31) + matrix[:2, :2] = [[np.cos(a), -np.sin(a)], [np.sin(a), np.cos(a)]] + assert choose_entry(attempts(matrix), np.eye(4), [0, 0, 0])["status"] == "rejected" + + +def test_partial_search_and_deadline_cannot_claim_unique_solution(): + assert choose_entry(attempts(), np.eye(4), [0, 0, 0], complete=False)["reasons"] == [ + "incomplete-search" + ] + times = iter([0, 1, 26, 26]) + points = np.random.default_rng(1).normal(size=(400, 3)) + output = acquire_entry( + points, + points, + np.eye(4), + [0, 0, 0], + [1, 0, 0], + fitter=lambda *args: attempts()[0]["result"], + clock=lambda: next(times), + ) + assert len(output["initialization"]["attempts"]) == 1 + assert output["reasons"] == ["incomplete-search"] + + +def test_recovers_known_transform_from_several_starts(): + pytest.importorskip("small_gicp") + from test_mission_registration import geometry + + ref = geometry() + truth = np.eye(4) + a = np.radians(10) + truth[:2, :2] = [[np.cos(a), -np.sin(a)], [np.sin(a), np.cos(a)]] + truth[:3, 3] = [0.5, 2.6, 0.1] + query = transform(ref, np.linalg.inv(truth)) + result = acquire_entry(ref, query, np.eye(4), [0, 0, 0], [1, 0, 0]) + assert result["status"] == "candidate", result["reasons"] + found = np.asarray(result["T_reference_query"]) + assert np.linalg.norm(found[:3, 3] - truth[:3, 3]) < 0.03 + assert angle_deg(found[:3, :3] @ truth[:3, :3].T) < 0.3 + assert result["policy"]["maximum_correction_m"] == 3 + assert not result["localization_confirmed"] and not result["vehicle_control"] + + +def test_centre_first_search_preserves_all_seed_identities(): + from k1link.missions.stationary_entry import STATIONARY_POLICY + seeds = list(entry_seeds(np.eye(4), [0, 0, 0], [1, 0, 0], policy=STATIONARY_POLICY)) + assert (seeds[0]["along_m"], seeds[0]["across_m"], seeds[0]["yaw_deg"]) == (0, 0, 0) + assert {s["index"] for s in seeds} == set(range(108)) + assert len({(s["along_m"], s["across_m"], s["yaw_deg"]) for s in seeds}) == 108 + + +def test_prepared_target_is_identical_across_rejected_and_accepted_fits(): + from k1link.missions.registration import PreparedReference, register + from test_mission_registration import geometry + ref = geometry() + prepared = PreparedReference(ref) + query = ref + [.7, -.5, .2] + first = prepared.register(query, np.eye(4)) + assert prepared.register(query + [100, 0, 0], np.eye(4))["status"] == "rejected" + second = prepared.register(query, np.eye(4)) + standalone = register(ref, query, np.eye(4)) + for result in (first, second, standalone): + result.pop("registration_seconds") + assert first == second == standalone + assert first["status"] == "candidate" + assert np.allclose(np.asarray(first["T_reference_query"])[:3, 3], [-.7, .5, -.2], atol=.02) diff --git a/tests/test_k1_installer.py b/tests/test_k1_installer.py index 446967e..915a84b 100644 --- a/tests/test_k1_installer.py +++ b/tests/test_k1_installer.py @@ -181,6 +181,10 @@ from k1link.device_plugins.xgrids_k1.node_bridge import NodeBridge assert "k1link.laboratory.execution" not in sys.modules assert "k1link.device_plugins.xgrids_k1.legacy_api" not in sys.modules assert "k1link.compute.jobs" not in sys.modules +assert "k1link.missions.live_tests" not in sys.modules +assert "k1link.device_plugins.xgrids_k1.planning_live" not in sys.modules +from k1link.sessions.live_planning import PlanningLiveEvent +assert PlanningLiveEvent("fixture", 1, 1, 1, 1, "ignored").points is None import k1link assert pathlib.Path(k1link.__file__).is_relative_to(stage) bridge = NodeBridge(stage) diff --git a/tests/test_live_perception.py b/tests/test_live_perception.py index 8645ac5..53fb025 100644 --- a/tests/test_live_perception.py +++ b/tests/test_live_perception.py @@ -31,7 +31,7 @@ def test_live_ingress_keeps_modalities_separately_bounded_and_ordered() -> None: received_monotonic_ns=200 + sequence, payload=f"camera-{sequence}".encode(), ) - for sequence in range(9): + for sequence in range(33): assert ingress.publish( modality="lidar", source_id="lixel/application/report/lio_pcl", @@ -40,7 +40,7 @@ def test_live_ingress_keeps_modalities_separately_bounded_and_ordered() -> None: received_monotonic_ns=400 + sequence, payload=f"lidar-{sequence}".encode(), ) - for sequence in range(20): + for sequence in range(36): assert ingress.publish( modality="pose", source_id="lixel/application/report/lio_pose", @@ -53,9 +53,9 @@ def test_live_ingress_keeps_modalities_separately_bounded_and_ordered() -> None: snapshot = ingress.snapshot() assert snapshot["queues"]["camera-frame"]["depth"] == 2 assert snapshot["queues"]["camera-frame"]["dropped_overflow"] == 3 - assert snapshot["queues"]["lidar"]["depth"] == 8 + assert snapshot["queues"]["lidar"]["depth"] == 32 assert snapshot["queues"]["lidar"]["dropped_overflow"] == 1 - assert snapshot["queues"]["pose"]["depth"] == 16 + assert snapshot["queues"]["pose"]["depth"] == 32 assert snapshot["queues"]["pose"]["dropped_overflow"] == 4 events = [] @@ -70,10 +70,10 @@ def test_live_ingress_keeps_modalities_separately_bounded_and_ordered() -> None: 4, ] assert [event.source_sequence for event in events if event.modality == "lidar"] == list( - range(1, 9) + range(1, 33) ) assert [event.source_sequence for event in events if event.modality == "pose"] == list( - range(4, 20) + range(4, 36) ) @@ -105,6 +105,27 @@ def test_live_ingress_wire_is_self_delimiting_and_explicitly_non_authoritative() assert encoded[4 + header_bytes :] == b"init" +def test_spatial_stop_is_identity_bound_idempotent_and_keeps_capture_active() -> None: + ingress = LivePerceptionIngress() + ingress.open_consumer("planning") + ingress.begin_session("A") + assert not ingress.request_spatial_stop("other", 1) + assert not ingress.request_spatial_stop("A", 2) + assert not ingress.snapshot()["spatial_stop_requested"] + assert ingress.request_spatial_stop("A", 1) + assert ingress.request_spatial_stop("A", 1) + assert ingress.snapshot()["active"] + assert ingress.snapshot()["queues"]["control"]["published"] == 2 + assert ingress.publish(modality="pose", source_id="pose", source_sequence=1, + captured_at_epoch_ns=1, received_monotonic_ns=1, payload=b"raw") + ingress.end_session("A") + ingress.begin_session("A") # Same name, new generation cannot inherit STOP. + assert not ingress.request_spatial_stop("A", 1) + assert not ingress.snapshot()["spatial_stop_requested"] + assert ingress.snapshot()["active"] + ingress.close() + + def test_live_ingress_rejects_oversize_without_affecting_other_modalities() -> None: ingress = LivePerceptionIngress() ingress.begin_session("session-1") diff --git a/tests/test_local_service_launchd.py b/tests/test_local_service_launchd.py index fb01fe0..0e8df04 100644 --- a/tests/test_local_service_launchd.py +++ b/tests/test_local_service_launchd.py @@ -58,6 +58,8 @@ def test_launch_agent_plan_disables_sync_and_enables_watchdog(tmp_path: Path) -> assert desired["RunAtLoad"] is True assert desired["AbandonProcessGroup"] is False assert desired["ExitTimeOut"] == 20 + assert desired["ProcessType"] == "Interactive" + assert plan.to_dict()["desired_process_type"] == "Interactive" assert plan.current_sha256 != plan.desired_sha256 assert plan.current_working_directory == repository assert plan.desired_working_directory == repository @@ -68,6 +70,28 @@ def test_launch_agent_plan_disables_sync_and_enables_watchdog(tmp_path: Path) -> assert plan.to_dict()["changes"]["local_observatory_worker_enabled"] is False +def test_background_migration_changes_scheduling_without_changing_operator_state(tmp_path): + repository = tmp_path / "repo" + repository.mkdir() + uv = tmp_path / "uv" + uv.write_text("#!/bin/sh\n") + agent = tmp_path / "agent.plist" + environment = {"PATH": "/usr/bin:/bin", "MISSIONCORE_DATA_DIR": "/existing/evidence"} + _write_agent(path=agent, repository=repository, uv_entrypoint=uv, environment=environment) + previous = plistlib.loads(agent.read_bytes()) + previous["ProcessType"] = "Background" + agent.write_bytes(plistlib.dumps(previous)) + original = agent.read_bytes() + plan = plan_mission_core_launch_agent(repository_root=repository, agent_path=agent) + desired = plistlib.loads(plan.desired_payload) + assert plan.current_process_type == "Background" + assert desired["ProcessType"] == "Interactive" + assert desired["EnvironmentVariables"] == {**environment, "MISSIONCORE_SERVICE_WATCHDOG": "1"} + assert desired["AbandonProcessGroup"] is False + assert "Nice" not in desired and "HardResourceLimits" not in desired + assert agent.read_bytes() == original # A plan never changes the running service. + + def test_launch_agent_plan_explicitly_enables_local_observatory_worker( tmp_path: Path, ) -> None: diff --git a/tests/test_mission_planner.py b/tests/test_mission_planner.py new file mode 100644 index 0000000..796c819 --- /dev/null +++ b/tests/test_mission_planner.py @@ -0,0 +1,110 @@ +from types import SimpleNamespace +from pathlib import Path +import json +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from test_stream_summary import _write_capture, _pcl_payload, _pose_payload +from test_session_recording import _command +from k1link.device_plugins.xgrids_k1.planning_source import export_planning_source +from k1link.missions.sources import PlanningSources +from k1link.missions.drafts import MissionDrafts, DraftConflict, route_from_source +from k1link.web.mission_planner_api import DraftRequest, build_mission_planner_router + + +def source_doc(): + return {'session_id': 'session-a', 'generation': 'a'*64, 'label': 'Route A', 'units': 'm', + 'frame_id': 'session/session-a', 'source_digests': {'primary': 'b'*64}, 'decode_errors': 0, + 'poses': [{'index': i, 'position': [i*3, i*4, 0]} for i in range(4)]} + + +class Sources: + changed = False + def bound(self, id, generation): + if self.changed or id != 'session-a' or generation != 'a'*64: + raise ValueError('source changed') + return source_doc() + verify = bound + + +def request(**values): + return DraftRequest(**dict({'name': 'Route', 'session_id': 'session-a', 'generation': 'a'*64, + 'start_index': 0, 'end_index': 2}, **values)) + + +def test_export_preserves_all_pose_indices_and_does_not_transform_twice(tmp_path): + src = tmp_path / 'mqtt.raw.k1mqtt' + _write_capture(src, [('lixel/application/report/lio_pcl', _pcl_payload(scaler=1000, point_count=4)), + *[('lixel/application/report/lio_pose', _pose_payload(xyz)) for xyz in [(0, 0, 0), (3, 4, 0), (0, 0, 0)]]]) + path = tmp_path / 'planning.json' + export_planning_source(src, path) + doc = json.loads(path.read_text()) + assert [p['index'] for p in doc['poses']] == [0, 1, 2] + assert doc['poses'][1]['position'] == [3, 4, 0] + assert doc['poses'][1]['distance_m'] == 5 + assert doc['path_m'] == 10 + assert all(p['elapsed_s'] is None for p in doc['poses']) + + +def test_export_rejects_camera_or_pose_only_recording(tmp_path): + src = tmp_path / 'mqtt.raw.k1mqtt' + _write_capture(src, [('lixel/application/report/lio_pose', _pose_payload((0, 0, 0)))] * 2) + with pytest.raises(ValueError): export_planning_source(src, tmp_path / 'out.json') + + +def test_draft_persists_full_route_and_optimistic_revision(tmp_path): + service = MissionDrafts(tmp_path, Sources()) + first = service.save(request(direction='reverse')) + assert first['vehicle_id'] is None and first['revision'] == 1 + assert [p['source_index'] for p in first['route']['points']] == [2, 1, 0] + assert first['route']['length_m'] == 10 + restarted = MissionDrafts(tmp_path, Sources()) + assert restarted.get(first['id']) == first + next = restarted.save(request(id=first['id'], revision=1, name='Changed')) + assert next['revision'] == 2 + with pytest.raises(DraftConflict): service.save(request(id=first['id'], revision=1)) + assert service.get(first['id']) == next + + +@pytest.mark.parametrize('start,end', [(2, 2), (3, 1), (-1, 2), (0, 4)]) +def test_route_rejects_out_of_bounds(start, end): + with pytest.raises(ValueError): route_from_source(source_doc(), start, end, 'forward') + + +def test_check_is_saved_revision_bound_and_never_claims_localization(tmp_path): + sources = Sources(); service = MissionDrafts(tmp_path, sources) + draft = service.save(request()) + result = service.check(draft['id'], 1) + assert result['localization'] == 'not_run' and result['vehicle_control'] is False + assert result['warnings'] and result['source_verified'] + with service.connect() as db: assert db.execute('SELECT COUNT(*) FROM checks').fetchone()[0] == 1 + sources.changed = True + with pytest.raises(ValueError): service.check(draft['id'], 1) + assert service.get(draft['id']) == draft # unavailable evidence never discards the draft + + +def test_api_forbids_vehicle_authority_and_checks_revision(tmp_path): + app = FastAPI(); app.include_router(build_mission_planner_router(MissionDrafts(tmp_path, Sources()))) + with TestClient(app) as client: + body = request().model_dump(mode='json') + assert client.post('/api/v1/mission-planner/drafts', json={**body, 'vehicle_id': 'rover'}).status_code == 422 + draft = client.post('/api/v1/mission-planner/drafts', json=body).json() + assert client.post('/api/v1/mission-planner/drafts/'+draft['id']+'/checks', json={'revision': 2}).status_code == 409 + assert client.get('/api/v1/mission-planner/drafts/'+draft['id']).json() == draft + + +def test_source_cache_is_bound_to_validated_archive_and_rejects_lab(tmp_path): + command = _command(tmp_path / 'source') + detail = SimpleNamespace(plugin_id=command.plugin_id, summary=SimpleNamespace(replayable=True, lab=None), as_dict=lambda: {'display_name': 'A'}) + store = SimpleNamespace(data_dir=tmp_path / 'data', get_session=lambda _: detail, prepare_replay=lambda _: command) + count = [] + def export(source, dest): + count.append(1); dest.write_text(json.dumps({'poses': [], 'path_m': 0})) + service = PlanningSources(store, {command.plugin_id: export}) + first = service.get(command.session_id) + assert service.get(command.session_id) == first and len(count) == 1 + assert service.verify(command.session_id, first['generation']) == first + command.primary_artifact.path.write_bytes(b'X' * command.primary_artifact.file_byte_length) + with pytest.raises(ValueError): service.bound(command.session_id, first['generation']) + detail.summary.lab = object() + with pytest.raises(ValueError): service.get(command.session_id) diff --git a/tests/test_mission_registration.py b/tests/test_mission_registration.py new file mode 100644 index 0000000..26d2f1f --- /dev/null +++ b/tests/test_mission_registration.py @@ -0,0 +1,114 @@ +import json +import threading +from types import SimpleNamespace +import numpy as np +import pytest + +pytest.importorskip('small_gicp') +from k1link.missions.registration import register, transform, rigid, path_hint, angle_deg +from k1link.device_plugins.xgrids_k1.localization_source import extract_submap +from k1link.device_plugins.xgrids_k1.planning_source import export_planning_source +from k1link.missions.registration_runs import RegistrationRuns +from test_stream_summary import _write_capture, _pcl_payload, _pose_payload + + +def geometry(): + r = np.random.default_rng(41) + return np.vstack([r.normal([0,0,0],[4,3,.1],(3000,3)), + r.normal([3,2,2],[.1,2,2],(1500,3)), + r.normal([-2,-1,2],[2,.1,1],(1500,3))]) + + +def test_recovers_known_rigid_transform_in_source_to_target_convention(): + p = geometry(); t = np.eye(4); a = .1 + t[:2,:2] = [[np.cos(a),-np.sin(a)],[np.sin(a),np.cos(a)]] + t[:3,3] = [.7,-.4,.2] + result = register(p, transform(p, np.linalg.inv(t)), np.eye(4)) + found = np.array(result['T_reference_query']) + assert result['status'] == 'candidate' + assert np.linalg.norm(found[:3,3]-t[:3,3]) < .01 + assert angle_deg(found[:3,:3] @ t[:3,:3].T) < .1 + assert not result['localization_confirmed'] and not result['vehicle_control'] + + +def test_no_overlap_and_uninformative_plane_are_rejected(): + p = geometry() + assert register(p, p+[100,100,100], np.eye(4))['status'] == 'rejected' + p[:,2] = 0 + result = register(p, p, np.eye(4)) + assert result['status'] == 'rejected' and result['shape_ratio'] == 0 + + +def test_nonrigid_and_nonfinite_inputs_rejected(): + with pytest.raises(ValueError): rigid(np.ones((4,4))) + p = geometry(); p[0,0] = np.nan + with pytest.raises(ValueError): register(p, geometry(), np.eye(4)) + with pytest.raises(ValueError): register(geometry()[:20], geometry(), np.eye(4)) + + +def test_path_hint_maps_query_entry_and_heading_to_selected_route(): + t = path_hint([[8,9,1],[8,14,1]], [[-1,-2,0],[4,-2,0]]) + assert np.allclose(transform(np.array([[-1,-2,0],[4,-2,0]]),t), [[8,9,1],[8,14,1]]) + with pytest.raises(ValueError): path_hint([[0,0,0],[0,0,0]], [[0,0,0],[4,0,0]]) + + +def test_extractor_uses_only_selected_interval_and_no_second_pose_transform(tmp_path): + raw = tmp_path/'mqtt.raw.k1mqtt' + _write_capture(raw, [('x/lio_pose', _pose_payload((5,0,0))), + ('x/lio_pcl', _pcl_payload(scaler=1000, point_count=4)), + ('x/lio_pose', _pose_payload((8,0,0))), + ('x/lio_pcl', b'bad-future-cloud')]) + out = tmp_path/'planning.json'; export_planning_source(raw,out) + points, meta = extract_submap(raw,json.loads(out.read_text()),0,1) + assert len(points) >= 1 and np.allclose(points[0], [1,-2,.5]) + assert meta['available_frames'] == 1 and meta['frames'][0]['message_index'] == 1 + assert meta['message_interval'] == [0,2] + + +def test_run_rejects_overlapping_self_comparison_and_revision_race(tmp_path): + source = {'poses':[{'position':[i,0,0],'distance_m':i} for i in range(31)]} + draft = {'id':'a', 'revision':1, 'zone':{'session_id':'A'}, + 'route':{'length_m':30,'start_index':0,'end_index':30}} + drafts = SimpleNamespace(database=tmp_path/'drafts.sqlite', get=lambda _:draft, + sources=SimpleNamespace(bound=lambda *_:source)) + runs = RegistrationRuns(drafts) + req = {'revision':1,'session_id':'A','generation':'x','start_index':0,'end_index':30} + try: + with pytest.raises(ValueError, match='не должны пересекаться'): runs.start('a',req) + with pytest.raises(ValueError, match='изменён'): runs.start('a',{**req,'revision':2}) + assert not runs.lock.locked() and not list(runs.root.glob('*/report.json')) + finally: runs.close() + + +def test_run_is_single_owner_persisted_and_bound_to_submitted_revision(tmp_path, monkeypatch): + import k1link.missions.registration_runs as module + entered, release = threading.Event(), threading.Event() + source = {'poses':[{'position':[i,0,0], 'distance_m':i} for i in range(31)]} + draft = {'id':'draft', 'revision':1, 'zone':{'session_id':'A','generation':'a'}, + 'route':{'length_m':30,'start_index':0,'end_index':30, + 'points':[{'position':[i,0,0]} for i in range(31)]}} + def submap(*args): + entered.set(); assert release.wait(5) + return geometry(), {'session_id':args[0]} + drafts = SimpleNamespace(database=tmp_path/'db', get=lambda _:draft, + sources=SimpleNamespace(bound=lambda *_:source, submap=submap)) + monkeypatch.setattr(module, 'write_scene', lambda p,*a: p.write_bytes(b'test-rrd')) + runs = RegistrationRuns(drafts) + req = {'revision':1,'session_id':'B','generation':'b','start_index':0,'end_index':30} + first = runs.start('draft',req) + try: + assert entered.wait(5) + with pytest.raises(ValueError, match='ещё выполняется'): runs.start('draft',req) + # Real draft reads are decoded snapshots. A later edit does not relabel an existing report. + draft = {**draft, 'revision':2} + finally: + release.set(); runs.close() + report = runs.get(first['id']) + assert report['state'] == 'ready' and report['revision'] == 1 + assert report['reference']['session_id'] == 'A' and report['query']['session_id'] == 'B' + assert not report['localization_confirmed'] and not report['vehicle_control'] + restarted = RegistrationRuns(drafts) + try: + assert restarted.get(first['id']) == report + assert restarted.list('draft')[0]['id'] == first['id'] + finally: restarted.close() diff --git a/tests/test_planning_archive_fault.py b/tests/test_planning_archive_fault.py new file mode 100644 index 0000000..1b3fa53 --- /dev/null +++ b/tests/test_planning_archive_fault.py @@ -0,0 +1,64 @@ +"""The archive fault adapter omits receipts without changing surviving clocks.""" + +import importlib.util +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +def adapter_module(): + path = Path(__file__).resolve().parents[1] / "scripts/planning_archive_source.py" + spec = importlib.util.spec_from_file_location("archive_fault_probe", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_fault_preserves_survivor_identity_payload_and_receipt_offsets(monkeypatch): + module = adapter_module() + original = [ + SimpleNamespace( + received_monotonic_ns=int((100 + t) * 1e9), + received_at_epoch_ns=int((1000 + t) * 1e9), + topic="fixture/lio_pcl" if i % 2 else "fixture/lio_pose", + sequence=i + 1, + payload=bytes([i]), + ) + for i, t in enumerate([0, 1, 2, 3, 3.9, 4, 5]) + ] + monkeypatch.setattr(module, "iter_replay_messages", lambda _: iter(original)) + clock = [int(500e9)] + + def wait(delay): + clock[0] += int(delay * 1e9) + return False + + monkeypatch.setattr(module, "time", SimpleNamespace(monotonic_ns=lambda: clock[0])) + source = module.ReceiptQueueArchiveSource(Path("unused"), "B", 10, drop_interval_s=(2, 4)) + source.stop = SimpleNamespace(wait=wait) + source.started = clock[0] + published = [] + source.ingress = SimpleNamespace( + publish=lambda **kw: published.append(kw) or True, + end_session=lambda session: None, + ) + source.publish() + kept = [original[i] for i in [0, 1, 5, 6]] + assert source.error is None + assert [x["sequence"] for x in source.dropped_receipts] == [3, 4, 5] + for out, entry in zip(published, kept, strict=True): + assert out["source_sequence"] == entry.sequence + assert out["payload"] is entry.payload + assert out["captured_at_epoch_ns"] == entry.received_at_epoch_ns + assert out["received_monotonic_ns"] == source.started + entry.received_monotonic_ns - int( + 100e9 + ) + + +@pytest.mark.parametrize("interval", [(2, 2), (4, 2), (0, 2), (1, 11), (1, float("nan"))]) +def test_invalid_fault_interval_is_rejected(interval): + with pytest.raises(ValueError, match="fault interval"): + adapter_module().ReceiptQueueArchiveSource( + Path("unused"), "B", 10, drop_interval_s=interval + ) diff --git a/tests/test_planning_cascade.py b/tests/test_planning_cascade.py new file mode 100644 index 0000000..8aa3676 --- /dev/null +++ b/tests/test_planning_cascade.py @@ -0,0 +1,98 @@ +"""Candidate fallback through the real service loop; synthetic fit outcomes only.""" + +import json +from types import SimpleNamespace + +import numpy as np +import pytest +from test_planning_live import event, fixture_service, until + +from k1link.missions.route_relocalization import ROUTE_RELOCALIZATION_POLICY + + +@pytest.mark.parametrize("first_stage", ["route", "dense-start"]) +def test_service_confirms_next_hypothesis_without_capture_restart( + tmp_path, monkeypatch, first_stage, +): + import k1link.missions.live_tests as module + + service, source, lock, _ = fixture_service(tmp_path, monkeypatch) + clock = [100.0] + monkeypatch.setattr(module, "time", SimpleNamespace( + monotonic=lambda: clock[0], monotonic_ns=lambda: int(clock[0] * 1e9))) + initial_calls, fit_seeds = [], [] + alternative = np.eye(4) + alternative[0, 3] = 10 + + def initialize(directory, ref, path, query, anchor, **kwargs): + initial_calls.append(kwargs) + matrix = alternative if kwargs.get("route_only") else np.eye(4) + info = dict(complete=True, scope="selected-route", policy=ROUTE_RELOCALIZATION_POLICY, + expected_attempts=1, attempts=[{}], selected_candidate_index=0) + if first_stage == "dense-start" and len(initial_calls) == 1: + info["stages"] = [dict(name="dense-start")] + elif len(initial_calls) == 1: + info["candidate_queue"] = [ + dict(candidate_index=0, T_reference_query=matrix.tolist(), ambiguous=False), + dict(candidate_index=2, T_reference_query=alternative.tolist(), ambiguous=False), + ] + return dict(status="candidate", T_reference_query=matrix.tolist(), overlap=.95, + inlier_rmse_m=.1, reasons=[], matched_query_indices=[], initialization=info) + + def calculate(directory, ref, query, hint): + fit_seeds.append(hint.copy()) + if len(fit_seeds) == 1: + return dict(status="rejected", T_reference_query=hint.tolist(), overlap=.5, + inlier_rmse_m=.3, reasons=["fixture rejection"], matched_query_indices=[]) + return dict(status="candidate", T_reference_query=hint.tolist(), overlap=.95, + inlier_rmse_m=.1, reasons=[], matched_query_indices=[0]) + + monkeypatch.setattr(module, "run_route_relocalization", initialize) + monkeypatch.setattr(module, "run_registration", calculate) + run = service.start("draft", 1) + points = np.random.default_rng(11).uniform([-1, -3, -1], [8, 3, 3], (1500, 3)) + sequence = 0 + + def frame(stamp): + nonlocal sequence + clock[0] = stamp + .002 + sequence += 1 + source.queue.put(event("pose", t=stamp, sequence=sequence)) + sequence += 1 + source.queue.put(event("points", t=stamp + .001, sequence=sequence, points=points)) + until(source.queue.empty) + + try: + until(lambda: service.get()["state"] == "waiting") + source.state.update(active=True, session_id="B", session_generation=2) + for i in range(140): + frame(100 + i * .5) + if service.get()["tracking_state"] == "tracking": + break + until(lambda: service.get()["tracking_state"] == "tracking") + assert service.get()["initialization_attempt"] == 1 + assert service.get()["reinitialization_count"] == 0 + assert source.state["active"] and source.owner == "planning-" + run["id"] + assert all(np.allclose(seed, alternative) for seed in fit_seeds[1:]) + assert len(fit_seeds) >= 4 # Rejection plus three independent accepted windows. + if first_stage == "dense-start": + assert initial_calls == [{}, {"route_only": True}] + else: + assert initial_calls == [{}] + seen = set() + sources = sorted(service.directory(run["id"]).glob("step-*/source.json")) + for path in sources: + sample = json.loads(path.read_text()) + if sample["role"] != "fresh-validation": + continue + ids = {item["sequence"] for item in sample["events"]} + assert not seen.intersection(ids) + assert all(item["monotonic_ns"] > sample["fresh_floor_ns"] + for item in sample["events"]) + seen.update(ids) + source.state["active"] = False + until(lambda: service.get()["state"] == "completed") + assert service.accepted_sample is None + finally: + service.close() + assert not lock.locked() and source.owner is None diff --git a/tests/test_planning_failure_boundaries.py b/tests/test_planning_failure_boundaries.py new file mode 100644 index 0000000..91130a4 --- /dev/null +++ b/tests/test_planning_failure_boundaries.py @@ -0,0 +1,247 @@ +"""Small failure fixtures; no device, network, live singleton or native search.""" + +from concurrent.futures import Future +from types import SimpleNamespace + +import numpy as np +import pytest +from test_planning_live import event, fixture_service, until +from test_stream_summary import _pose_payload + +from k1link.compute.live_perception import LivePerceptionIngress +from k1link.device_plugins.xgrids_k1.planning_live import K1PlanningLiveSource +from k1link.missions.stationary_live import run_stationary_live + + +@pytest.mark.parametrize("failure", ["persist", "thread-constructor", "thread-start", "open"]) +def test_failed_start_releases_resources_and_allows_retry(tmp_path, monkeypatch, failure): + import k1link.missions.live_tests as module + + service, source, compute_lock, _ = fixture_service(tmp_path, monkeypatch) + + def fail(*args, **kwargs): + raise OSError("injected startup failure") + + with monkeypatch.context() as patch: + if failure == "persist": + patch.setattr(service, "persist", fail) + elif failure == "thread-constructor": + patch.setattr(module.threading, "Thread", fail) + elif failure == "thread-start": + patch.setattr(module.threading.Thread, "start", fail) + else: + patch.setattr(source, "open", fail) + try: + with pytest.raises(OSError, match="injected startup failure"): + service.start("draft", 1) + assert not compute_lock.locked() + assert source.owner is None + assert service.thread is None + if failure != "open": + assert service.get()["state"] == "error" + assert service.get()["tracking_state"] == "lost" + assert service.source is None + finally: + # Also isolate the pre-fix red run, whose leaked resources are fixtures. + if compute_lock.locked(): + compute_lock.release() + source.owner = None + + run = service.start("draft", 1) + try: + until(lambda: service.get()["state"] == "waiting") + service.stop(run["id"]) + finally: + service.close() + assert not compute_lock.locked() and source.owner is None + + +@pytest.mark.parametrize("failure", ["executor", "report", "close"]) +def test_worker_cleanup_keeps_failure_terminal_and_unlocks(tmp_path, monkeypatch, failure): + import k1link.missions.live_tests as module + + service, source, compute_lock, _ = fixture_service(tmp_path, monkeypatch) + original_persist = service.persist + original_close = source.close + + def fail(*args, **kwargs): + raise OSError("injected worker failure") + + if failure == "executor": + monkeypatch.setattr(module, "ThreadPoolExecutor", fail) + elif failure == "report": + + def persist(): + if service.run["state"] != "preparing": + fail() + original_persist() + + monkeypatch.setattr(service, "persist", persist) + else: + + def close(owner): + original_close(owner) + fail() + + monkeypatch.setattr(source, "close", close) + + run = service.start("draft", 1) + try: + if failure == "close": + until(lambda: service.get()["state"] == "waiting") + service.stop(run["id"]) + until(lambda: not service.thread.is_alive()) + assert not compute_lock.locked() + assert source.owner is None + assert service.get()["state"] == "error" + assert service.accepted_sample is None and service.last_result_ns == 0 + finally: + service.close() + if compute_lock.locked(): + compute_lock.release() + source.owner = None + + +def test_partial_report_write_is_recovered_as_terminal_before_retry(tmp_path, monkeypatch): + import k1link.missions.live_tests as module + + service, source, compute_lock, _ = fixture_service(tmp_path, monkeypatch) + replace_file = module.os.replace + failures = [] + + def fail_first_active_replace(src, dst): + if dst == service.root / "active.json" and not failures: + failures.append(dst) + raise OSError("injected partial report commit") + replace_file(src, dst) + + monkeypatch.setattr(module.os, "replace", fail_first_active_replace) + with pytest.raises(OSError, match="partial report commit"): + service.start("draft", 1) + assert not compute_lock.locked() and source.owner is None + assert service.thread is None + restored = module.PlanningLiveTests(service.drafts, service.sources, compute_lock) + assert restored.get()["state"] == "error" + assert restored.get()["tracking_state"] == "lost" + assert restored.get()["scene_available"] is False + assert ( + "partial report commit" + in (service.directory(service.run["id"]) / "failure.txt").read_text() + ) + run = service.start("draft", 1) + try: + until(lambda: service.get()["state"] == "waiting") + service.stop(run["id"]) + finally: + service.close() + assert not compute_lock.locked() and source.owner is None + + +@pytest.mark.parametrize("modality", ["camera-init", "camera-frame"]) +def test_auxiliary_receipt_is_not_an_empty_queue(modality): + ingress = LivePerceptionIngress() + source = K1PlanningLiveSource(ingress) + source.open("fixture") + ingress.begin_session("B") + try: + assert source.take("fixture").kind == "session-start" + ingress.publish( + modality=modality, + source_id="camera", + source_sequence=1, + captured_at_epoch_ns=10, + received_monotonic_ns=20, + payload=b"fixture", + ) + ingress.publish( + modality="pose", + source_id="x/lio_pose", + source_sequence=2, + captured_at_epoch_ns=11, + received_monotonic_ns=21, + payload=_pose_payload((0, 0, 0)), + ) + auxiliary = source.take("fixture") + assert auxiliary is not None and auxiliary.kind == "ignored" + assert auxiliary.session_id == "B" and auxiliary.generation == 1 + assert auxiliary.monotonic_ns == 20 and auxiliary.epoch_ns == 10 + assert auxiliary.points is None and auxiliary.position is None + assert ingress.snapshot()["queues"]["pose"]["depth"] == 1 + pose = source.take("fixture") + assert pose.kind == "pose" and pose.sequence > auxiliary.sequence + finally: + source.close("fixture") + ingress.close() + + +def test_delayed_auxiliary_receipt_does_not_freeze_before_queued_prefix(tmp_path, monkeypatch): + service, _, _, _ = fixture_service(tmp_path, monkeypatch) + service.reference_path = np.array([[0.0, 0, 0], [20.0, 0, 0]]) + points = np.random.default_rng(501).uniform([-2, -2, -1], [2, 2, 2], (700, 3)) + service.reference = points + service.run = dict( + baseline_generation=0, + baseline_session_id="old", + draft=dict(zone=dict(session_id="A")), + maximum_distance_m=20, + ) + updates = [] + service.update = lambda **values: updates.append(values) + service.directory = lambda _: tmp_path + clock_value = [100.0] + pending = [] + for t in range(100, 111): + pending.extend([event("pose", t=t), event("points", t=t + 0.001, points=points)]) + if t == 108: + # Use the production adapter, not an invented None/ignored value. + ingress = LivePerceptionIngress() + adapter = K1PlanningLiveSource(ingress) + adapter.open("fixture") + ingress.begin_session("B") + adapter.take("fixture") + ingress.publish( + modality="camera-frame", + source_id="camera", + source_sequence=1, + captured_at_epoch_ns=1, + received_monotonic_ns=108_100_000_000, + payload=b"fixture", + ) + pending.append(adapter.take("fixture")) + adapter.close("fixture") + ingress.close() + # Preserve the same session/generation/order across modality boundaries. + from dataclasses import replace + + pending = [ + (108.1, None) + if item is None + else (item.monotonic_ns / 1e9, replace(item, generation=1, sequence=i + 1)) + for i, item in enumerate(pending) + ] + + class Source: + def snapshot(self): + return dict(active=True, session_id="B", session_generation=1) + + def take(self, owner): + stamp, item = pending.pop(0) + clock_value[0] = stamp + 2 + return item + + searches = [] + + class Executor: + def submit(self, *args, **kwargs): + searches.append((args, kwargs)) + service.cancel.set() + future = Future() + future.set_result({}) + return future + + clock = SimpleNamespace( + monotonic=lambda: clock_value[0], monotonic_ns=lambda: int(clock_value[0] * 1e9) + ) + run_stationary_live(service, Source(), "fixture", Executor(), clock, None, None) + assert len(searches) == 1 and not pending + assert any(update.get("planning_phase") == "searching" for update in updates) diff --git a/tests/test_planning_fast_display.py b/tests/test_planning_fast_display.py new file mode 100644 index 0000000..6502e13 --- /dev/null +++ b/tests/test_planning_fast_display.py @@ -0,0 +1,312 @@ +"""Bounded functional tests of display/registration separation and delta fences.""" + +from dataclasses import replace +from types import SimpleNamespace + +import numpy as np +from fastapi import FastAPI +from fastapi.testclient import TestClient +from test_planning_live import event, fixture_service +from test_planning_stabilization import fit, sample + +from k1link.missions.live_buffer import LiveCloudBuffer +from k1link.missions.live_display_buffer import LiveDisplayBuffer +from k1link.missions.live_scene_delta import decode_cursor, scene_delta +from k1link.web.planning_live_api import build_planning_live_router + + +def ingest(display, numeric, t, sequence, points=None): + pose = event("pose", t=t, sequence=sequence, p=(t / 100, 0, 0)) + cloud = event( + "points", + t=t + 0.001, + sequence=sequence + 1, + points=np.array([[t / 100, 1, 0]]) if points is None else points, + ) + for e in (pose, cloud): + numeric.ingest(e) + display.ingest(e, numeric) + return cloud + + +def test_display_preserves_high_points_independently_from_tracking_filter(): + numeric, display = LiveCloudBuffer([[0,0,0],[10,0,0]]), LiveDisplayBuffer() + points = np.array([[1,1,1],[1,0,40],[1,0,79.9],[1,0,80.1]]) + ingest(display, numeric, 100, 1, points) + np.testing.assert_allclose(display.snapshot()["points"], points[:3]) + np.testing.assert_allclose(numeric.snapshot()["points"], points[:1]) + + +def test_native_packets_are_not_numerical_sampling_and_snapshots_are_immutable(): + numeric, display = LiveCloudBuffer([[0, 0, 0], [10, 0, 0]]), LiveDisplayBuffer() + ingest(display, numeric, 100, 1) + first = display.snapshot() + for i in range(1, 10): + ingest(display, numeric, 100 + i / 10, i * 2 + 1) + assert display.frames == 10 + assert len(numeric.chunks) == 2 # registration's 500 ms policy is unchanged + assert display.snapshot()["sequence"] == 20 + assert first["sequence"] == 2 and first["cloud_revision"] == 1 + np.testing.assert_equal(first["points"], [[1, 1, 0]]) + assert len(display.chunks) == 1 # The current half-second receipt stays live. + np.testing.assert_allclose(display.snapshot()["current_points"], [[1.009, 1, 0]]) + + +def test_budgets_eviction_tombstones_old_receipts_and_segment_reset(): + numeric, display = LiveCloudBuffer([[0, 0, 0], [10, 0, 0]]), LiveDisplayBuffer() + points = np.array([[x * 0.3, y * 0.3, 0] for x in range(35) for y in range(35)]) + for i in range(60): + ingest(display, numeric, 100 + i * 0.5, 2 * i, points) + assert len(display.chunks) <= 40 + assert sum(map(len, display.chunks.values())) <= 40_000 + before = display.frames + display.ingest(event("points", t=100, points=points), numeric) + assert display.frames == before + assert any(p is None and revision > 0 for _, revision, p in display.snapshot()["chunks"]) + ingest(display, numeric, 133, 200, points) + assert numeric.segment == 1 and len(display.chunks) == 0 + assert len(display.snapshot()["current_points"]) == len(points) + + +def test_delta_sends_only_changed_chunks_pose_and_transform(monkeypatch): + import k1link.missions.live_scene_delta as module + + logs = [] + + class Recording: + def __init__(self, *args, **kwargs): + pass + + def binary_stream(self): + return SimpleNamespace(read=lambda: b"RRF2") + + def log(self, name, value, **kwargs): + logs.append((name, value, kwargs)) + + def set_time(self, *args, **kwargs): + logs.append(("time", args, kwargs)) + + def flush(self): + pass + + def disconnect(self): + pass + + monkeypatch.setattr(module.rr, "RecordingStream", Recording) + monkeypatch.setattr(module, "log_base", lambda *args: None) + monkeypatch.setattr(module, "log_view", lambda *args: None) + monkeypatch.setattr(module.rr, "Points3D", lambda points, **kwargs: np.asarray(points)) + monkeypatch.setattr(module.rr, "Transform3D", lambda **kwargs: kwargs) + numeric, display = LiveCloudBuffer([[0, 0, 0], [10, 0, 0]]), LiveDisplayBuffer() + ingest(display, numeric, 100, 1) + + def render(cursor="", result=None, live=True, evidence=None, options=None): + return scene_delta( + "run", + display.epoch, + np.zeros((3, 3)), + np.zeros((2, 3)), + display.snapshot(), + result or fit(), + evidence, + live, + cursor=cursor, + options=options, + ) + + _, cursor = render() + assert all( + kwargs.get("static") + for name, _, kwargs in logs + if name == "world/query" or "/cloud/" in name + ) + np.testing.assert_equal(dict((n, v) for n, v, _ in logs)["world/query/live"], [[1, 1, 0]]) + logs.clear() + render(options={"ceiling_m": -0.1}) + assert len(dict((n, v) for n, v, _ in logs)["world/query/live"]) == 0 + logs.clear() + assert render(cursor)[0] == b"" + assert logs == [] + ingest(display, numeric, 100.1, 3) + _, cursor = render(cursor) + assert [n for n, _, _ in logs if "/cloud/" in n] == [] + assert "world/query/live" in [n for n, _, _ in logs] + assert "world/query" not in [n for n, _, _ in logs] + logs.clear() + adjusted = fit() + adjusted["T_reference_query"][0][3] = 9 + _, cursor = render(cursor, result=adjusted) + assert [n for n, _, _ in logs] == ["world/query", "time", "world/query/live"] + assert logs[0][1]["translation"][0] == 9 + logs.clear() + _, cursor = render(cursor, result=adjusted, evidence=(sample(), fit())) + assert "world/validated_query" in [n for n, _, _ in logs] + logs.clear() + render(cursor, result=adjusted, live=False) + assert "world/validated_query" in [n for n, _, _ in logs] + assert not any("/cloud/" in n for n, _, _ in logs) + + +def test_presentation_changes_and_geometry_repairs_never_reset_camera(monkeypatch): + import k1link.missions.live_scene_delta as module + + views = [] + monkeypatch.setattr(module, "log_view", lambda *args: views.append(args[-1].copy())) + reference = np.array([[0., 0., -1.], [20., 1., 40.]]) + cursor = "" + + def render(options=None, **kwargs): + nonlocal cursor + payload, cursor = scene_delta( + "camera-test", kwargs.pop("epoch", "first"), reference, reference, + None, None, None, False, cursor=cursor, options=options, **kwargs, + ) + return payload + + assert render().startswith(b"RRF2") + assert len(views) == 1 + for options in ({"ceiling_m": 3}, {"reference": False}, {"query": False}, + {"trajectory": False}, {"point_size": 4}, {"grid": False}, {}): + assert render(options).startswith(b"RRF2") + assert len(views) == 1 + assert render(base=True).startswith(b"RRF2") + assert render(epoch="reconnected").startswith(b"RRF2") + assert len(views) == 1 + render({"mode": "top"}) + render({"mode": "top", "reset": 1}) + assert len(views) == 3 + assert render({"mode": "top", "reset": 1}) == b"" + assert len(views) == 3 + + +def test_grid_is_display_geometry_and_never_a_camera_command(): + from k1link.missions.live_scene import grid_lines, log_base + + reference = np.array([[0., 0., -1.], [20., 1., 40.]]) + logs = {} + recording = SimpleNamespace(log=lambda path, value, **kw: logs.update({path: value})) + log_base(recording, reference, reference, {"ceiling_m": 3}) + assert "world/grid" in logs + assert len(logs["world/reference"].positions.as_arrow_array()) == 1 + assert len(grid_lines(reference)) > 0 + log_base(recording, reference, reference, {"grid": False}) + assert len(logs["world/grid"].strips.as_arrow_array()) == 0 + + +def test_service_freezes_after_loss_fences_identity_and_expires_without_packets( + tmp_path, monkeypatch +): + import k1link.missions.live_tests as module + + service, source, _, _ = fixture_service(tmp_path, monkeypatch) + run_id = "cedf3261-a703-453d-a1c8-aac71c6ce5e3" + service.run = dict( + id=run_id, + state="running", + query_session_id="B", + query_generation=2, + vehicle_control=False, + ) + service.source = source + source.state.update(active=True, session_id="B", session_generation=2) + service.reference, service.reference_path = np.zeros((3, 3)), np.zeros((2, 3)) + summary = service.get() + assert summary["scene_height_min_m"] == 0.0 + assert summary["scene_height_max_m"] == 80.0 + clock = [int(100.1e9)] + monkeypatch.setattr(module, "time", SimpleNamespace(monotonic_ns=lambda: clock[0])) + monkeypatch.setattr(service, "persist", lambda: None) + numeric = LiveCloudBuffer(service.reference_path) + for e in (event("pose", t=100), event("points", t=100.01, points=np.ones((2, 3)))): + numeric.ingest(e) + service.observe_display(e, numeric, clock[0]) + assert service.presentation.sample is None # no hint fallback + service.commit_result( + fit(), + sample(), + {"accepted": True}, + "tracking", + phase="tracking", + message="tracking", + tracking_established=True, + ) + app = FastAPI() + app.include_router(build_planning_live_router(service)) + with TestClient(app) as client: + url = f"/api/v1/mission-planner/live-tests/{run_id}/scene-delta.rrd" + first = client.get(url) + assert first.status_code == 200 and first.content.startswith(b"RRF2") + cursor = first.headers["X-Planning-Scene-Cursor"] + assert first.headers["X-Planning-Cloud-Revision"] == "1" + assert first.headers["X-Planning-Cloud-Sequence"] == "1" + assert first.headers["X-Planning-Height-Min"] == "0.0" + assert first.headers["X-Planning-Height-Max"] == "80.0" + observation = { + "schema_version": "missioncore.planning-browser-presentation/v1", + "samples": [ + { + "cloud_revision": 1, + "cloud_sequence": 1, + "display_epoch": service.display.epoch, + "request_ms": 40.0, + "rerun_admission_ms": 2.0, + "first_animation_frame_ms": 12.0, + "second_animation_frame_ms": 28.0, + "frame_timeout": False, + "source_to_second_animation_frame_upper_bound_ms": 268.0, + } + ], + } + report_url = ( + f"/api/v1/mission-planner/live-tests/{run_id}/presentation-observations" + ) + assert client.post(report_url, json=observation).status_code == 204 + presentation = service.get()["browser_presentation"] + assert presentation["reported_sample_count"] == 1 + assert presentation["second_animation_frame_ms"]["p50"] == 28.0 + assert service.get()["vehicle_control"] is False + observation["samples"][0]["cloud_revision"] = 2 + assert client.post(report_url, json=observation).status_code == 204 + assert service.get()["browser_presentation"]["rejected_sample_count"] == 1 + assert client.get(url, params={"cursor": cursor}).status_code == 204 + assert client.get(url, params={"cursor": "corrupt"}).status_code == 200 + assert client.get(url, params={"cursor": "a" * 2049}).status_code == 422 + clock[0] = int(102.2e9) + expired = client.get(url, params={"cursor": cursor}) + assert expired.status_code == 200 + assert decode_cursor(expired.headers["X-Planning-Scene-Cursor"])["evidence"] is None + frozen = service.presentation.sample + service.accepted_sample = None + fresh = event("points", t=102.3, points=np.ones((2, 3))) + service.observe_display(replace(fresh, session_id="other"), numeric, int(102.3e9)) + assert service.display.frames == 1 + numeric.ingest(event("pose", t=102.2)) + service.observe_display(event("pose", t=102.2), numeric, int(102.2e9)) + service.observe_display(fresh, numeric, int(102.3e9)) + assert service.presentation.sample is frozen + source.state["session_generation"] = 3 + assert not service._view_live(int(102.3e9)) + + +def test_reinitialize_endpoint_discards_only_a_failed_initialization(tmp_path, monkeypatch): + service, _, _, _ = fixture_service(tmp_path, monkeypatch) + run_id = "cedf3261-a703-453d-a1c8-aac71c6ce5e4" + service.run = dict( + id=run_id, + state="running", + planning_phase="lost", + tracking_established=False, + initialization_attempt=1, + reinitialization_count=0, + ) + service.persist = lambda: None + app = FastAPI() + app.include_router(build_planning_live_router(service)) + with TestClient(app) as client: + response = client.post(f"/api/v1/mission-planner/live-tests/{run_id}/reinitialize") + assert response.status_code == 200 + body = response.json() + assert body["state"] == "running" + assert body["planning_phase"] == "waiting-cloud" + assert body["initialization_attempt"] == 2 + assert body["reinitialization_count"] == 1 diff --git a/tests/test_planning_live.py b/tests/test_planning_live.py new file mode 100644 index 0000000..9415c8d --- /dev/null +++ b/tests/test_planning_live.py @@ -0,0 +1,441 @@ +"""Bounded functional fixtures; no device, socket, capture or load generation.""" +import json +import threading +import time +from queue import Queue, Empty +from types import SimpleNamespace +import numpy as np +import pytest +from k1link.sessions.live_planning import PlanningLiveEvent +from k1link.missions.live_buffer import LiveCloudBuffer +from k1link.missions.live_tests import PlanningLiveTests +from k1link.missions.registration_colors import query_colors +from k1link.missions.live_scene import scene_bytes +from k1link.compute.live_perception import LivePerceptionIngress +from k1link.device_plugins.xgrids_k1.planning_live import K1PlanningLiveSource +from test_stream_summary import _pose_payload, _pcl_payload + + +def event(kind, *, t=1, p=(0,0,0), points=None, sequence=1): + return PlanningLiveEvent('B',2,sequence,int(t*1e9),int(t*1e9),kind,points,p) + + +def test_causal_pose_and_bounded_point_window(): + b=LiveCloudBuffer([[10,10,0],[14,10,0]]) + cloud=np.array([[1,1,1],[1.01,1,1],[99,1,1],[1,1,20]]) + b.ingest(event('points',points=cloud));assert len(b.snapshot()['points'])==0 + b.ingest(event('pose',t=2)) + b.ingest(event('points',t=1.9,points=cloud));assert len(b.snapshot()['points'])==0 + b.ingest(event('points',t=2.6,points=cloud));assert len(b.snapshot()['points'])==0 + b.ingest(event('points',t=2.1,points=cloud));assert len(b.snapshot()['points'])==1 + # No double pose transform: query remains in its native local K1 frame. + assert np.allclose(b.snapshot()['points'][0],[1,1,1]) + for i in range(1,80): + b.ingest(event('pose',t=3+i,p=(i*.05,0,0))) + b.ingest(event('points',t=3.1+i,points=cloud,sequence=i)) + assert len(b.chunks)==40 and len(b.events)==40 + assert len(b.snapshot()['points'])<=40000 + with pytest.raises(ValueError,match='Разрыв координат'):b.ingest(event('pose',t=200,p=(100,0,0))) + + +def test_route_initialization_can_request_an_explicit_wider_k1_scene(): + b=LiveCloudBuffer([[0,0,0],[10,0,0]],point_radius_m=80) + b.ingest(event('pose',t=1,p=(0,0,0))) + b.ingest(event('points',t=1.1,points=np.array([[79.9,0,1],[80.1,0,1]]))) + sample=b.snapshot() + assert sample['point_radius_m']==80 + assert len(sample['points'])==1 + + +def test_only_accepted_correspondences_are_green(): + p=np.array([[0,0,0],[0,0,1],[0,0,2]]) + green=np.array([154,235,75]) + assert not (query_colors(p)==green).all(axis=1).any() + assert not (query_colors(p,{'status':'rejected','matched_query_indices':[0,1,2]})==green).all(axis=1).any() + c=query_colors(p,{'status':'candidate','matched_query_indices':[1]}) + assert (c==green).all(axis=1).tolist()==[False,True,False] + + +def test_plugin_adapter_reads_existing_committed_ingress_only(): + ingress=LivePerceptionIngress();adapter=K1PlanningLiveSource(ingress) + adapter.open('test');ingress.begin_session('B') + assert adapter.take('test').kind=='session-start' + ingress.publish(modality='pose',source_id='x/lio_pose',source_sequence=3, + captured_at_epoch_ns=4,received_monotonic_ns=5,payload=_pose_payload((5,0,0))) + p=adapter.take('test');assert p.position==(5.,0.,0.) and p.generation==1 + ingress.publish(modality='lidar',source_id='x/lio_pcl',source_sequence=4, + captured_at_epoch_ns=5,received_monotonic_ns=6,payload=_pcl_payload(scaler=1000,point_count=4)) + frame=adapter.take('test');assert np.allclose(frame.points[0],[1,-2,.5]) + with pytest.raises(RuntimeError):adapter.open('other-profile') + adapter.close('test');assert not ingress.snapshot()['consumer_connected'] + ingress.close() + + +class Source: + def __init__(self):self.state=dict(active=False,session_generation=1,session_id='old');self.queue=Queue();self.owner=None + def snapshot(self):return dict(self.state) + def open(self,id): + if self.owner:raise RuntimeError('busy') + self.owner=id + def close(self,id):assert self.owner==id;self.owner=None + def take(self,id): + try:return self.queue.get(timeout=.02) + except Empty:return None + + +def fixture_service(tmp_path,monkeypatch): + import k1link.missions.live_tests as module + draft=dict(id='draft',name='Test route',revision=1,zone=dict(session_id='A',generation='a'), + route=dict(length_m=20,start_index=0,end_index=20,points=[dict(position=[i,0,0]) for i in range(21)])) + r=np.random.default_rng(19);points=r.normal(size=(1000,3)) + sources=SimpleNamespace(store=SimpleNamespace(get_session=lambda _:SimpleNamespace(plugin_id='test-plugin')), + reference_map=lambda *args,**kwargs:(points,dict(session_id='A',source_digests={'raw':'a'*64}))) + drafts=SimpleNamespace(database=tmp_path/'db',sources=sources,get=lambda _:json.loads(json.dumps(draft))) + source=Source();lock=threading.Lock();service=PlanningLiveTests(drafts,{'test-plugin':source},lock) + def calculate(directory,ref,query,hint): + # Real numeric regression is tested separately; this fixture verifies orchestration. + return dict(status='candidate',T_reference_query=hint.tolist(),matched_query_indices=[0], + overlap=.9,inlier_rmse_m=.1,reasons=[]) + monkeypatch.setattr(module,'run_registration',calculate) + from k1link.missions.route_relocalization import ROUTE_RELOCALIZATION_POLICY + monkeypatch.setattr(module,'run_route_relocalization', + lambda directory,ref,path,query,anchor:dict( + status='candidate',T_reference_query=np.eye(4).tolist(), + matched_query_indices=[0],overlap=.9,inlier_rmse_m=.1,reasons=[], + initialization=dict(complete=True,scope='selected-route', + policy=ROUTE_RELOCALIZATION_POLICY,expected_attempts=1,attempts=[{}]))) + return service,source,lock,draft + + +def until(fn): + deadline=time.monotonic()+5 + while time.monotonic()