feat(planning): consolidate recorded-route localization and spatial scene

Preserve the completed teach-and-repeat laboratory stage: reference preparation, cascaded acquisition, local tracking and recovery, recording lifecycle, replay qualification, and persistent Rerun scene controls. Document the open grid-picking regression and Rerun upgrade contract. No autonomous driving or loop-closure optimization is claimed.
This commit is contained in:
DCCONSTRUCTIONS
2026-09-21 08:47:19 +03:00
parent be58d589e2
commit e515ab1b8c
189 changed files with 19074 additions and 758 deletions
+10
View File
@@ -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<HTMLDivElement | null>(null);
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(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" ? (
<div ref={setWorkspaceHeaderToolsHost} />
) : activeDefinition.kind === "vehicles" ? null : activeDefinition.kind === "datasets" ? (
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
) : 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 ? (
<SessionOverviewWorkspace key={recordedReplay.sessionId} sessionId={recordedReplay.sessionId} />
) : (
<WorkspaceRenderer
definition={activeDefinition}
fleetCreateRequest={fleetCreateRequest}
headerToolsHost={workspaceHeaderToolsHost}
state={activeRuntimeState}
backendStatus={runtime.backendStatus}
sourceUrl={effectiveSourceUrl}
@@ -28,6 +28,7 @@ export interface CameraLeaseRetryBudget {
const CAMERA_LEASE_RETRY_DELAYS = [400, 1_000, 2_000, 5_000] as const;
export const CAMERA_FIRST_MEDIA_TIMEOUT_MS = 8_000;
export const CAMERA_FIRST_PLAYABLE_FRAME_TIMEOUT_MS = 8_000;
export const CAMERA_BUFFERING_NOTICE_DELAY_MS = 1_000;
// The gateway may release an 8 MiB / 64-fragment slow-reader backlog after a
// main-thread stall. Keep one bounded append margin above that complete batch;
// crossing either limit replaces the MSE epoch instead of dropping fragments.
@@ -284,6 +285,7 @@ export function MseFmp4WebSocketPlayer({
let onBufferError: (() => 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" ? (
<div className="mse-fmp4-player__status" role="status" aria-live="polite">
<Icon name={status === "error" ? "alert" : "video"} size={20} />
<strong>{status === "error" ? "Канал прерван" : "Подготовка камеры"}</strong>
<strong>{status === "error" ? "Канал прерван" : status === "buffering" ? "Ожидание изображения" : "Подготовка камеры"}</strong>
<span>{message}</span>
{status === "error" ? (
<button
@@ -0,0 +1,21 @@
import { Button, LoadingRegion, Window } from "@nodedc/ui-react";
import type { RouteCheck } from "../../core/missions/planner";
export function MissionCheckWindow({ open, onClose, busy, error, report, run }: {
open: boolean; onClose: () => void; busy: boolean; error: string | null; report: RouteCheck | null; run: () => void;
}) {
return <Window open={open} title="Проверка маршрута" size="md" onClose={onClose}><div className="mission-planner__check">
<p>Проверка сохранённой записи и выбранного участка. Новый проход со сканером для этой проверки не требуется.</p>
<LoadingRegion loading={busy} label="Проверка исходных данных">
{error && <p role="alert">{error}</p>}
{report && <><dl>
<div><dt>Исходная запись</dt><dd>{report.source_verified ? "Контрольные суммы совпадают" : "Недоступна"}</dd></div>
<div><dt>Версия черновика</dt><dd>{report.revision}</dd></div>
<div><dt>Длина маршрута</dt><dd>{report.length_m.toFixed(2)} м</dd></div>
<div><dt>Положения сканера</dt><dd>{report.pose_count.toLocaleString("ru-RU")}</dd></div>
<div><dt>Максимальный шаг</dt><dd>{report.max_step_m.toFixed(2)} м</dd></div></dl>
{report.warnings.map(w => <p key={w}>{w}</p>)}
<p>Совмещение с повторным проходом не выполнялось. Записанная траектория описывает движение сканера; проходимость для аппарата по ней не проверена.</p></>}
<Button disabled={busy} onClick={run}>{report ? "Повторить проверку" : "Начать проверку"}</Button>
</LoadingRegion>
</div></Window>;
}
@@ -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 <div className="session-overview__empty">Выберите участок траектории.</div>;
const [x, y] = projection.project(point);
return <div className="mission-route-preview"><h2>Выбранный маршрут · вид сверху</h2>
<svg viewBox="0 0 800 480" role="img" aria-label="Траектория записи и выбранный маршрут">
<polyline points={projection.reference} className="mission-route-preview__reference" fill="none" />
<polyline points={projection.selected} className="mission-route-preview__selected" fill="none" />
<circle cx={x} cy={y} r="6" className="mission-route-preview__cursor" />
<path d="M40 450h100" className="mission-route-preview__selected" /><text x="40" y="438">{(100 / projection.scale).toFixed(1)} м</text>
</svg><p>Путь: {routeLength(poses).toFixed(2)} м · {poses.length.toLocaleString("ru-RU")} положений · X/Y, масштаб осей одинаковый</p>
<RangeControl label="Положение на маршруте" value={Math.min(cursor, poses.length - 1) + 1} min={1} max={poses.length} step={1} formatValue={n => `${n} / ${poses.length}`} onChange={n => setCursor(n - 1)} />
<p>Кадр {point.index + 1} · X {point.position[0].toFixed(2)} · Y {point.position[1].toFixed(2)} · Z {point.position[2].toFixed(2)} м</p>
</div>;
}
@@ -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 <LoadingRegion loading label="Подготовка облака зоны" className="mission-planner__zone-loading" />;
if (failure || !data?.scene_url) return <div className="session-overview__empty"><span>{failure || "Облако записи недоступно."}</span><Button onClick={retry}>Повторить</Button></div>;
return <SessionOverviewScene sourceUrl={data.scene_url} toolbar={toolbar} hideTitle />;
}
@@ -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=<><StatusBadge tone="neutral">Профиль · Планирование</StatusBadge>
<p>{t.draft.name} · эталон {t.draft.zone.label} · {t.draft.route.length_m.toFixed(1)} м</p>
<p>Укажите новое имя проекта сканера. Неподвижная калибровка просматривает весь выбранный маршрут; после калибровки сканера оставайтесь на месте до статуса «Сопровождение». Если совпадение не найдено или неоднозначно, сцена сообщит причину и не начнёт сопровождение.</p></>;
return <div className="mission-planner__check">
{details}<p>{t.message}</p>{p.error&&<p role="alert">{p.error}</p>}
<div className="mission-planner__actions"><Button onClick={()=>setOpen(true)}>Подключение сканера</Button>
{t.state==='running'&&t.planning_phase==='lost'&&!t.tracking_established&&<Button loading={p.busy} onClick={()=>void p.retryInitialization()}>Переинициализировать</Button>}
{!terminal&&<Button loading={p.busy} onClick={()=>void p.finish()}>Завершить исследование</Button>}</div>
<Window open={open} title="Подключение сканера · Планирование" size="lg" className="planning-connection" onClose={()=>setOpen(false)}>
<div className="mission-planner__check">{details}
{t.state==='preparing'?<LoadingRegion loading label="Подготовка эталонного участка" />
: terminal?<p>{t.message}</p>:children}
</div>
</Window>
</div>;
}
@@ -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<number|null>{
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<string,string|number|boolean>;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<number|null>(null);
const boundsRef=useRef<{min:number;max:number}|null>(null);
const host=useRef<HTMLDivElement>(null);
const [state,setState]=useState('loading'),[retry,setRetry]=useState(0);
const [bounds,setBounds]=useState<{min:number;max:number}|null>(null);
const [ceiling,setCeiling]=useState<number|null>(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.current<next.min||ceilingRef.current>next.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 <LoadingRegion loading={state==='loading'} label="Загрузка эталона и нового прохода" className="planning-live__scene">
<div ref={host} className="session-overview__runtime rerun-single-view-content" style={{visibility:state==='ready'?'visible':'hidden'}} />
{rangeBounds&&state==='ready'&&<div className="session-overview__height">
<RangeControl orientation="vertical" limitSide="left" label="Срез" value={ceiling??rangeBounds.max} min={rangeBounds.min} max={rangeBounds.max} step="any"
formatValue={value=>`${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);
}}/>
</div>}
{state==='error'&&<div className="session-overview__empty"><span>Обновление сцены недоступно.</span><Button onClick={()=>setRetry(v=>v+1)}>Повторить подключение сцены</Button></div>}
</LoadingRegion>;
}
@@ -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 <Inspector variant="panel" defaultOpen={['sources','result']} sections={[
{id:'sources',label:'Проект',icon:<Icon name="file"/>,content:<div className="inspector-control-stack">
<p>{p.name}</p><small>{new Date(p.created_at_utc).toLocaleString('ru-RU')}</small>
<dl><div><dt>Эталон</dt><dd>{p.reference_label}</dd></div><div><dt>Повторный проход</dt><dd>{p.query_label??'—'}</dd></div>
<div><dt>Участок эталона</dt><dd>{p.draft.route.length_m.toFixed(2)} м</dd></div></dl>
</div>},
{id:'result',label:'Результат',icon:<Icon name="activity"/>,content:<div className="inspector-control-stack">
<StatusBadge tone={r?.status==='rejected'?'warning':'neutral'}>{planningProjectStatus(p)}</StatusBadge>
{r?<><dl><div><dt>Точки в пределах 0,5 м</dt><dd>{(r.overlap*100).toFixed(1)}%</dd></div>
<div><dt>Расхождение поверхностей</dt><dd>{r.inlier_rmse_m==null?'—':`${r.inlier_rmse_m.toFixed(3)} м`}</dd></div>
{r.correction_m!=null&&<div><dt>Уточнение привязки</dt><dd>{r.correction_m.toFixed(2)} м · {r.correction_deg.toFixed(1)}°</dd></div>}
{r.registration_seconds!=null&&<div><dt>Расчёт</dt><dd>{r.registration_seconds.toFixed(2)} с</dd></div>}
</dl><small>Совпадение поверхностей не является измеренной точностью положения.</small></>:<p>{p.message?`Сохранённое сообщение: ${p.message}`:'Расчёт совмещения ещё не выполнен.'}</p>}
{p.evidence_relation==='same_recording'&&<small>Оба участка из одной записи: внутренняя проверка общей карты.</small>}
{p.scene_note&&p.scene_url&&<small>{p.scene_note}</small>}
{r?.reasons.map(reason=><small key={reason}>{reason}</small>)}
{!p.scene_url&&r&&<small>Сохранённое совмещённое облако недоступно.</small>}
</div>},
]}/>;
}
@@ -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<void>;
}) {
const [target, setTarget] = useState<PlanningProject | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
return <>
<Select label="Совмещённые маршруты" value={value} disabled={disabled || busy} searchable
options={[{value: '', label: 'Совмещённые маршруты'}, ...items.map(item => ({
value: item.key, label: item.name,
description: `${new Date(item.created_at_utc).toLocaleString('ru-RU')} · ${planningProjectStatus(item)}${item.query_label ? ` · ${item.query_label}` : ''}`,
action: {
label: `Удалить совмещённый маршрут ${item.name} · ${new Date(item.created_at_utc).toLocaleString('ru-RU')}`,
icon: <Icon name="trash" />,
tone: 'danger' as const,
disabled: !planningProjectDeletable(item),
onAction: () => { setError(null); setTarget(item); },
},
}))]}
onChange={key => { if (key) onChange(key); }} />
<ConfirmationModal open={target !== null} title="Удалить совмещённый маршрут?"
description={target ? <>
<strong>{target.name}</strong>
<p>{new Date(target.created_at_utc).toLocaleString('ru-RU')} · {planningProjectStatus(target)}</p>
<p>Проект будет удалён из списка совмещённых маршрутов. Эталон, запись прохода и сохранённые отчёты останутся на диске. Другие проекты не изменятся.</p>
</> : null}
confirmLabel="Удалить маршрут" pendingLabel="Удаление…" danger
onClose={() => { if (!busy) setTarget(null); }}
onConfirm={async () => {
if (!target || busy) return;
setBusy(true); setError(null);
try { await onRemove(target); setTarget(null); }
catch (e) { setError(e instanceof Error ? e.message : 'Не удалось удалить проект. Повторите попытку.'); }
finally { setBusy(false); }
}} />
<ToastStack items={error ? [{id: 'planning-project-delete-error', tone: 'error', title: 'Маршрут не удалён', description: error}] : []}
onDismiss={() => setError(null)} />
</>;
}
@@ -0,0 +1,53 @@
import { Button, Icon, Inspector, InspectorSelectField, LoadingRegion, RangeControl, SegmentedControl, TextField } from '@nodedc/ui-react';
import { endAtDistance, indexAtDistance, routeLength, canSelectSession, canStartPlanningRoute } from '../../core/missions/planner';
import type { useMissionPlanner } from '../../core/missions/useMissionPlanner';
import type { useRegistrationTest } from '../../core/missions/useRegistrationTest';
export function PlanningProjectSettings({p,t,mode,setMode,onStart,starting}:{
p:ReturnType<typeof useMissionPlanner>; t:ReturnType<typeof useRegistrationTest>;
mode:'scanner'|'recording';setMode:(mode:'scanner'|'recording')=>void;onStart:()=>void;starting:boolean;
}) {
const disabled=p.busy||starting;
const length=routeLength(p.poses);
return <Inspector variant="panel" defaultOpen={['project','zone','route','query']} sections={[
{id:'project',label:'Проект',icon:<Icon name="file"/>,content:<div className="inspector-control-stack">
<TextField label="Название проекта" placeholder="Название совмещения" value={p.name} maxLength={120} disabled={disabled} onChange={e=>p.setName(e.target.value)}/>
</div>},
{id:'zone',label:'Эталон',icon:<Icon name="globe"/>,content:<div className="inspector-control-stack">
<InspectorSelectField label="Сохранённая запись" searchable value={p.sessionId} options={p.options} disabled={disabled||p.catalogBusy} onChange={p.chooseSource}/>
{p.cursor&&<Button disabled={p.catalogBusy} onClick={()=>void p.loadMore()}>Ещё записи</Button>}
{p.source&&<small>{p.source.poses.length.toLocaleString('ru-RU')} положений · {p.source.path_m.toFixed(2)} м</small>}
</div>},
{id:'route',label:'Участок эталона',icon:<Icon name="plan"/>,content:<div className="inspector-control-stack">
{p.source&&!p.sourceChanged?<>
<RangeControl label="Начало участка" value={p.source.poses[p.start]?.distance_m??0} min={0} max={p.source.poses[p.source.poses.length-2].distance_m} step="any" disabled={disabled}
formatValue={n=>`${n.toFixed(1)} м`} onChange={distance=>{const n=indexAtDistance(p.source!,distance,0,p.source!.poses.length-2);p.setStart(n);if(n>=p.end)p.setEnd(n+1);}}/>
<RangeControl label="Конец участка" value={p.source.poses[p.end]?.distance_m??0} min={p.source.poses[p.start+1]?.distance_m??0} max={p.source.path_m} step="any" disabled={disabled}
formatValue={n=>`${n.toFixed(1)} м`} onChange={distance=>p.setEnd(indexAtDistance(p.source!,distance,p.start+1,p.source!.poses.length-1,true))}/>
<Button disabled={disabled} onClick={()=>p.setEnd(endAtDistance(p.source!,p.start,30))}>30 м от начала участка</Button>
<Button disabled={disabled} onClick={()=>{p.setStart(0);p.setEnd(p.source!.poses.length-1);}}>Вся траектория</Button>
<InspectorSelectField label="Направление" value={p.direction} onChange={p.setDirection} disabled={disabled} options={[{value:'forward',label:'По записи'},{value:'reverse',label:'В обратную сторону'}]}/>
<small>{length.toFixed(2)} м · {p.poses.length.toLocaleString('ru-RU')} положений</small>
</>:<p>Выберите сохранённую запись эталона.</p>}
</div>},
{id:'query',label:'Повторный проход',icon:<Icon name="activity"/>,content:<div className="inspector-control-stack">
<SegmentedControl label="Источник повторного прохода" value={mode} onChange={setMode} items={[{value:'scanner',label:'Новый проход',disabled},{value:'recording',label:'Из записи',disabled}]}/>
{mode==='scanner'?<p>После запуска откроется подключение сканера. Новый проход записывается отдельным проектом.</p>:<>
<InspectorSelectField label="Повторная запись" value={t.sessionId} disabled={disabled} searchable onChange={t.setSessionId}
options={[{value:'',label:'Выберите повторный проход'},...p.sessions.filter(canSelectSession).map(s=>({value:s.id,label:s.label,description:s.id===p.sessionId?'Та же запись · внутренняя проверка':'Сохранённые облако и траектория'}))]}/>
<LoadingRegion loading={t.loading} label="Подготовка повторного прохода">
{t.source&&<div className="inspector-control-stack">
<RangeControl label="Начало повторного участка" min={0} max={t.source.poses[t.source.poses.length-2].distance_m} step="any" value={t.source.poses[t.start].distance_m} disabled={disabled} formatValue={n=>`${n.toFixed(1)} м`}
onChange={n=>{const start=indexAtDistance(t.source!,n,0,t.source!.poses.length-2);t.setStart(start);t.setEnd(endAtDistance(t.source!,start,20));}}/>
<RangeControl label="Конец повторного участка" min={t.source.poses[t.start+1].distance_m} max={t.source.path_m} step="any" value={t.source.poses[t.end].distance_m} disabled={disabled} formatValue={n=>`${n.toFixed(1)} м`} onChange={n=>t.setEnd(indexAtDistance(t.source!,n,t.start+1))}/>
</div>}
</LoadingRegion>
</>}
<small>{mode==='scanner'
?'Длина выбранного участка задаёт предел прохода. Ограничения по времени нет; запись сканера не останавливается автоматически. Неподвижная калибровка просматривает весь выбранный маршрут. После калибровки оставайтесь на месте до статуса «Сопровождение»; при отсутствии или неоднозначности совпадения сцена прямо сообщит причину и не начнёт сопровождение.'
:'Участки от 3 до 40 м. Начальная привязка — выбранное место старта и одинаковое направление.'}</small>
<Button variant="primary" loading={starting} disabled={disabled||!p.ready||!canStartPlanningRoute(length,mode)||(mode==='recording'&&(!t.source||t.loading||!canStartPlanningRoute(t.length,'recording')))} onClick={onStart}><Icon name="play"/>{mode==='scanner'?'Начать новый проход':'Запустить совмещение'}</Button>
{(p.error||t.error)&&<p role="alert">{p.error||t.error}</p>}
</div>},
]}/>;
}
@@ -0,0 +1,19 @@
import {useState,type ReactNode,type RefObject} from 'react';
import {WorkspaceWindow,type WorkspaceWindowRect} from '@nodedc/ui-react';
/** A scene-bounded tool: never a portal, backdrop or renderer owner. */
export function PlanningSceneToolWindow({boundsRef,title,children,onClose}:{
boundsRef:RefObject<HTMLDivElement|null>;title:string;children:ReactNode;onClose:()=>void;
}){
const [rect,setRect]=useState<WorkspaceWindowRect>({x:16,y:120,width:390,height:260});
const [maximized,setMaximized]=useState(false);
return <WorkspaceWindow boundsRef={boundsRef} rect={rect} onRectChange={setRect}
title={title} maximized={maximized} onMaximizedChange={setMaximized}
minWidth={280} minHeight={200} active zIndex={100} onClose={onClose}
closeLabel="Закрыть инструмент" moveLabel="Переместить инструмент"
resizeLabel="Изменить размер инструмента" maximizeLabel="Развернуть инструмент" restoreLabel="Восстановить инструмент" onKeyDown={event=>{
if(event.key==='Escape'&&!event.defaultPrevented){event.preventDefault();event.stopPropagation();onClose();}
}}>
<div className="mission-planner__check">{children}</div>
</WorkspaceWindow>;
}
@@ -0,0 +1,30 @@
import { useEffect, useRef, useState } from "react";
import { Button, LoadingRegion } from "@nodedc/ui-react";
import { createIsolatedRerunHost } from "../rerun/isolatedRerunHost";
/** One isolated, read-only Rerun realm; disposal releases its WASM memory. */
export function RegistrationScene({ sourceUrl }: { sourceUrl: string }) {
const host = useRef<HTMLDivElement>(null);
const [state, setState] = useState("loading"), [retry, setRetry] = useState(0);
useEffect(() => {
if (!host.current) return;
let disposed = false; setState("loading");
const runtime = createIsolatedRerunHost(host.current);
const timer = window.setTimeout(() => { if (!disposed) { setState("error"); runtime.dispose(); } }, 60_000);
void runtime.ready.then(async ({ viewer, mount }) => {
if (disposed) return;
viewer.on("recording_open", () => { if (!disposed) { clearTimeout(timer); setState("ready"); } });
await viewer.start(new URL(sourceUrl, window.location.origin).href, mount, {
width: "100%", height: "100%", hide_welcome_screen: true, enable_history: false, allow_fullscreen: false,
});
if (disposed) return;
if (viewer.get_active_recording_id()) { clearTimeout(timer); setState("ready"); }
for (const panel of ["top", "blueprint", "selection", "time"] as const) viewer.override_panel_state(panel, "hidden");
}).catch(() => { if (!disposed) { clearTimeout(timer); setState("error"); runtime.dispose(); } });
return () => { disposed = true; clearTimeout(timer); runtime.dispose(); };
}, [sourceUrl, retry]);
return <LoadingRegion loading={state === "loading"} label="Загрузка совмещённых облаков" className="mission-registration__scene">
<div ref={host} className="session-overview__runtime rerun-single-view-content" style={{ visibility: state === "ready" ? "visible" : "hidden" }} />
{state === "error" && <div className="session-overview__empty"><span>Облако недоступно.</span><Button onClick={() => setRetry(n => n+1)}>Повторить</Button></div>}
</LoadingRegion>;
}
@@ -0,0 +1,34 @@
import { useEffect, useRef, useState } from "react";
import { overviewChartPoints } from "../../core/observation/sessionOverview";
export function SessionIntervalChart({ chart, bucketSeconds }: { chart: [number, number][]; bucketSeconds: number }) {
const svg = useRef<SVGSVGElement>(null);
const [size, setSize] = useState({ width: 900, height: 160 });
useEffect(() => {
if (!svg.current) return;
const observer = new ResizeObserver(([entry]) => {
if (entry.contentRect.width > 0 && entry.contentRect.height > 0) {
setSize({ width: entry.contentRect.width, height: entry.contentRect.height });
}
});
observer.observe(svg.current);
return () => observer.disconnect();
}, [chart.length > 0]);
const width = Math.max(1, size.width - 106);
const height = Math.max(1, size.height - 38);
const { points, xmax, ymax } = overviewChartPoints(chart, width, height);
if (!chart.length) return <div className="session-overview__empty">Временные метки кадров отсутствуют.</div>;
return <div className="session-overview__chart">
<svg ref={svg} viewBox={`0 0 ${size.width} ${size.height}`} role="img" aria-label="Интервалы поступления кадров облака">
<g transform="translate(64,10)">
{(height >= 70 ? [0, .5, 1] : height >= 32 ? [0, 1] : [1]).map(f => <g key={f}>
<line x1="0" x2={width} y1={height * (1 - f)} y2={height * (1 - f)} className="session-overview__grid" />
<text x="-12" y={height * (1 - f) + 4} textAnchor="end">{(f * ymax).toFixed(2)} с</text>
</g>)}
<polyline points={points} fill="none" className="session-overview__series" vectorEffect="non-scaling-stroke" />
{[0, .25, .5, .75, 1].map(f => <text key={f} x={f * width} y={height + 24} textAnchor="middle">{(f * xmax / 60).toFixed(1)} мин</text>)}
</g>
</svg>
<span className="session-overview__note">Максимальный интервал за каждые {bucketSeconds} с · время от первого кадра</span>
</div>;
}
@@ -0,0 +1,80 @@
import { type ReactNode, useEffect, useRef, useState } from "react";
import { Button, LoadingRegion, RangeControl, SegmentedControl } from "@nodedc/ui-react";
import { createIsolatedRerunHost } from "../rerun/isolatedRerunHost";
import type { RecordedRerunViewer } from "../rerun/recordedRerunFacade";
import { fetchOverviewSpatial, updateOverviewSpatial, type OverviewSpatialMetadata, type OverviewViewMode } from "../../core/observation/sessionOverviewSpatial";
/** A separate, bounded static recording; teardown releases the whole WASM realm. */
export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false }: { sourceUrl: string; toolbar?:ReactNode; hideTitle?:boolean }) {
const host = useRef<HTMLDivElement>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [retry, setRetry] = useState(0);
const [metadata, setMetadata] = useState<OverviewSpatialMetadata | null>(null);
const [ceiling, setCeiling] = useState<number | null>(null);
const [mode, setMode] = useState<OverviewViewMode>("3d");
const [viewError, setViewError] = useState<string | null>(null);
const [visiblePoints, setVisiblePoints] = useState<number | null>(null);
const appliedMode = useRef<OverviewViewMode | null>(null);
const controller = useRef<{ viewer: RecordedRerunViewer; channel: ReturnType<RecordedRerunViewer["open_channel"]> } | null>(null);
useEffect(() => {
if (!host.current) return;
let disposed = false;
const abort = new AbortController();
appliedMode.current = null;
setState("loading");
setMetadata(null); setCeiling(null); setMode("3d"); setVisiblePoints(null); setViewError(null);
void fetchOverviewSpatial(sourceUrl, abort.signal).then(setMetadata).catch(() => { if (!disposed) setViewError("Параметры среза недоступны."); });
const runtime = createIsolatedRerunHost(host.current);
const timer = window.setTimeout(() => { if (!disposed) { setState("error"); runtime.dispose(); } }, 60_000);
void runtime.ready.then(async ({ viewer, mount }) => {
if (disposed) return;
viewer.on("recording_open", () => { if (!disposed) { clearTimeout(timer); setState("ready"); } });
await viewer.start(new URL(sourceUrl, window.location.origin).href, mount, {
width: "100%", height: "100%", hide_welcome_screen: true,
enable_history: false, allow_fullscreen: false,
});
if (disposed) return;
controller.current = { viewer, channel: viewer.open_channel("session-overview-controls") };
if (viewer.get_active_recording_id()) { clearTimeout(timer); setState("ready"); }
for (const panel of ["top", "blueprint", "selection", "time"] as const) viewer.override_panel_state(panel, "hidden");
}).catch(() => { if (!disposed) { clearTimeout(timer); setState("error"); runtime.dispose(); } });
return () => { disposed = true; abort.abort(); controller.current = null; clearTimeout(timer); runtime.dispose(); };
}, [sourceUrl, retry]);
useEffect(() => {
if (state !== "ready" || !metadata || !controller.current) return;
const abort = new AbortController();
const timer = setTimeout(() => {
const aspect = Math.max(.1, Math.min(20, (host.current?.clientWidth ?? 1) / Math.max(1, (host.current?.clientHeight ?? 1) - 28)));
void updateOverviewSpatial(sourceUrl, ceiling, appliedMode.current === mode ? null : mode, aspect, abort.signal).then(result => {
if (abort.signal.aborted || !controller.current) return;
controller.current.channel.send_rrd(result.bytes);
if (result.eye) controller.current.viewer.configure_camera_journal(result.eye, 0);
appliedMode.current = mode;
setVisiblePoints(result.visiblePoints); setViewError(null);
}).catch(() => { if (!abort.signal.aborted) setViewError("Не удалось обновить вид облака."); });
}, 180);
return () => { abort.abort(); clearTimeout(timer); };
}, [state, metadata, sourceUrl, ceiling, mode, retry]);
const low = metadata?.height_min_m;
const high = metadata?.height_max_m;
return <>
<div className={`session-overview__scene-head ${hideTitle?"session-overview__scene-head--end":""}`}>{!hideTitle&&<h2>Облако и траектория</h2>}
<SegmentedControl label="Вид облака" value={mode} onChange={setMode}
items={[{ value: "top", label: "Сверху", disabled: state !== "ready" }, { value: "3d", label: "3D", disabled: state !== "ready" }]} />
{toolbar}
</div>
<LoadingRegion loading={state === "loading"} label="Загрузка облака" className="session-overview__scene">
<div ref={host} className={`session-overview__runtime ${hideTitle?"rerun-single-view-content":""}`} style={{ visibility: state === "ready" ? "visible" : "hidden" }} />
{low != null && high != null && high > low && state === "ready" && <div className="session-overview__height">
<RangeControl orientation="vertical" limitSide="left" label="Срез" value={ceiling ?? high} min={low} max={high} step="any"
formatValue={value => `${value.toFixed(1).replace('.', ',')} м`} formatLimit={value => value.toFixed(1).replace('.', ',')}
onChange={value => setCeiling(value >= high - Math.max(1, high - low) * 1e-9 ? null : value)} />
</div>}
{state === "error" && <div className="session-overview__empty"><span>Не удалось открыть облако.</span><Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>}
</LoadingRegion>
{viewError ? <div className="session-overview__note" role="alert">{viewError}<Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>
: <span className="session-overview__note">{ceiling == null ? "Без среза" : `Высота ≤ ${ceiling.toFixed(1)} м`} · {visiblePoints?.toLocaleString("ru-RU") ?? "—"} точек</span>}
</>;
}
@@ -12,6 +12,7 @@ interface ApplicationPanelActionsOptions {
saveWorkspaceLayout: () => Promise<void>;
workspaceLayoutSaving: boolean;
systemUtilityActions: readonly ApplicationPanelUtilityAction[];
sessionOverview?: { open: boolean; toggle: () => void; available: boolean };
}
export function deviceRuntimeUtilityAction({
@@ -51,6 +52,7 @@ export function useApplicationPanelActions({
saveWorkspaceLayout,
workspaceLayoutSaving,
systemUtilityActions,
sessionOverview,
}: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] {
return useMemo(() => {
const actions: ApplicationPanelUtilityAction[] = [];
@@ -71,6 +73,11 @@ export function useApplicationPanelActions({
});
}
if (definition?.root === "system") actions.push(...systemUtilityActions);
if (definition?.kind === "recordings" && sessionOverview) actions.push({
label: sessionOverview.open ? "Закрыть информацию о записи" : "Информация о записи",
icon: "info", pressed: sessionOverview.open, disabled: !sessionOverview.available,
onClick: sessionOverview.toggle,
});
return actions;
}, [
definition,
@@ -81,5 +88,6 @@ export function useApplicationPanelActions({
saveWorkspaceLayout,
systemUtilityActions,
workspaceLayoutSaving,
sessionOverview,
]);
}
@@ -97,9 +97,18 @@ export interface DevicePluginHostActions {
activateAutomaticSpatialSource: () => void;
}
/** Session-bound, presentation-only extension of an active spatial workflow. */
export interface SpatialActivityPresentation {
sessionId: string;
label: string;
detail: string;
busy: boolean;
}
export interface DevicePluginConnectionProps {
model: DeviceModelDefinition;
host: DevicePluginHostActions;
spatialActivity?: SpatialActivityPresentation;
}
export interface DeviceUiPlugin {
@@ -40,7 +40,7 @@ const defaultQuickActions: Record<
home: ["spatial-scene", "vehicles"],
fleet: ["vehicles", "contour-health"],
observation: ["cameras", "world-map"],
missions: ["mission-planner", null],
missions: ["routes", null],
data: ["recordings", "datasets"],
system: ["modules", "integrations"],
polygon: ["lab-archive", "local-device"],
@@ -0,0 +1,71 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
import { plannerBase, plannerRequest, type Draft } from './planner';
export interface PlanningLiveTest {
schema_version: 'missioncore.planning-live-test/v1'; id: string; profile: 'planning'; plugin_id:string; draft: Draft;
state: 'preparing'|'waiting'|'running'|'completed'|'cancelled'|'error'|'interrupted';
planning_phase?: 'preparing'|'waiting-cloud'|'collecting'|'searching'|'refreshing'|'validating'|'tracking'|'lost'|'ended';
tracking_state?: 'acquiring'|'tracking'|'lost';
tracking_established?: boolean;
presentation_state?: 'live'|'historical'|'unlocalized'; scene_revision?:number;
scene_height_min_m?:number|null; scene_height_max_m?:number|null;
message: string; query_session_id: string|null; distance_m: number; query_points?: number;
scene_available: boolean; stale: boolean; frame_age_s: number|null; result_age_s: number|null;
result: {status:'candidate'|'rejected'; overlap:number; inlier_rmse_m:number|null; reasons:string[]}|null;
route_relocalization_policy?: {version?:string;query_radius_m?:number};
}
export interface PlanningRunSummary {id:string;name:string;state:string;query_session_id:string|null;created_at_utc:string}
const endpoint = plannerBase+'/live-tests';
const terminal = new Set(['completed','cancelled','error','interrupted']);
const Context = createContext<{
test: PlanningLiveTest|null; history:PlanningRunSummary[]; select:(id:string)=>Promise<void>; selected: boolean; busy:boolean; error:string|null;
begin:(draft:Draft)=>Promise<PlanningLiveTest|null>; finish:()=>Promise<void>; retryInitialization:()=>Promise<void>; resume:()=>void;
}|null>(null);
/** Server owns the run; navigation and reload retain its frozen reference. */
export function PlanningTestProvider({children}:{children:ReactNode}) {
const [test,setTest]=useState<PlanningLiveTest|null>(null);
const [dismissed,setDismissed]=useState<string|null>(null);
const [busy,setBusy]=useState(false),[error,setError]=useState<string|null>(null);
const [pollError,setPollError]=useState<string|null>(null);
const [history,setHistory]=useState<PlanningRunSummary[]>([]);
const generation=useRef(0);
useEffect(()=>{let disposed=false;void plannerRequest<{items:PlanningRunSummary[]}>(endpoint).then(r=>{if(!disposed)setHistory(r.items);}).catch(()=>{});return()=>{disposed=true;};},[test?.id,test?.state]);
const select=useCallback(async(id:string)=>{
generation.current+=1;setBusy(true);setError(null);
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+id+'/select',{method:'POST'}));setDismissed(null);}
catch(e){setError(e instanceof Error?e.message:'Не удалось открыть исследование.');}
finally{generation.current+=1;setBusy(false);}
},[]);
useEffect(()=>{
let disposed=false, timer:ReturnType<typeof setTimeout>;
const poll=async()=>{
const expected=generation.current;
try { const next=await plannerRequest<PlanningLiveTest|null>(endpoint+'/active'); if(!disposed && expected===generation.current) {setTest(next);setPollError(null);} }
catch(e) {if(!disposed && expected===generation.current)setPollError(e instanceof Error?e.message:'Исследование недоступно.');}
if(!disposed)timer=setTimeout(()=>void poll(),1500);
};void poll();return()=>{disposed=true;clearTimeout(timer);};
},[]);
const begin=useCallback(async(draft:Draft)=>{
generation.current+=1;setBusy(true);setError(null);
try {const next=await plannerRequest<PlanningLiveTest>(endpoint,{method:'POST',body:JSON.stringify({draft_id:draft.id,revision:draft.revision})});setTest(next);setDismissed(null);sessionStorage.removeItem('planning-dismissed');return next;}
catch(e){setError(e instanceof Error?e.message:'Не удалось подготовить тест.');return null;}
finally{generation.current+=1;setBusy(false);}
},[]);
const finish=useCallback(async()=>{
if(!test)return;generation.current+=1;setBusy(true);
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+test.id+'/stop',{method:'POST'}));}
catch(e){setError(e instanceof Error?e.message:'Не удалось завершить тест.');}
finally{generation.current+=1;setBusy(false);}
},[test]);
const retryInitialization=useCallback(async()=>{
if(!test)return;generation.current+=1;setBusy(true);setError(null);
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+test.id+'/reinitialize',{method:'POST'}));}
catch(e){setError(e instanceof Error?e.message:'Не удалось переинициализировать привязку.');}
finally{generation.current+=1;setBusy(false);}
},[test]);
const resume=useCallback(()=>{setDismissed(null);sessionStorage.removeItem('planning-dismissed');},[]);
return <Context.Provider value={{test,history,select,selected:!!test&&dismissed!==test.id,busy,error:error??pollError,begin,finish,retryInitialization,resume}}>{children}</Context.Provider>;
}
export function usePlanningTest(){const value=useContext(Context);if(!value)throw new Error('PlanningTestProvider missing');return value;}
export function planningTestTerminal(test:PlanningLiveTest){return terminal.has(test.state);}
@@ -0,0 +1,44 @@
export interface PlanningPose { index: number; message_index: number; position: [number, number, number]; elapsed_s: number | null; distance_m: number }
export interface PlanningSource { schema_version: "missioncore.planning-source/v1"; session_id: string; generation: string; label: string; frame_id: string; units: "m"; poses: PlanningPose[]; path_m: number; decode_errors: number }
export interface SessionOption { id: string; label: string; modalities: string[]; replayable: boolean; lab?: unknown; }
export type Direction = "forward" | "reverse";
export interface Draft {
id: string; revision: number; name: string; updated_at_utc: string; vehicle_id: null;
zone: { session_id: string; generation: string; label: string };
route: { start_index: number; end_index: number; direction: Direction; length_m: number; points: { source_index: number; position: [number, number, number] }[] };
}
export interface RouteCheck { id: string; revision: number; length_m: number; pose_count: number; max_step_m: number; source_verified: boolean; warnings: string[]; localization: "not_run"; vehicle_control: false; }
export const plannerBase = "/api/v1/mission-planner";
export function canStartPlanningRoute(length: number, mode: 'scanner' | 'recording') {
return Number.isFinite(length) && length >= 3 && (mode === 'scanner' || length <= 40);
}
export async function plannerRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(path, { ...init, cache: "no-store", headers: { "Content-Type": "application/json", ...init.headers } });
if (!response.ok) { const doc = await response.json().catch(() => null); throw new Error(typeof doc?.detail === "string" ? doc.detail : "Данные планировщика недоступны."); }
return response.json() as Promise<T>;
}
export function canSelectSession(item: SessionOption) { return item.replayable && !item.lab && item.modalities.includes("point-cloud") && item.modalities.includes("trajectory"); }
export function selectedPoses(source: PlanningSource | null, start: number, end: number, direction: Direction) {
if (!source || start < 0 || end >= source.poses.length || start >= end) return [];
const poses = source.poses.slice(start, end + 1);
return direction === "reverse" ? poses.reverse() : poses;
}
export function routeLength(poses: PlanningPose[]) { return poses.slice(1).reduce((sum, p, index) => sum + Math.hypot(...p.position.map((v, axis) => v - poses[index].position[axis])), 0); }
export function endAtDistance(source: PlanningSource, start: number, length: number) {
const target = source.poses[start].distance_m + length;
const index = source.poses.findIndex((pose, index) => index > start && pose.distance_m >= target);
return index < 0 ? source.poses.length - 1 : index;
}
export function validatePlanningSource(data: PlanningSource, sessionId: string): PlanningSource {
if (data.schema_version !== "missioncore.planning-source/v1" || data.session_id !== sessionId || data.units !== "m"
|| !/^[a-f0-9]{64}$/.test(data.generation) || !Array.isArray(data.poses) || data.poses.length < 2 || data.poses.length > 100_000
|| !data.poses.every((p, i) => p.index === i && p.position.length === 3 && p.position.every(Number.isFinite) && Number.isFinite(p.distance_m))) throw new Error("Траектория записи некорректна.");
return data;
}
/** Distance along the recorded path, so overlapping outbound/return branches remain distinct. */
export function indexAtDistance(source: PlanningSource, distance: number, min = 0, max = source.poses.length - 1, before = false) {
let low = min, high = max;
while (low < high) { const mid = Math.floor((low + high) / 2); if (source.poses[mid].distance_m < distance) low = mid + 1; else high = mid; }
return before && low > min && source.poses[low].distance_m > distance ? low - 1 : low;
}
@@ -0,0 +1,51 @@
import type {SpatialActivityPresentation} from '../device-plugins/contracts';
export interface PlanningMatchState {
state:string;stale:boolean;frame_age_s:number|null;result_age_s:number|null;
result:{status:string}|null;
tracking_state?:string;planning_phase?:string;tracking_established?:boolean;
presentation_state?:'live'|'historical'|'unlocalized';
}
/** Current geometry is insufficient: the temporal gate must also be tracking. */
export function planningMatchCurrent(t:PlanningMatchState,transportError=false){
return !transportError&&t.state==='running'&&t.tracking_state==='tracking'&&!t.stale
&&(t.presentation_state===undefined||t.presentation_state==='live')
&&t.frame_age_s!==null&&t.frame_age_s>=0&&t.frame_age_s<2
&&t.result_age_s!==null&&t.result_age_s>=0&&t.result_age_s<8
&&t.result?.status==='candidate';
}
type Status={label:string;tone:'neutral'|'success'|'warning'|'danger';message?:string;pulse?:boolean};
type PlanningPresentation=PlanningMatchState & {message:string};
const phaseLabels:Record<string,string>={
collecting:'Накопление данных',searching:'Поиск положения на маршруте',
refreshing:'Подтверждение привязки',validating:'Подтверждение привязки',
};
/** Terminal failures and data freshness take precedence over successful old fits. */
export function planningStatus(t:PlanningPresentation,error:string|null=null):Status{
if(error)return {label:'Исследование недоступно',tone:'danger',message:error};
if(t.state==='error'||t.state==='interrupted')return {label:'Совмещение остановлено',tone:'danger',message:t.message,pulse:true};
if(t.state==='completed'||t.state==='cancelled')return {label:'Исследование завершено',tone:'neutral',message:t.message+(t.presentation_state==='historical'?' Показана последняя принятая привязка; это не текущее положение.':'')};
if(planningMatchCurrent(t))return {label:'Сопровождение',tone:'success',message:t.message};
if(t.planning_phase==='recovering')return {label:'Восстановление привязки',tone:'danger',pulse:true,message:'Остановитесь. Ищем положение по новым данным; запись продолжается.'};
if(t.planning_phase==='lost'&&!t.tracking_established)
return {label:'Маршрут не синхронизирован',tone:'warning',message:t.message};
if(t.planning_phase==='lost'||t.planning_phase==='tracking')
return {label:'Привязка потеряна',tone:'danger',pulse:true,message:'Нет актуального подтверждения привязки. Остановитесь; ожидается восстановление по новым данным.'+(t.presentation_state==='historical'?' Сцена сохранена в последней принятой привязке.':'')};
if(t.planning_phase&&phaseLabels[t.planning_phase])
return {label:phaseLabels[t.planning_phase],tone:'neutral'};
if(t.result?.status==='rejected')return {label:'Совпадение не подтверждено',tone:'warning',message:t.message};
return {label:t.state==='preparing'?'Подготовка эталона':t.stale?'Ожидание данных':'Приём нового прохода',tone:'neutral',message:t.message};
}
/** A plugin may show this only for its matching, authoritative acquiring session. */
export function planningActivity(
t:PlanningPresentation & {query_session_id:string|null},error:string|null=null,
):SpatialActivityPresentation|undefined{
if(!t.query_session_id||!['running','error'].includes(t.state)
||!t.planning_phase||['preparing','waiting-cloud','ended'].includes(t.planning_phase))return undefined;
const status=planningStatus(t,error);
return {sessionId:t.query_session_id,label:status.label,detail:status.message??'',
busy:!error&&t.state==='running'&&Object.hasOwn(phaseLabels,t.planning_phase)};
}
@@ -0,0 +1,93 @@
import type { PlanningSceneDelivery } from './planningSceneStream';
export const PLANNING_BROWSER_PRESENTATION_SCHEMA =
'missioncore.planning-browser-presentation/v1';
type BrowserPresentationSample = {
cloud_revision:number;
cloud_sequence:number;
display_epoch:string;
request_ms:number;
rerun_admission_ms:number;
first_animation_frame_ms:number|null;
second_animation_frame_ms:number|null;
frame_timeout:boolean;
source_to_second_animation_frame_upper_bound_ms:number|null;
};
type ReporterDependencies = {
url:string;
fetcher?:typeof fetch;
schedule?:(callback:()=>void,ms:number)=>ReturnType<typeof setTimeout>;
cancel?:(timer:ReturnType<typeof setTimeout>)=>void;
};
const batchSize=8;
const flushDelayMs=250;
/**
* Bounded, best-effort browser observation. These samples never drive scene
* admission, registration, navigation, or device control.
*/
export function createPlanningPresentationReporter(deps:ReporterDependencies){
const fetcher=deps.fetcher??fetch;
const schedule=deps.schedule??setTimeout;
const cancel=deps.cancel??clearTimeout;
let pending:BrowserPresentationSample[]=[];
let timer:ReturnType<typeof setTimeout>|undefined;
let sending=false,stopped=false;
const flush=async()=>{
if(sending||stopped||!pending.length)return;
if(timer!==undefined){cancel(timer);timer=undefined;}
sending=true;
const samples=pending.splice(0,batchSize);
try{
// Deliberately best-effort: the presentation observer must not make the
// live Rerun channel wait for an unrelated report request.
const response=await fetcher(deps.url,{method:'POST',cache:'no-store',keepalive:true,
headers:{'Content-Type':'application/json'},
body:JSON.stringify({schema_version:PLANNING_BROWSER_PRESENTATION_SCHEMA,samples}),
});
if(!response.ok)throw new Error(`Planning presentation telemetry was not accepted (${response.status})`);
}catch{
// A lost browser report is observable as fewer samples, never retried into
// an unbounded queue and never promoted into a capture/fit failure.
}finally{
sending=false;
if(!stopped&&pending.length)void flush();
}
};
const record=(delivery:PlanningSceneDelivery,timing:{
rerunAdmissionMs:number;firstAnimationFrameMs:number|null;
secondAnimationFrameMs:number|null;
})=>{
if(stopped||delivery.presentation!=='live'||delivery.cloudRevision===null||
delivery.cloudSequence===null||delivery.displayEpoch===null)return;
const second=timing.secondAnimationFrameMs;
pending.push({
cloud_revision:delivery.cloudRevision,
cloud_sequence:delivery.cloudSequence,
display_epoch:delivery.displayEpoch,
request_ms:delivery.requestMs,
rerun_admission_ms:timing.rerunAdmissionMs,
first_animation_frame_ms:timing.firstAnimationFrameMs,
second_animation_frame_ms:second,
frame_timeout:second===null,
source_to_second_animation_frame_upper_bound_ms:second===null?null:
delivery.cloudAgeMs+delivery.requestMs+timing.rerunAdmissionMs+second,
});
if(pending.length>=batchSize)void flush();
else if(timer===undefined)timer=schedule(()=>{timer=undefined;void flush();},flushDelayMs);
};
return {
record,
dispose:()=>{
stopped=true;
if(timer!==undefined)cancel(timer);
pending=[];
},
};
}
@@ -0,0 +1,37 @@
import type { Draft } from './planner';
import type { RegistrationReport } from './useRegistrationTest';
import { plannerBase, plannerRequest } from './planner';
export interface PlanningProject {
key: string; kind: 'recorded'|'live'|'draft'; id: string; name: string;
state: string; created_at_utc: string; result_status: string|null;
reference_label: string; query_label: string|null; draft_id: string; revision: number;
}
export interface PlanningProjectDetail extends PlanningProject {
draft: Draft; result: RegistrationReport['result'] | null; message?: string;
scene_url: string|null; evidence_relation?: string; elapsed_seconds?: number;
scene_note?:string|null;
}
export const planningProjectPending = (p: PlanningProject) => ['queued','running','preparing','waiting'].includes(p.state);
export const planningProjectDeletable = (p: PlanningProject) => p.kind === 'draft'
|| (p.kind === 'recorded' ? ['ready', 'error'] : ['completed', 'cancelled', 'error', 'interrupted']).includes(p.state);
export async function deletePlanningProject(project: PlanningProject) {
if (!planningProjectDeletable(project)) throw new Error('Сначала завершите исследование.');
const receipt = await plannerRequest<{key: string; deleted: boolean}>(
`${plannerBase}/projects/${project.kind}/${encodeURIComponent(project.id)}`,
{method: 'DELETE', body: JSON.stringify({revision: project.revision})},
);
if (receipt.key !== project.key || receipt.deleted !== true) throw new Error('Удаление проекта не подтверждено. Обновите список.');
}
export function planningProjectStatus(p: PlanningProject) {
if (p.kind === 'draft') return 'Подготовка';
if (planningProjectPending(p)) return 'Выполняется';
if (p.result_status === 'candidate') return 'Кандидат совмещения';
if (p.result_status === 'rejected') return 'Совмещение отклонено';
return 'Без результата совмещения';
}
export function defaultPlanningProject(items: PlanningProject[], last: string|null) {
return items.find(p => p.key === last)?.key
?? items.find(p => p.kind === 'recorded' && p.state === 'ready')?.key
?? items[0]?.key ?? '';
}
@@ -0,0 +1,87 @@
/** One in-flight request, latest-only deltas, no browser backlog or authority. */
type Snapshot = {options:Record<string,string|number|boolean>;active:boolean;revision:number};
export type PlanningSceneDelivery = {
presentation:'live'|'historical'; cloudAgeMs:number; fitAgeMs:number;
requestMs:number; cloudRevision:number|null; cloudSequence:number|null;
displayEpoch:string|null; heightMinM:number|null; heightMaxM:number|null;
};
type Dependencies = {
url:string; snapshot:()=>Snapshot;
apply:(bytes:Uint8Array,delivery:PlanningSceneDelivery)=>void|Promise<void>; error:()=>void;
fetcher?:typeof fetch; now?:()=>number;
schedule?:(callback:()=>void,ms:number)=>ReturnType<typeof setTimeout>;
cancel?:(timer:ReturnType<typeof setTimeout>)=>void;
};
export function startPlanningSceneStream(deps:Dependencies){
const fetcher=deps.fetcher??fetch, now=deps.now??(()=>performance.now());
const schedule=deps.schedule??setTimeout, cancel=deps.cancel??clearTimeout;
let stopped=false,cursor='',key='',revision=-1,needsBase=true;
let timer:ReturnType<typeof setTimeout>|undefined;
let request:AbortController|undefined;
const poll=async()=>{
if(stopped)return;
const started=now(), snapshot=deps.snapshot(), nextKey=JSON.stringify(snapshot.options);
if(!needsBase&&cursor&&!snapshot.active&&key===nextKey&&revision===snapshot.revision){
timer=schedule(()=>void poll(),500);return;
}
request=new AbortController();
const deadline=schedule(()=>request?.abort(),2500);
let failed=false;
try{
const params=new URLSearchParams(Object.entries(snapshot.options).map(([name,value])=>[name,String(value)]));
params.set('base',String(needsBase||!cursor||key!==nextKey));
if(cursor)params.set('cursor',cursor);
const response=await fetcher(`${deps.url}?${params}`,{cache:'no-store',signal:request.signal});
if(!response.ok)throw new Error('scene unavailable');
const bytes=response.status===204?null:new Uint8Array(await response.arrayBuffer());
if(stopped)return;
const presentation=response.headers.get('X-Planning-Presentation')==='live'?'live':'historical';
const cloud=Number(response.headers.get('X-Planning-Cloud-Age')??NaN);
const fit=Number(response.headers.get('X-Planning-Fit-Age')??NaN);
const elapsed=now()-started;
if(presentation==='live'){
// Include the entire request duration: a conservative upper bound, without
// comparing clocks on different hosts. Late bytes must not revive green.
if(!Number.isFinite(cloud)||!Number.isFinite(fit)||cloud<0||fit<0||cloud+elapsed/1000>=2||fit+elapsed/1000>=8)
throw new Error('scene expired during delivery');
}
const latest=deps.snapshot();
// Never apply a response from the previous display mode or live/ended state.
if(JSON.stringify(latest.options)!==nextKey||latest.active!==snapshot.active){needsBase=true;return;}
const nextCursor=response.headers.get('X-Planning-Scene-Cursor');
if(!nextCursor)throw new Error('scene cursor missing');
if(bytes)await deps.apply(bytes,{
presentation,cloudAgeMs:presentation==='live'?cloud*1000:0,
fitAgeMs:presentation==='live'?fit*1000:0,requestMs:elapsed,
cloudRevision:parseNonnegativeInteger(response.headers.get('X-Planning-Cloud-Revision')),
cloudSequence:parseNonnegativeInteger(response.headers.get('X-Planning-Cloud-Sequence')),
displayEpoch:response.headers.get('X-Planning-Display-Epoch'),
heightMinM:parseFiniteNumber(response.headers.get('X-Planning-Height-Min')),
heightMaxM:parseFiniteNumber(response.headers.get('X-Planning-Height-Max')),
}); // Native admission only; the caller may record a separate presentation proxy.
cursor=nextCursor;key=nextKey;revision=snapshot.revision;needsBase=false;
}catch{
// Keep the last admitted cursor (including camera intent). A full geometry
// repair after a missed/expired response is not permission to reset the eye.
if(!stopped){failed=true;needsBase=true;deps.error();}
}finally{
cancel(deadline);request=undefined;
if(!stopped)timer=schedule(()=>void poll(),failed?500:Math.max(10,(snapshot.active?100:500)-(now()-started)));
}
};
void poll();
return ()=>{stopped=true;request?.abort();if(timer!==undefined)cancel(timer);};
}
function parseNonnegativeInteger(value:string|null){
if(value===null||!/^\d+$/.test(value))return null;
const parsed=Number(value);
return Number.isSafeInteger(parsed)&&parsed>0?parsed:null;
}
function parseFiniteNumber(value:string|null){
if(value===null||value.trim()==='')return null;
const parsed=Number(value);
return Number.isFinite(parsed)?parsed:null;
}
@@ -0,0 +1,91 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { canSelectSession, endAtDistance, plannerBase, plannerRequest, selectedPoses, validatePlanningSource, type Direction, type Draft, type PlanningSource, type RouteCheck, type SessionOption } from "./planner";
export function useMissionPlanner() {
const initializeRoute = useRef(true);
const [sessions, setSessions] = useState<SessionOption[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Draft[]>([]);
const [saved, setSaved] = useState<Draft | null>(null);
const [name, setName] = useState("");
const [sessionId, setSessionId] = useState("");
const [source, setSource] = useState<PlanningSource | null>(null);
const [start, setStart] = useState(0);
const [end, setEnd] = useState(1);
const [direction, setDirection] = useState<Direction>("forward");
const [error, setError] = useState<string | null>(null);
const [sourceError, setSourceError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [catalogBusy, setCatalogBusy] = useState(true);
const [loadVersion, setLoadVersion] = useState(0);
const [sourceVersion, setSourceVersion] = useState(0);
const [check, setCheck] = useState<RouteCheck | null>(null);
useEffect(() => {
const abort = new AbortController(); setCatalogBusy(true); setError(null);
void Promise.all([
plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>("/api/v1/observation-sessions?limit=100&pagination=cursor-v1", { signal: abort.signal }),
plannerRequest<{ items: Draft[] }>(`${plannerBase}/drafts`, { signal: abort.signal }),
]).then(([catalog, archive]) => { if (!abort.signal.aborted) { setSessions(catalog.items); setCursor(catalog.next_cursor); setDrafts(archive.items); } })
.catch(reason => { if (!abort.signal.aborted) setError(reason.message); })
.finally(() => { if (!abort.signal.aborted) setCatalogBusy(false); });
return () => abort.abort();
}, [loadVersion]);
useEffect(() => {
const abort = new AbortController(); setSource(null); setSourceError(null);
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}`, { signal: abort.signal })
.then(data => { if (!abort.signal.aborted) { const next = validatePlanningSource(data, sessionId); setSource(next); if (initializeRoute.current) { setStart(0); setEnd(endAtDistance(next, 0, 30)); initializeRoute.current = false; } } })
.catch(reason => { if (!abort.signal.aborted) setSourceError(reason.message); });
return () => abort.abort();
}, [sessionId, sourceVersion]);
const currentSource = source?.session_id === sessionId ? source : null;
const sourceChanged = !!(saved && currentSource && saved.zone.session_id === sessionId && saved.zone.generation !== currentSource.generation);
const poses = useMemo(() => selectedPoses(currentSource, start, end, direction), [currentSource, start, end, direction]);
const dirty = !saved || name.trim() !== saved.name || sessionId !== saved.zone.session_id || sourceChanged
|| start !== saved.route.start_index || end !== saved.route.end_index || direction !== saved.route.direction;
const ready = !!currentSource && !sourceChanged && poses.length > 1 && !!name.trim();
const chooseSource = (id: string) => { initializeRoute.current = true; setSessionId(id); setStart(0); setEnd(1); setCheck(null); };
const newDraft = () => { setSaved(null); setName(""); chooseSource(""); setDirection("forward"); setError(null); };
const openDraft = async (id: string) => {
setBusy(true); setError(null);
try {
const next = await plannerRequest<Draft>(`${plannerBase}/drafts/${id}`);
initializeRoute.current = false; setSaved(next); setName(next.name); setSessionId(next.zone.session_id);
setStart(next.route.start_index); setEnd(next.route.end_index); setDirection(next.route.direction); setCheck(null);
setSourceVersion(n => n + 1);
} catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
};
const save = async () => {
if (!ready || !currentSource) return;
setBusy(true); setError(null); setCheck(null);
try {
const next = await plannerRequest<Draft>(`${plannerBase}/drafts`, { method: "POST", body: JSON.stringify({
id: saved?.id ?? null, revision: saved?.revision ?? 0, name: name.trim(), session_id: sessionId,
generation: currentSource.generation, start_index: start, end_index: end, direction,
}) });
setSaved(next); setDrafts(items => [next, ...items.filter(item => item.id !== next.id)]);
return next;
} catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
};
const runCheck = async () => {
if (!saved || dirty || !ready) return;
setBusy(true); setError(null); setCheck(null);
try { setCheck(await plannerRequest<RouteCheck>(`${plannerBase}/drafts/${saved.id}/checks`, { method: "POST", body: JSON.stringify({ revision: saved.revision }) })); }
catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
};
const loadMore = async () => {
if (!cursor) return;
setCatalogBusy(true);
try {
const page = await plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>(`/api/v1/observation-sessions?limit=100&pagination=cursor-v1&cursor=${encodeURIComponent(cursor)}`);
setSessions(items => [...items, ...page.items.filter(item => !items.some(old => old.id === item.id))]); setCursor(page.next_cursor);
} catch (reason) { setError((reason as Error).message); } finally { setCatalogBusy(false); }
};
return { sessions, drafts, saved, name, setName, sessionId, chooseSource, source: currentSource, start, setStart, end, setEnd,
direction, setDirection, error, sourceError, sourceChanged, busy, catalogBusy, cursor, loadMore, dirty, ready, poses, check,
save, openDraft, newDraft, runCheck, retrySource: () => setSourceVersion(n => n + 1), refresh: useCallback(() => setLoadVersion(n => n + 1), []),
options: [{ value: "", label: "Выберите сохранённую запись" }, ...sessions.map(item => ({ value: item.id, label: item.label,
description: canSelectSession(item) ? "Облако и траектория" : "Зона недоступна: нет исходного облака и траектории", disabled: !canSelectSession(item) }))] };
}
@@ -0,0 +1,62 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { plannerBase, plannerRequest } from './planner';
import { defaultPlanningProject, deletePlanningProject, planningProjectPending, type PlanningProject, type PlanningProjectDetail } from './planningProjects';
const storageKey = 'missioncore.planning-project.selected.v1';
export function usePlanningProjects() {
const [items, setItems] = useState<PlanningProject[]>([]);
const [key, setKey] = useState('');
const [detail, setDetail] = useState<PlanningProjectDetail|null>(null);
const [loading, setLoading] = useState(true), [error, setError] = useState<string|null>(null);
const [version, setVersion] = useState(0);
const initialized = useRef(false);
const selectedKey = useRef('');
const removedKeys = useRef(new Set<string>());
const removing = useRef(false);
const select = useCallback((value: string) => {
initialized.current = true; selectedKey.current = value; setDetail(null); setKey(value);
try { if (value) sessionStorage.setItem(storageKey,value); else sessionStorage.removeItem(storageKey); } catch { /* selection is optional UI state */ }
}, []);
const refresh = useCallback(() => setVersion(n=>n+1), []);
const remove = useCallback(async (project: PlanningProject) => {
if (removing.current) throw new Error('Удаление уже выполняется.');
removing.current = true;
try {
await deletePlanningProject(project);
removedKeys.current.add(project.key);
setItems(items => items.filter(item => item.key !== project.key));
if (selectedKey.current === project.key) select('');
} finally { removing.current = false; }
}, [select]);
useEffect(()=>{
const abort = new AbortController(); setLoading(true); setError(null);
void plannerRequest<{items:PlanningProject[]}>(plannerBase+'/projects',{signal:abort.signal}).then(data=>{
if (abort.signal.aborted) return;
const visible = data.items.filter(item => !removedKeys.current.has(item.key));
setItems(visible);
if (!initialized.current) {
let last=null; try {last=sessionStorage.getItem(storageKey);} catch { /* optional */ }
select(defaultPlanningProject(visible,last));
}
}).catch(e=>{if(!abort.signal.aborted)setError(e.message);}).finally(()=>{if(!abort.signal.aborted)setLoading(false);});
return ()=>abort.abort();
},[version,select]);
useEffect(()=>{
if (!key) return;
const abort=new AbortController(); let timer:ReturnType<typeof setTimeout>;
setDetail(null); setError(null);
const [kind,id]=key.split(':');
const poll=async()=>{
try {
const next=await plannerRequest<PlanningProjectDetail>(`${plannerBase}/projects/${kind}/${id}`,{signal:abort.signal});
if(abort.signal.aborted || removedKeys.current.has(key))return;
if(next.key!==key)throw new Error('Получен результат другого исследования.');
setDetail(next);setError(null);
setItems(items=>items.some(p=>p.key===key)?items.map(p=>p.key===key?next:p):[next,...items]);
if(planningProjectPending(next))timer=setTimeout(()=>void poll(),2000);
} catch(e) {if(!abort.signal.aborted)setError((e as Error).message);}
};
void poll(); return()=>{abort.abort();clearTimeout(timer);};
},[key,version]);
return {items,key,detail,loading,error,select,refresh,remove};
}
@@ -0,0 +1,39 @@
import { useEffect, useState } from "react";
import { endAtDistance, plannerBase, plannerRequest, validatePlanningSource, type Draft, type PlanningSource } from "./planner";
export interface RegistrationReport {
id: string; state: "queued" | "running" | "ready" | "error"; message?: string; progress_label?: string;
revision: number; created_at_utc: string; evidence_relation: "same_recording" | "different_recordings";
scene_url?: string; elapsed_seconds?: number; reference?: { label: string }; query?: { label: string };
result?: { correspondence_colors?: "accepted-distance-v1"; status: "candidate" | "rejected"; reasons: string[]; overlap: number; inlier_rmse_m: number | null;
correction_m: number; correction_deg: number; registration_seconds: number; reference_points: number; query_points: number };
}
export function useRegistrationTest(draft: Draft | null) {
const [sessionId, setSessionId] = useState("");
const [source, setSource] = useState<PlanningSource | null>(null);
const [start, setStart] = useState(0), [end, setEnd] = useState(1);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false), [submitting, setSubmitting] = useState(false);
useEffect(() => {
const abort = new AbortController(); setSource(null); setError(null); setLoading(!!sessionId);
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}`, { signal: abort.signal })
.then(value => { if (!abort.signal.aborted) { const data = validatePlanningSource(value, sessionId); setSource(data); setStart(0); setEnd(endAtDistance(data, 0, 20)); } })
.catch(e => { if (!abort.signal.aborted) setError(e.message); })
.finally(() => { if (!abort.signal.aborted) setLoading(false); });
return () => abort.abort();
}, [sessionId]);
const busy = submitting;
const length = source ? (source.poses[end]?.distance_m ?? 0) - (source.poses[start]?.distance_m ?? 0) : 0;
const run = async (savedDraft = draft) => {
if (!source || busy || !savedDraft) return;
setSubmitting(true); setError(null);
try {
const data = await plannerRequest<RegistrationReport>(`${plannerBase}/drafts/${savedDraft.id}/registration-runs`, {
method: "POST", body: JSON.stringify({ revision: savedDraft.revision, session_id: sessionId, generation: source.generation, start_index: start, end_index: end }),
});
return data;
} catch (e) { setError((e as Error).message); } finally { setSubmitting(false); }
};
return { sessionId, setSessionId, source, start, setStart, end, setEnd, error, busy, loading, length, run };
}
@@ -0,0 +1,41 @@
export interface SessionOverviewMetrics {
point_frames: number; pose_frames: number; point_count: number; sample_points: number;
decode_errors: number; sequence_gaps: number; sequence_nonincreasing: number;
path_m: number | null; start_end_m: number | null; stream_seconds: number | null;
mean_hz: number | null; interval_p95_s: number | null; interval_max_s: number | null;
interval_statistics_complete: boolean; gaps_over_second: number | null;
arrival_backwards: number; chart: [number, number][]; chart_bucket_seconds: number;
spatial_available: boolean;
}
export interface SessionOverview {
schema_version: "missioncore.session-overview/v1";
state: "queued" | "preparing" | "ready" | "error";
session: { session_id: string; display_name: string; duration_seconds: number | null;
started_at_utc: string | null; total_bytes: number; modalities: string[]; status: string; };
metrics?: SessionOverviewMetrics | null;
scene_url?: string | null;
message?: string;
messages_processed?: number;
}
export async function fetchSessionOverview(sessionId: string, signal: AbortSignal, retry = false): Promise<SessionOverview> {
const response = await fetch(`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/overview${retry ? "/retry" : ""}`, {
signal, method: retry ? "POST" : "GET", cache: "no-store",
});
if (!response.ok) throw new Error("Обзор записи недоступен.");
const data = await response.json() as SessionOverview;
if (data.schema_version !== "missioncore.session-overview/v1" || data.session?.session_id !== sessionId
|| !["queued", "preparing", "ready", "error"].includes(data.state)) throw new Error("Получены некорректные сведения о записи.");
if (data.scene_url) {
const url = new URL(data.scene_url, window.location.origin);
if (url.origin !== window.location.origin || url.pathname !== `/api/v1/observation-sessions/${sessionId}/overview/scene.rrd`) throw new Error("Облако записи недоступно.");
}
return data;
}
export function overviewChartPoints(chart: readonly [number, number][], width: number, height: number) {
const valid = chart.filter(([x, y]) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0);
const xmax = Math.max(1, ...valid.map(p => p[0]));
const ymax = Math.max(.1, ...valid.map(p => p[1])) * 1.1;
return { xmax, ymax, points: valid.map(([x, y]) => `${x / xmax * width},${height - y / ymax * height}`).join(" ") };
}
@@ -0,0 +1,26 @@
export type OverviewViewMode = "3d" | "top";
export interface OverviewSpatialMetadata { height_min_m: number | null; height_max_m: number | null; sample_points: number; }
function endpoint(source: string) {
const url = new URL(source, window.location.origin);
if (url.origin !== window.location.origin || !url.pathname.endsWith("/overview/scene.rrd")) throw new Error("Облако недоступно.");
url.pathname = url.pathname.replace(/scene\.rrd$/, "spatial");
return url;
}
export async function fetchOverviewSpatial(source: string, signal: AbortSignal): Promise<OverviewSpatialMetadata> {
const response = await fetch(endpoint(source), { signal, cache: "no-store" });
if (!response.ok) throw new Error("Параметры среза недоступны.");
return response.json();
}
export async function updateOverviewSpatial(source: string, ceiling: number | null, mode: OverviewViewMode | null, aspect: number, signal: AbortSignal) {
const url = endpoint(source);
const generation = url.searchParams.get("generation");
url.search = "";
const response = await fetch(url, { method: "POST", signal, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ generation, ceiling_m: ceiling, mode, aspect }) });
if (!response.ok) throw new Error("Не удалось обновить вид облака.");
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength > 32 * 1024 * 1024) throw new Error("Обзор превышает допустимый размер.");
return { bytes, visiblePoints: Number(response.headers.get("X-Overview-Visible-Points")),
eye: response.headers.get("X-Overview-Eye") ? JSON.parse(response.headers.get("X-Overview-Eye")!) : null };
}
@@ -0,0 +1,41 @@
import { useCallback, useEffect, useState } from "react";
import { fetchSessionOverview, type SessionOverview } from "./sessionOverview";
export function useSessionOverview(sessionId: string) {
const [data, setData] = useState<SessionOverview | null>(null);
const [error, setError] = useState<string | null>(null);
const [retryGeneration, setRetryGeneration] = useState(0);
useEffect(() => {
const abort = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
setData(null); setError(null);
const load = async (retry = false) => {
try {
const next = await fetchSessionOverview(sessionId, abort.signal, retry);
if (abort.signal.aborted) return;
setData(next);
if (next.state === "queued" || next.state === "preparing") timer = setTimeout(() => void load(), 1200);
} catch (reason) {
if (!abort.signal.aborted) setError(reason instanceof Error ? reason.message : "Обзор недоступен.");
}
};
void load(retryGeneration > 0);
return () => { abort.abort(); clearTimeout(timer); };
}, [sessionId, retryGeneration]);
return { data: data?.session.session_id === sessionId ? data : null, error,
retry: useCallback(() => setRetryGeneration(n => n + 1), []) };
}
export function useSessionOverviewMode(enabled: boolean) {
const [open, setOpen] = useState(false);
useEffect(() => { if (!enabled) setOpen(false); }, [enabled]);
useEffect(() => {
if (!open) return;
const close = (event: KeyboardEvent) => {
if (event.key === "Escape" && !event.defaultPrevented) { event.preventDefault(); event.stopPropagation(); setOpen(false); }
};
window.addEventListener("keydown", close, true);
return () => window.removeEventListener("keydown", close, true);
}, [open]);
return { open: enabled && open, toggle: useCallback(() => setOpen(v => !v), []) };
}
+2 -1
View File
@@ -7,6 +7,7 @@ import "@nodedc/ui-core/styles.css";
import App from "./App";
import { installedDevicePlugins, installedNodeSensorContributions } from "./composition/devicePlugins";
import { PlanningTestProvider } from "./core/missions/PlanningTestContext";
import { DevicePluginHostProvider } from "./core/device-plugins/DevicePluginHost";
import { ComputeContourProvider } from "./core/system/ComputeContourContext";
import "./styles.css";
@@ -25,7 +26,7 @@ createRoot(rootElement).render(
<StrictMode>
<DevicePluginHostProvider plugins={installedDevicePlugins} nodeSensorContributions={installedNodeSensorContributions}>
<ComputeContourProvider>
<App />
<PlanningTestProvider><App /></PlanningTestProvider>
</ComputeContourProvider>
</DevicePluginHostProvider>
</StrictMode>,
+5 -6
View File
@@ -402,11 +402,11 @@ export const workspaces: WorkspaceDefinition[] = [
},
{
id: "mission-planner",
root: "missions",
root: "polygon",
label: "Планировщик",
title: "Планировщик миссии",
eyebrow: "МИССИИ / ЧЕРНОВИК",
description: "Черновик миссии, аппараты, зона, маршрут, действия и ограничения.",
title: "Исследование планирования",
eyebrow: "LAB / ПЛАНИРОВАНИЕ",
description: "Эталонный участок и сопоставление нового прохода сканера.",
icon: "edit",
kind: "missions",
groups: [
@@ -414,8 +414,7 @@ export const workspaces: WorkspaceDefinition[] = [
title: "Состав миссии",
description: "Продуктовый контракт без отправки команд на борт.",
capabilities: [
contract("Назначение аппарата", "Один аппарат сейчас, несколько — в будущем."),
contract("Зона и маршрут", "Точки, коридор, высота/скорость и геозоны."),
active("Зона и маршрут", "Выбор сохранённой записи, участка траектории и направления."),
contract("Полезная нагрузка", "Запуск сенсоров и требуемые выходные данные."),
later("Проверка выполнимости", "Расчёт энергии, связи, столкновений и резервов."),
],
@@ -0,0 +1,73 @@
.mission-planner { height: 100%; box-sizing: border-box; min-height: 360px; display: flex; flex-direction: column; gap: 16px; min-width: 0; flex: 1; padding: 8px 16px 20px; }
.mission-planner__toolbar { display: flex; align-items: end; gap: 8px; flex-wrap: wrap; }
.mission-planner__toolbar > .nodedc-field { flex: 1; min-width: 180px; }
.mission-planner__toolbar > .nodedc-select-anchor { width: 260px; max-width: 100%; }
.mission-planner__layout { display: grid; grid-template-columns: minmax(270px, 320px) minmax(0, 1fr); gap: 16px; flex: 1; min-height: 0; }
.mission-planner__editor { min-height: 0; overflow: auto; display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.mission-planner__section { display: flex; flex-direction: column; gap: 10px; }
.mission-planner h2 { font-size: var(--nodedc-font-size-md); margin: 0; font-weight: 600; }
.mission-planner p, .mission-planner__check p { font-size: var(--nodedc-font-size-sm); margin: 0; line-height: 1.5; }
.mission-planner small { font-size: var(--nodedc-font-size-xs); color: var(--nodedc-text-muted); line-height: 1.5; }
.mission-planner__actions { display: flex; flex-wrap: wrap; gap: 8px; }
.mission-planner__viewer { min-height: 0; }
.mission-planner__viewer-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; }
.mission-planner__zone-loading { flex: 1; min-height: 320px; }
.mission-planner__notice { font-size: var(--nodedc-font-size-sm); padding: 8px 0; }
.mission-planner__expanded { width: min(1300px, 94vw); }
.mission-planner__expanded .mission-planner__viewer { height: min(65vh, 620px); min-height: 360px; }
.mission-planner__expanded .mission-route-preview svg { min-height: 140px; }
.mission-route-preview { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 12px; padding: 16px; }
.mission-route-preview svg { width: 100%; flex: 1; min-height: 250px; }
.mission-route-preview p { margin: 0; line-height: 1.5; color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); }
.mission-route-preview__reference { stroke: var(--nodedc-text-muted); opacity: .35; stroke-width: 2; }
.mission-route-preview__selected { stroke: var(--nodedc-text-primary); stroke-width: 2; }
.mission-route-preview__cursor { fill: var(--nodedc-text-primary); }
.mission-route-preview text { fill: var(--nodedc-text-muted); font-size: 12px; }
.mission-planner__check { display: flex; flex-direction: column; gap: 16px; }
.mission-planner__check dl { font-size: var(--nodedc-font-size-sm); }
.mission-planner__check dl > div { display: flex; justify-content: space-between; gap: 16px; padding: 8px 0; }
.mission-planner__check dd { margin: 0; text-align: right; }
.mission-planner__check dt { color: var(--nodedc-text-muted); }
@media (max-width: 900px) { .mission-planner { height: auto; } .mission-planner__layout { grid-template-columns: minmax(0, 1fr); } .mission-planner__editor { max-height: 55vh; } .mission-planner__viewer { height: 65vh; } }
.mission-registration { width: min(1100px, 94vw); }
.mission-registration__scene { position: relative; height: 380px; min-height: 300px; overflow: hidden; }
.mission-registration--expanded .mission-registration__scene { height: 65vh; }
.mission-registration .mission-planner__viewer-head { font-size: var(--nodedc-font-size-xs); }
.planning-connection { width: min(1080px, 94vw); }
.planning-live__viewport { overflow: hidden; min-height: 520px; }
.planning-live__scene { position: relative; height: 60vh; min-height: 420px; overflow: hidden; }
.planning-live--expanded { width: min(1400px, 96vw); }
.planning-live--expanded .planning-live__scene { height: 70vh; }
.planning-live .mission-planner__toolbar, .planning-live .mission-planner__actions { font-size: var(--nodedc-font-size-sm); }
.planning-live__scene > .session-overview__empty { position: absolute; inset: 0; }
.spatial-viewport-shell > .planning-live__scene { position: absolute; inset: 0; width: 100%; height: 100%; min-height: 0; }
.planning-live__summary { display: flex; flex-wrap: wrap; gap: 8px 20px; padding: 8px 4px; color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); }
.planning-live__camera-note { position: absolute; right: 20px; bottom: 24px; display: flex; align-items: center; gap: 8px; font-size: var(--nodedc-font-size-xs); color: var(--nodedc-text-muted); }
.mission-planner__editor .mission-planner__actions { display: grid; grid-template-columns: minmax(0, 1fr); }
.mission-planner__editor .inspector-control-stack > .nodedc-button, .mission-planner__editor .mission-planner__actions > .nodedc-button { width: 100%; }
.mission-planner__viewer-head--end, .session-overview__scene-head--end { justify-content: flex-end; padding: 12px; }
/* The pinned embedded Rerun canvas reserves 28 px above its 26 px view strip.
Allocate this non-content chrome outside the clipped host; pointer coordinates
remain native and the scene retains the full host height. */
.session-overview__runtime.rerun-single-view-content { top: -54px; }
/* Scene-first planning: settings float within the stage and never allocate a column. */
.planning-project { padding: 8px 16px 16px; }
.planning-project__header-tools { display: flex; align-items: center; gap: 8px; min-width: 0; }
.planning-project__header-tools > .nodedc-select-anchor { width: clamp(200px, 20vw, 300px); min-width: 0; }
.planning-project__stage { position: relative; overflow: hidden; flex: 1; min-height: 360px; }
.planning-project__viewport { position: absolute; inset: 0; overflow: hidden; }
.planning-project__viewport > .mission-registration__scene { flex: 1; height: auto; min-height: 0; }
.planning-project__result-header { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 12px 16px; font-size: var(--nodedc-font-size-sm); }
.planning-project__legend { padding: 8px 16px; }
.planning-project__inspector .inspector-control-stack > .nodedc-button { width: 100%; }
.planning-project__inspector p { margin: 0; font-size: var(--nodedc-font-size-sm); line-height: 1.5; }
.planning-project__inspector dl { margin: 0; font-size: var(--nodedc-font-size-sm); }
.planning-project__inspector dl > div { display: flex; align-items: start; justify-content: space-between; gap: 12px; padding: 6px 0; }
.planning-project__inspector dt { color: var(--nodedc-text-muted); }
.planning-project__inspector dd { margin: 0; text-align: right; overflow-wrap: anywhere; }
@media (max-width: 900px) { .mission-planner.planning-project { height: 100%; } .planning-project__header-tools { flex-wrap: wrap; } }
@@ -187,9 +187,6 @@
text-align: left;
}
.scene-metrics {
display: none;
}
}
/* The shared shell switches to its compact overlay at 760 px, but Mission
@@ -544,11 +541,6 @@
justify-content: center;
}
.scene-status--top-left {
top: 0.6rem;
left: 0.6rem;
}
.scene-device-controls {
top: 0.6rem;
max-width: calc(100% - 8rem);
@@ -0,0 +1,25 @@
.session-overview { height: 100%; min-height: 360px; flex: 1; min-width: 0; }
.session-overview__loading, .session-overview__split { height: 100%; min-height: 0; }
.session-overview__panel { height: 100%; min-height: 0; min-width: 0; display: flex; flex-direction: column; overflow: hidden; container-type: inline-size; }
.session-overview__panel h2 { margin: 0; padding: 16px 18px; font: inherit; font-size: var(--nodedc-font-size-md); font-weight: 600; flex: none; }
.session-overview__scene { position: relative; flex: 1; min-height: 0; overflow: hidden; }
.session-overview__runtime { position: absolute; inset: 0; }
.session-overview__runtime iframe { display: block; width: 100%; height: 100%; border: 0; }
.session-overview__scene-head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; padding-right: 12px; flex: none; }
.session-overview__height { position: absolute; z-index: 2; top: 44px; right: 12px; bottom: 12px; width: 58px; display: flex; flex-direction: column; align-items: center; gap: 6px; color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); }
.session-overview__height > .nodedc-range-scale { flex: 1; min-height: 0; }
.session-overview__empty { display: flex; flex: 1; min-height: 160px; align-items: center; justify-content: center; flex-direction: column; gap: 14px; font-size: var(--nodedc-font-size-sm); color: var(--nodedc-text-muted); }
.session-overview__scene .session-overview__empty { position: absolute; inset: 0; }
.session-overview__note { padding: 8px 16px 12px; color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); flex: none; }
.session-overview__facts { overflow: auto; min-height: 0; padding: 0 18px 12px; font-size: var(--nodedc-font-size-sm); }
.session-overview__facts dl { margin: 0; }
.session-overview__facts dl > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 14px; padding: 8px 0; }
.session-overview__facts dt { color: var(--nodedc-text-muted); }
.session-overview__facts dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; }
.session-overview__facts p { color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); line-height: 1.5; }
.session-overview__chart { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.session-overview__chart svg { width: 100%; flex: 1; min-height: 0; overflow: visible; }
.session-overview__chart text { fill: var(--nodedc-text-muted); font-size: 12px; }
.session-overview__grid { stroke: var(--nodedc-text-muted); opacity: .2; stroke-width: 1; }
.session-overview__series { stroke: var(--nodedc-text-primary); stroke-width: 1.5; }
@container (max-width: 270px) { .session-overview__facts dl > div { grid-template-columns: minmax(0, 1fr); gap: 3px; } .session-overview__facts dd { text-align: left; overflow-wrap: anywhere; } }
@@ -1,3 +1,4 @@
import { PlanningConnectionWindow } from "../components/missions/PlanningConnectionWindow";
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
import { useDevicePluginHost } from "../core/device-plugins/DevicePluginHost";
@@ -40,7 +41,7 @@ function ModelCard({
);
}
export function DeviceWorkspace({
function DeviceConnectionBody({
onOpenSpatialScene,
onActivateAutomaticSpatialSource,
}: {
@@ -128,3 +129,7 @@ export function DeviceWorkspace({
</div>
);
}
export function DeviceWorkspace(props: {onOpenSpatialScene:()=>void;onActivateAutomaticSpatialSource:()=>void}) {
return <PlanningConnectionWindow><DeviceConnectionBody {...props}/></PlanningConnectionWindow>;
}
@@ -1,39 +1,26 @@
import {SpatialScene, EmptySpatialStage} from '../../../../packages/spatial-ui/src';
import {SpatialToolbarActions} from "../../../../packages/spatial-ui/src/SpatialToolbarActions";
import {SpatialWorkspace} from "./spatial/SpatialWorkspace";
import { usePlanningTest } from "../core/missions/PlanningTestContext";
import { PlanningSpatialWorkspace } from "./missions/PlanningSpatialWorkspace";
import { MissionPlannerWorkspace } from "./missions/MissionPlannerWorkspace";
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useMemo } from "react";
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
import {
ObservationMedia,
ObservationSourcePicker,
observationSourceStatusLabel,
} from "../components/ObservationSources";
import { ObservationTimeline } from "../components/ObservationTimeline";
import { FloatingObservationWindow } from "../components/FloatingObservationWindow";
import type {
RecordedAdmissionPhase,
RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission";
import { liveRerunRecoveryAuthorityIdentity } from "../core/observation/liveReceiverWatchdog";
import { liveAcquisitionRerunProfile, recordedSessionRerunProfile } from "../core/observation/viewerProfile";
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
import {
RerunViewport,
isRecordedPlaybackPresentationReady,
rerunPresentationStatus,
type RerunPlaybackController,
type RerunPlaybackState,
type RerunSelection,
type RerunViewportStatus,
type RecordedPerceptionLoadState,
type RecordedPointColorLoadState,
} from "../components/RerunViewport";
import {
capabilityStatusLabel,
type CapabilityStatus,
type WorkspaceDefinition,
} from "../productModel";
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
import { sourceModeLabel } from "../presentation";
import type { WorkspaceRendererProps } from "./contracts";
import { DatasetGatewayWorkspace } from "./DatasetGatewayWorkspace";
import { ArtifactHealthWorkspace } from "./data/ArtifactHealthWorkspace";
@@ -91,608 +78,6 @@ function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition;
</section>
);
}
function SpatialWorkspace({
state,
sourceUrl,
requestedPlaybackSeconds,
recordedReplay,
recordedSessionAdmission,
sceneSettings,
accumulationSeconds,
onAccumulationChange,
onAccumulationCommit,
livePerceptionLayers,
onLivePerceptionLayersChange,
observationLayout,
navigation,
spatialControls,
}: WorkspaceRendererProps) {
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
const [viewerMessage, setViewerMessage] = useState("");
const [selection, setSelection] = useState<RerunSelection | null>(null);
const [playbackState, setPlaybackState] = useState<RerunPlaybackState | null>(null);
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
const lastRequestedPlaybackSeconds = useRef<number | null>(null);
const [recordedViewResetGeneration, setRecordedViewResetGeneration] = useState<0 | 1>(0);
const [followRecordedTrajectory, setFollowRecordedTrajectory] = useState(false);
const [perceptionLoad, setPerceptionLoad] = useState<RecordedPerceptionLoadState>({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
const [perceptionRetryGeneration, setPerceptionRetryGeneration] = useState(0);
const [pointColorLoad, setPointColorLoad] = useState<RecordedPointColorLoadState>({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
const [showDetections2d, setShowDetections2d] = useState(false);
const [showSegmentation, setShowSegmentation] = useState(false);
const [showCuboids3d, setShowCuboids3d] = useState(false);
const recordedSource = Boolean(recordedReplay) || /\.rrd(?:$|[?#])/i.test(sourceUrl);
const liveRerunSource = !recordedSource && /^rerun\+https?:\/\//i.test(sourceUrl.trim());
const recordedSessionGate: RecordedAdmissionPhase = recordedSource
? recordedSessionAdmission?.phase ?? "loading"
: "ready";
const recordedPlaybackReady = !recordedSource ||
(recordedSessionGate === "ready" &&
isRecordedPlaybackPresentationReady(viewerStatus, playbackState));
const streamActive = state?.sourceMode === "live" || state?.sourceMode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
// An explicit manual gRPC source has no Mission Core metrics producer. Its
// native Rerun range is therefore the only available activity proof.
const livePresentationActivitySequence = metrics?.publishedFrameCount ??
(liveRerunSource && !streamActive ? 1 : null);
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frameRateHz);
const points = finiteMetric(metrics?.pointCount);
const aiLatency = finiteMetric(metrics?.aiLatencyMs);
const aiFrameRate = finiteMetric(metrics?.aiFrameRateHz);
const observationSources = state?.observationSources ?? [];
const pointCloudSource = observationSources.find((source) => source.modality === "point-cloud");
const pointCloudVisible = pointCloudSource
? observationLayout.visibleSourceIds.has(pointCloudSource.id)
: Boolean(sourceUrl.trim());
const mediaSources = observationSources.filter(
(source) => source.capabilities.overlay && source.modality !== "point-cloud",
);
const recordedPerceptionSupported =
recordedSource && perceptionLoad.phase !== "unavailable";
const recordedPerceptionLoading = recordedSource && perceptionLoad.phase === "loading";
const recordedPerceptionEnabled =
showDetections2d || showSegmentation || showCuboids3d;
// The native recorded camera remains the authoritative original. Only 2D
// image-space overlays need Rerun's paired camera/world composition; 3D
// cuboids are added directly to the stable spatial view.
const unifiedPerception = recordedPerceptionSupported &&
(showDetections2d || showSegmentation);
const livePerceptionAvailable = !recordedSource && streamActive;
const detections2dActive = recordedSource
? showDetections2d
: livePerceptionLayers.detections2d;
const segmentationActive = recordedSource
? showSegmentation
: livePerceptionLayers.segmentation;
const cuboids3dActive = recordedSource
? showCuboids3d
: livePerceptionLayers.cuboids3d;
const visibleMediaSources = mediaSources.filter((source) =>
observationLayout.visibleSourceIds.has(source.id) &&
!source.id.startsWith("recorded.perception."),
);
const initialRecordedPlaybackStartSeconds = recordedSource
? mediaSources.reduce<number | undefined>((earliest, source) => {
if (
source.id.startsWith("recorded.perception.") ||
source.delivery?.kind !== "recorded-fmp4-manifest"
) return earliest;
const start = source.delivery.timelineStartSeconds;
return earliest === undefined ? start : Math.min(earliest, start);
}, undefined)
: undefined;
const presentedMediaSourceCount = unifiedPerception ? 0 : visibleMediaSources.length;
const pointCloudFocused = Boolean(
pointCloudSource && observationLayout.focusedSourceId === pointCloudSource.id,
);
useEffect(() => {
if (!pointCloudFocused) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
observationLayout.setFocusedSourceId(null);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [observationLayout.setFocusedSourceId, pointCloudFocused]);
const floatingSourceMaximized = !unifiedPerception &&
Boolean(observationLayout.maximizedFloatingSourceId);
const timeline = state?.observationTimeline;
const viewportRef = useRef<HTMLDivElement>(null);
const intentionalSourceEnd = !recordedSource && state?.sourceMode === "idle" && ["awaiting_external_stop", "stopping", "finalizing", "completed",].includes(state?.acquisition?.state ?? "");
const presentedViewerStatus = intentionalSourceEnd
? "idle"
: rerunPresentationStatus(
viewerStatus,
recordedSessionGate,
recordedSource,
);
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
setViewerStatus(status);
setViewerMessage(message ?? "");
if (recordedSessionAdmission) {
recordedSessionAdmission.reportSpatial(
recordedSessionAdmission.key,
status === "ready" ? "ready" : status === "error" ? "error" : "loading",
);
}
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportSpatial]);
const onRecordedAdmissionChange = useCallback((
sourceId: string,
next: RecordedCameraAdmissionState,
) => {
if (!recordedSessionAdmission) return;
if (next.admissionKey !== recordedSessionAdmission.key) return;
recordedSessionAdmission.reportCamera(recordedSessionAdmission.key, sourceId, next);
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportCamera]);
const shouldPrepareRecordedSource = useCallback((sourceId: string) => {
if (!recordedSessionAdmission) return false;
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
["ready", "error"].includes(recordedSessionAdmission.cameras[sourceId]?.phase ?? "loading");
}, [recordedSessionAdmission]);
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
const onPlaybackChange = useCallback(
(next: RerunPlaybackState | null) => setPlaybackState(next),
[],
);
const onPlaybackControllerChange = useCallback(
(next: RerunPlaybackController | null) => setPlaybackController(next),
[],
);
const onPerceptionLoadChange = useCallback((next: RecordedPerceptionLoadState) => {
setPerceptionLoad(next);
if (next.phase === "unavailable" || next.phase === "error") {
setShowDetections2d(false);
setShowSegmentation(false);
setShowCuboids3d(false);
}
}, []);
const onPointColorLoadChange = useCallback((next: RecordedPointColorLoadState) => {
setPointColorLoad(next);
}, []);
useEffect(() => {
setPerceptionLoad({
phase: recordedSource ? "loading" : "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: recordedSource ? "Ожидаем канал AI-слоёв." : "",
});
setPerceptionRetryGeneration(0);
setShowDetections2d(false);
setShowSegmentation(false);
setShowCuboids3d(false);
setRecordedViewResetGeneration(0);
setFollowRecordedTrajectory(false);
setPointColorLoad({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
}, [recordedSource, sourceUrl]);
useEffect(() => {
if (pointCloudVisible && sourceUrl.trim()) return;
setViewerStatus("idle");
setViewerMessage("");
setSelection(null);
setPlaybackState(null);
setPlaybackController(null);
setPerceptionLoad({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
setShowDetections2d(false);
setShowSegmentation(false);
setShowCuboids3d(false);
setFollowRecordedTrajectory(false);
setPointColorLoad({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
}, [pointCloudVisible, sourceUrl]);
useEffect(() => {
lastRequestedPlaybackSeconds.current = null;
}, [sourceUrl]);
useEffect(() => {
if (
!recordedSource
|| !playbackController
|| requestedPlaybackSeconds === null
|| requestedPlaybackSeconds === undefined
|| !Number.isFinite(requestedPlaybackSeconds)
|| lastRequestedPlaybackSeconds.current === requestedPlaybackSeconds
) return;
lastRequestedPlaybackSeconds.current = requestedPlaybackSeconds;
playbackController.setPlaying(false);
playbackController.seek(Math.round(requestedPlaybackSeconds * 1_000_000_000));
}, [playbackController, recordedSource, requestedPlaybackSeconds]);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
const publishViewportSize = () => {
const bounds = viewport.getBoundingClientRect();
if (bounds.width < 1 || bounds.height < 1) return;
observationLayout.setViewportSize({
width: bounds.width,
height: bounds.height,
});
};
publishViewportSize();
const observer = new ResizeObserver(publishViewportSize);
observer.observe(viewport);
return () => observer.disconnect();
}, [observationLayout.setViewportSize]);
const viewerStatusLabel = {
idle: intentionalSourceEnd ? "Источник отключён" : "Источник не назначен",
loading: "Подключение",
ready: "Визуализатор готов",
error: "Ошибка источника",
}[presentedViewerStatus];
const viewerStatusTone = presentedViewerStatus === "ready"
? "success"
: presentedViewerStatus === "error"
? "danger"
: "neutral";
const rerunViewerProfile = recordedSource
? recordedSessionRerunProfile({
sourceUrl,
artifact: recordedReplay,
autoplayWhenReady: true,
presentationGate: recordedSessionGate,
expectedTimelineStartSeconds: state?.observationTimeline?.range?.startSeconds,
expectedTimelineEndSeconds: state?.observationTimeline?.range?.endSeconds,
initialPlaybackStartSeconds: initialRecordedPlaybackStartSeconds,
view: "spatial",
viewResetGeneration: recordedViewResetGeneration,
followTrajectory: followRecordedTrajectory,
perceptionLayers: {
enabled: recordedPerceptionSupported && recordedPerceptionEnabled,
detections2d: showDetections2d,
segmentation: showSegmentation,
cuboids3d: showCuboids3d,
},
perceptionRetryGeneration,
lockPerceptionCameraInteraction: unifiedPerception,
})
: liveAcquisitionRerunProfile({
sourceUrl,
liveActivitySequence: livePresentationActivitySequence,
liveStreamId: state?.spatialSource?.id ?? null,
liveRecoveryAuthorityIdentity: streamActive
? liveRerunRecoveryAuthorityIdentity(pointCloudSource, state?.spatialSource)
: null,
});
return <SpatialScene viewportRef={viewportRef} focused={pointCloudFocused||floatingSourceMaximized}
primaryFocused={pointCloudFocused} mediaMaximized={floatingSourceMaximized}
toolbar={<> {recordedPerceptionSupported || livePerceptionAvailable ? (
<div className="spatial-toolbar__view-switch" role="group" aria-label="Слои распознавания сцены">
<Button
size="compact"
variant="primary"
icon={<Icon name="video" />}
aria-pressed="true"
disabled
>
Оригинал
</Button>
<Button
size="compact"
variant={detections2dActive ? "primary" : "secondary"}
icon={<Icon name="target" />}
aria-pressed={detections2dActive}
disabled={recordedPerceptionLoading}
onClick={() => recordedSource
? setShowDetections2d((current) => !current)
: onLivePerceptionLayersChange({
...livePerceptionLayers,
detections2d: !livePerceptionLayers.detections2d,
})}
>
Объекты 2D
</Button>
<Button
size="compact"
variant={segmentationActive ? "primary" : "secondary"}
icon={<Icon name="image" />}
aria-pressed={segmentationActive}
disabled={recordedPerceptionLoading}
onClick={() => recordedSource
? setShowSegmentation((current) => !current)
: onLivePerceptionLayersChange({
...livePerceptionLayers,
segmentation: !livePerceptionLayers.segmentation,
})}
>
Сегментация
</Button>
<Button
size="compact"
variant={cuboids3dActive ? "primary" : "secondary"}
icon={<Icon name="apps" />}
aria-pressed={cuboids3dActive}
disabled={recordedPerceptionLoading}
onClick={() => recordedSource
? setShowCuboids3d((current) => !current)
: onLivePerceptionLayersChange({
...livePerceptionLayers,
cuboids3d: !livePerceptionLayers.cuboids3d,
})}
>
Кубы 3D
</Button>
</div>
) : null}
{recordedSource && presentedViewerStatus === "ready" ? (
<Button
size="compact"
variant={followRecordedTrajectory ? "primary" : "secondary"}
icon={<Icon name="target" />}
aria-pressed={followRecordedTrajectory}
title="Удерживать orbital-пивот на риге до повторного нажатия"
onClick={() => setFollowRecordedTrajectory((current) => !current)}
>
Следовать
</Button>
) : null}
{recordedSource && presentedViewerStatus === "ready" ? (
<Button
size="compact"
variant="secondary"
icon={<Icon name="refresh" />}
onClick={() => setRecordedViewResetGeneration((current) => current === 0 ? 1 : 0)}
>
Сброс вида
</Button>
) : null}
<SpatialToolbarActions openSource={navigation.openSource} openLayers={navigation.openLayers} openDisplay={navigation.openDisplay}/></>} renderer={<> {sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
<RerunViewport
profile={rerunViewerProfile}
sceneSettings={sceneSettings}
onPerceptionLoadChange={onPerceptionLoadChange}
onPointColorLoadChange={onPointColorLoadChange}
onStatusChange={onStatusChange}
onSelectionChange={onSelectionChange}
onPlaybackChange={onPlaybackChange}
onPlaybackControllerChange={onPlaybackControllerChange}
/>
) : (
<EmptySpatialStage settings={sceneSettings} />
)}
</>}
deviceControls={spatialControls&&!recordedSource?( <spatialControls.View
model={spatialControls.model}
host={{
openSpatialScene: () => navigation.openView("spatial-scene"),
activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource,
}}
/>):null}
sourceControls={<> {pointCloudFocused ? (
<button
type="button"
className="scene-focus-exit"
aria-label="Выйти из полноэкранного режима облака точек"
onClick={() => observationLayout.setFocusedSourceId(null)}
>
<Icon name="minimize" size={16} />
</button>
) : !floatingSourceMaximized ? (
<div className="scene-source-controls">
<ObservationSourcePicker
sources={unifiedPerception
? observationSources.filter((source) => source.modality === "point-cloud")
: observationSources}
visibleSourceIds={observationLayout.visibleSourceIds}
pendingSourceIds={observationLayout.pendingSourceIds}
onToggle={observationLayout.toggleSource}
/>
{pointCloudSource?.capabilities.fullscreen && pointCloudVisible && sourceUrl.trim() ? (
<button
type="button"
className="scene-source-control"
aria-label="Развернуть облако точек"
onClick={() => observationLayout.setFocusedSourceId(pointCloudSource.id)}
>
<Icon name="expand" size={16} />
</button>
) : null}
</div>
) : null}
</>}
status={{label:viewerStatusLabel,tone:viewerStatusTone,message:!intentionalSourceEnd?viewerMessage:undefined}}
metrics={<> <div>
<span>КАДР/С</span>
<strong>{formatNumber(frameRate)}</strong>
</div>
<div>
<span>Точек</span>
<strong>{points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}</strong>
</div>
<div>
<span>До публикации</span>
<strong>{formatNumber(latency)}<small> мс</small></strong>
</div>
{streamActive ? (
<div>
<span>AI</span>
<strong>
{aiLatency === null ? "—" : formatNumber(aiLatency)}
<small>{aiLatency === null ? "" : " мс"}</small>
{aiFrameRate === null ? null : <small> · {formatNumber(aiFrameRate)} Гц</small>}
</strong>
</div>
) : null}</>} overlays={<> {!pointCloudFocused && !floatingSourceMaximized && state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
<div className="scene-adapter-note">
<Icon name="alert" />
<span>
Локальный поток <strong>{sourceModeLabel(state.sourceMode).toLocaleLowerCase("ru-RU")}</strong> активен,
Rerun-мост запускается и опубликует адрес автоматически.
</span>
</div>
) : null}
{!pointCloudFocused && !floatingSourceMaximized && selection ? (
<div className="scene-selection">
<span>Выбрано</span>
<strong>{selection.entityPath}</strong>
{selection.viewName ? <small>{selection.viewName}</small> : null}
</div>
) : null}
{!floatingSourceMaximized && recordedSource && (
perceptionLoad.phase === "loading" ||
perceptionLoad.phase === "error" ||
pointColorLoad.phase === "loading" ||
pointColorLoad.phase === "error"
) ? (
<div className="scene-operation-status-stack">
{perceptionLoad.phase === "loading" ? (
<div className="scene-operation-status" role="status">
<span className="busy-indicator" aria-hidden="true" />
<span>{perceptionLoad.message || "Готовим AI-слои."}</span>
</div>
) : null}
{perceptionLoad.phase === "error" ? (
<button
type="button"
className="scene-operation-status scene-operation-status--action"
onClick={() => setPerceptionRetryGeneration((generation) => generation + 1)}
>
<Icon name="refresh" size={12} />
<span>Повторить AI</span>
</button>
) : null}
{pointColorLoad.phase === "loading" ? (
<div className="scene-operation-status" role="status">
<span className="busy-indicator" aria-hidden="true" />
<span>{pointColorLoad.message}</span>
</div>
) : null}
{pointColorLoad.phase === "error" ? (
<div className="scene-operation-status scene-operation-status--error" role="status">
<span>{pointColorLoad.message}</span>
</div>
) : null}
</div>
) : null}
</>}
navigationReady={presentedViewerStatus==='ready'} timeline={<> {!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
<ObservationTimeline
active={presentedViewerStatus === "ready"}
sourceCount={Math.max(1, (unifiedPerception ? 2 : 1) + presentedMediaSourceCount)}
mode={recordedSource && playbackState?.rangeNs
? "recorded"
: timeline?.mode}
seekable={recordedSource && playbackState?.rangeNs
? true
: timeline?.seekable}
synchronization={timeline?.synchronization}
rangeNs={playbackState?.rangeNs}
currentNs={playbackState?.currentNs}
playing={playbackState?.playing}
onSeek={playbackController?.seek}
onPlayingChange={playbackController?.setPlaying}
onJumpToEnd={playbackController?.jumpToEnd}
accumulationSeconds={accumulationSeconds}
onAccumulationChange={onAccumulationChange}
onAccumulationCommit={onAccumulationCommit}
className="scene-timeline"
/>
) : null}
</>}
media={<> {visibleMediaSources.map((source, index) => (
<FloatingObservationWindow
key={source.id}
source={source}
index={index}
count={visibleMediaSources.length}
boundsRef={viewportRef}
rect={observationLayout.windowRects[source.id]}
maximized={observationLayout.maximizedFloatingSourceId === source.id}
active={observationLayout.activeFloatingSourceId === source.id}
hidden={pointCloudFocused || unifiedPerception}
onRectChange={(rect) => observationLayout.setWindowRect(source.id, rect)}
onMaximizedChange={(maximized) =>
observationLayout.setFloatingMaximized(source.id, maximized)}
onActivate={() => observationLayout.activateFloatingSource(source.id)}
playback={recordedSource && playbackState ? {
currentSeconds: playbackState.currentNs / 1_000_000_000,
playing: playbackState.playing,
} : null}
prepareRecorded={!recordedSource || shouldPrepareRecordedSource(source.id)}
recordedSessionGate={recordedSessionGate}
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
onRecordedAdmissionChange={onRecordedAdmissionChange}
onClose={() => {
if (observationLayout.pendingSourceIds.has(source.id)) return;
observationLayout.setFloatingMaximized(source.id, false);
void observationLayout.hideSource(source.id);
}}
/>
))}
{recordedSource ? (
<div className="recorded-session-preloaders" aria-hidden="true">
{mediaSources.filter((source) => (
source.delivery?.kind === "recorded-fmp4-manifest" &&
!observationLayout.visibleSourceIds.has(source.id) &&
shouldPrepareRecordedSource(source.id)
)).map((source) => (
<ObservationMedia
key={source.id}
source={source}
playback={playbackState ? {
currentSeconds: playbackState.currentNs / 1_000_000_000,
playing: false,
} : null}
prepareRecorded
recordedSessionGate="loading"
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
onRecordedAdmissionChange={onRecordedAdmissionChange}
/>
))}
</div>
) : null}</>} footer={ <div className="spatial-contract-strip">
<span><i data-state="ready" />Облако точек</span>
<span><i data-state="ready" />Траектория</span>
<span><i data-state="ready" />Преобразования</span>
<span><i data-state="contract" />Камеры в 3D</span>
<span><i data-state={detections2dActive ? "ready" : "contract"} />Объекты 2D</span>
<span><i data-state={segmentationActive ? "ready" : "contract"} />Сегментация</span>
<span><i data-state={cuboids3dActive ? "ready" : "contract"} />Кубы 3D</span>
<span><i data-state="contract" />Компоновка</span>
</div>}/>;
}
function CameraSourceCard({
source,
focused,
@@ -997,54 +382,6 @@ function TimelineWorkspace({ definition, state }: WorkspaceRendererProps) {
);
}
function MissionWorkspace({ definition }: WorkspaceRendererProps) {
const steps = [
{ id: "01", label: "Аппарат", value: "Не назначен" },
{ id: "02", label: "Зона", value: "Не задана" },
{ id: "03", label: "Маршрут", value: "Черновик · 0 точек" },
{ id: "04", label: "Наблюдение", value: "Облако точек" },
{ id: "05", label: "Завершение", value: "Безопасная остановка" },
];
return (
<div className="standard-workspace mission-workspace">
<WorkspaceLead definition={definition} note="Команды на физический аппарат отключены" />
<div className="mission-layout">
<GlassSurface className="mission-sequence" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">МИССИЯ / ЧЕРНОВИК</span>
<h2>Новая миссия</h2>
</div>
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
</header>
<div className="mission-steps">
{steps.map((step) => (
<button key={step.id} type="button" disabled>
<span>{step.id}</span>
<div><strong>{step.label}</strong><small>{step.value}</small></div>
<Icon name="chevron-right" />
</button>
))}
</div>
</GlassSurface>
<GlassSurface className="mission-summary" padding="lg">
<span className="section-eyebrow">ГОТОВНОСТЬ</span>
<div className="mission-readiness"><strong>0</strong><span>/ 5 блоков</span></div>
<p>Сохранение и отправка станут доступны после подключения исполнителя миссий и проверки безопасности.</p>
<div className="mission-summary__checks">
<span><i />Аппарат</span>
<span><i />Геометрия</span>
<span><i />Связь</span>
<span><i />Безопасность</span>
</div>
<Button variant="primary" width="full" disabled>Сохранить миссию</Button>
</GlassSurface>
</div>
<FeatureInventory definition={definition} />
</div>
);
}
function CatalogWorkspace({ definition }: WorkspaceRendererProps) {
const total = definition.groups.reduce((count, group) => count + group.capabilities.length, 0);
const active = definition.groups.reduce(
@@ -1079,9 +416,10 @@ function RecordingsWorkspace(props: WorkspaceRendererProps) {
}
export function WorkspaceRenderer(props: WorkspaceRendererProps) {
const planning = usePlanningTest();
switch (props.definition.kind) {
case "spatial":
return <SpatialWorkspace {...props} />;
return planning.selected ? <PlanningSpatialWorkspace {...props}/> : <SpatialWorkspace {...props} />;
case "recordings":
return <RecordingsWorkspace {...props} />;
case "cameras":
@@ -1093,7 +431,7 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
case "timeline":
return <TimelineWorkspace {...props} />;
case "missions":
return <MissionWorkspace {...props} />;
return <MissionPlannerWorkspace openView={props.navigation.openView} headerToolsHost={props.headerToolsHost} />;
case "vehicles":
return <VehiclesWorkspace createRequest={props.fleetCreateRequest} />;
case "catalog":
@@ -36,6 +36,7 @@ export interface LaboratoryViewAction {
export interface WorkspaceRendererProps {
fleetCreateRequest?: number;
headerToolsHost?: HTMLElement | null;
definition: WorkspaceDefinition;
state: MissionRuntimeState | null;
backendStatus: BackendStatus;
@@ -0,0 +1,107 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Button, ConfirmationModal, GlassSurface, Icon, IconButton, LoadingRegion, SegmentedControl, StatusBadge, WorkspaceWindow, type WorkspaceWindowRect } from '@nodedc/ui-react';
import { usePlanningTest } from '../../core/missions/PlanningTestContext';
import { useMissionPlanner } from '../../core/missions/useMissionPlanner';
import { useRegistrationTest } from '../../core/missions/useRegistrationTest';
import { usePlanningProjects } from '../../core/missions/usePlanningProjects';
import { planningProjectPending, planningProjectStatus } from '../../core/missions/planningProjects';
import { MissionZonePreview } from '../../components/missions/MissionZonePreview';
import { MissionRoutePreview } from '../../components/missions/MissionRoutePreview';
import { RegistrationScene } from '../../components/missions/RegistrationScene';
import { PlanningProjectSettings } from '../../components/missions/PlanningProjectSettings';
import { PlanningProjectResult } from '../../components/missions/PlanningProjectResult';
import { PlanningProjectSelect } from '../../components/missions/PlanningProjectSelect';
import '../../styles/session-overview.css';
import '../../styles/mission-planner.css';
export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id:string)=>void;headerToolsHost?:HTMLElement|null}) {
const live=usePlanningTest(), p=useMissionPlanner(), projects=usePlanningProjects();
const t=useRegistrationTest(p.saved);
const [creating,setCreating]=useState(false), [settingsOpen,setSettingsOpen]=useState(false);
const [mode,setMode]=useState<'scanner'|'recording'>('scanner');
const [view,setView]=useState<'cloud'|'route'>('cloud');
const [starting,setStarting]=useState(false);
const [pending,setPending]=useState<(()=>void)|null>(null);
const bounds=useRef<HTMLDivElement>(null);
const [rect,setRect]=useState<WorkspaceWindowRect>({x:16,y:16,width:390,height:600});
const [maximized,setMaximized]=useState(false);
const project=projects.detail;
const editing=creating||project?.kind==='draft';
const loadedDraft=useRef('');
useEffect(()=>{
if(project?.kind==='draft'&&loadedDraft.current!==project.key) {
loadedDraft.current=project.key; void p.openDraft(project.id); setSettingsOpen(true);
}
},[project?.key]);
const replace=(action:()=>void)=>{
if(editing&&p.dirty&&(p.sessionId||p.name))setPending(()=>action);else action();
};
const choose=(key:string)=>replace(()=>{setCreating(false);setSettingsOpen(false);projects.select(key);});
const newProject=()=>replace(()=>{
projects.select('');p.newDraft();t.setSessionId('');setMode('scanner');setView('cloud');
setCreating(true);setSettingsOpen(true);setMaximized(false);loadedDraft.current='';
});
const start=async()=>{
if(starting)return;setStarting(true);
try {
const draft=p.saved&&!p.dirty?p.saved:await p.save();
if(!draft)return;
if(mode==='scanner') {
const next=await live.begin(draft);
if(next){projects.select('live:'+next.id);openView('local-device');}
} else {
const result=await t.run(draft);
if(result){setCreating(false);projects.select('recorded:'+result.id);setSettingsOpen(true);}
}
projects.refresh();
} finally {setStarting(false);}
};
const tools=<div className="planning-project__header-tools">
<PlanningProjectSelect value={projects.key} items={projects.items} disabled={starting} onChange={choose}
onRemove={async target => {
await projects.remove(target);
if (target.key === projects.key) {
setCreating(false); setSettingsOpen(false); loadedDraft.current = ''; p.newDraft();
}
}} />
<IconButton label="Создать проект" disabled={starting} onClick={newProject}><Icon name="plus"/></IconButton>
<IconButton label="Обновить проекты" loading={projects.loading} disabled={starting} onClick={()=>{projects.refresh();p.refresh();}}><Icon name="refresh"/></IconButton>
<IconButton label="Настройки" aria-pressed={settingsOpen} onClick={()=>setSettingsOpen(v=>!v)}><Icon name="settings"/></IconButton>
</div>;
const zoneControls=<SegmentedControl label="Представление зоны" value={view} onChange={setView} items={[{value:'cloud',label:'Облако'},{value:'route',label:'Маршрут'}]}/>;
return <div className="mission-planner planning-project">
{headerToolsHost?createPortal(tools,headerToolsHost):tools}
<div ref={bounds} className="planning-project__stage" onKeyDown={event=>{
if(event.key==='Escape'&&!event.defaultPrevented&&settingsOpen){event.preventDefault();event.stopPropagation();setSettingsOpen(false);}
}}>
<GlassSurface className="planning-project__viewport session-overview__panel" radius="panel">
{editing?<>
{(view!=='cloud'||!p.source)&&<header className="mission-planner__viewer-head mission-planner__viewer-head--end">{zoneControls}</header>}
{!p.sessionId?<div className="session-overview__empty">Выберите сохранённую запись эталона в настройках.</div>
:p.sourceError||p.sourceChanged?<div className="session-overview__empty" role="alert"><span>{p.sourceError||'Исходная запись изменилась.'}</span><Button onClick={p.retrySource}>Повторить загрузку</Button></div>
:!p.source?<LoadingRegion loading label="Подготовка эталона" className="mission-planner__zone-loading"/>
:view==='cloud'?<MissionZonePreview sessionId={p.sessionId} toolbar={zoneControls}/>:<MissionRoutePreview source={p.source} poses={p.poses}/>}
</>:projects.error?<div className="session-overview__empty" role="alert"><span>{projects.error}</span><Button onClick={projects.refresh}>Повторить</Button></div>
:project?<>
<header className="planning-project__result-header"><span>{project.name}</span><StatusBadge tone={project.result_status==='rejected'?'warning':'neutral'}>{planningProjectStatus(project)}</StatusBadge></header>
{project.scene_url?<RegistrationScene sourceUrl={project.scene_url}/>
:planningProjectPending(project)?<LoadingRegion loading label="Подготовка результата совмещения" className="mission-planner__zone-loading"/>
:<div className="session-overview__empty"><span>В этом проекте нет сохранённого совмещения.</span><small>Исходная запись остаётся в разделе «Данные».</small></div>}
{project.scene_url&&<small className="planning-project__legend">{project.result?.correspondence_colors?'Серый — эталон · цветной — повторный проход · зелёный — точки в пределах 0,5 м':'Серый — эталон · зелёный — повторный проход (ранний формат записи)'}</small>}
</>:projects.loading||projects.key?<LoadingRegion loading label="Загрузка проекта" className="mission-planner__zone-loading"/>
:<div className="session-overview__empty">Выберите совмещённый маршрут или создайте проект кнопкой «+».</div>}
</GlassSurface>
{settingsOpen&&<WorkspaceWindow boundsRef={bounds} rect={rect} onRectChange={setRect} maximized={maximized} onMaximizedChange={setMaximized}
title="Настройки" minWidth={320} minHeight={260} active zIndex={100} onClose={()=>setSettingsOpen(false)} closeLabel="Закрыть настройки"
moveLabel="Переместить настройки" resizeLabel="Изменить размер настроек" maximizeLabel="Развернуть настройки" restoreLabel="Восстановить настройки"
className="planning-project__inspector">
{editing?<PlanningProjectSettings p={p} t={t} mode={mode} setMode={setMode} onStart={()=>void start()} starting={starting}/>
:project?<PlanningProjectResult project={project}/>:<p>Выберите проект или создайте новый кнопкой «+».</p>}
{project?.kind==='live'&&planningProjectPending(project)&&<Button disabled={live.busy} onClick={async()=>{await live.select(project.id);live.resume();openView(project.state==='preparing'||project.state==='waiting'?'local-device':'spatial-scene');}}>Открыть текущий проход</Button>}
{live.error&&editing&&<p role="alert">{live.error}</p>}
</WorkspaceWindow>}
</div>
<ConfirmationModal open={!!pending} title="Закрыть настройки проекта?" description="Несохранённые изменения будут потеряны." confirmLabel="Продолжить" onClose={()=>setPending(null)} onConfirm={()=>{pending?.();setPending(null);}}/>
</div>;
}
@@ -0,0 +1,51 @@
import {useState} from 'react';
import {Button,Checker,Icon,LoadingRegion,RangeControl,SegmentedControl} from '@nodedc/ui-react';
import {SpatialToolbarActions} from '../../../../../packages/spatial-ui/src/SpatialToolbarActions';
import {planningActivity,planningMatchCurrent,planningStatus} from '../../core/missions/planningPresentation';
import {planningTestTerminal,usePlanningTest} from '../../core/missions/PlanningTestContext';
import {PlanningLiveScene} from '../../components/missions/PlanningLiveScene';
import {PlanningSceneToolWindow} from '../../components/missions/PlanningSceneToolWindow';
import {SpatialWorkspace} from '../spatial/SpatialWorkspace';
import type {WorkspaceRendererProps} from '../contracts';
import '../../styles/mission-planner.css';
/** Planning supplies geometry and evidence to the ordinary scene, never replaces its camera/recording owners. */
export function PlanningSpatialWorkspace(props:WorkspaceRendererProps){
const p=usePlanningTest();
const [tool,setTool]=useState<'layers'|'display'|null>(null);
const [view,setView]=useState('3d'),[reset,setReset]=useState(0);
const [layers,setLayers]=useState({reference:true,query:true,trajectory:true,grid:true});
const [pointSize,setPointSize]=useState(1.8);
const t=p.test;if(!t)return null;
const terminal=planningTestTerminal(t),readyMatch=planningMatchCurrent(t,!!p.error);
const status=planningStatus(t,p.error);
const options={...layers,mode:view,point_size:pointSize,reset};
const heightMin=t.scene_height_min_m,heightMax=t.scene_height_max_m;
const heightBounds=typeof heightMin==='number'&&typeof heightMax==='number'&&Number.isFinite(heightMin)&&Number.isFinite(heightMax)&&heightMax>heightMin?{min:heightMin,max:heightMax}:null;
const renderer=t.scene_available?<PlanningLiveScene runId={t.id} options={options} active={t.state==='running'} revision={t.scene_revision} heightBounds={heightBounds}/>:<LoadingRegion loading={t.state==='preparing'} label="Подготовка эталона" className="planning-live__scene"><p>{t.message}</p></LoadingRegion>;
const toolbar=<>
<SegmentedControl label="Вид сцены планирования" value={view} onChange={setView} items={[{value:'top',label:'Сверху'},{value:'3d',label:'3D'}]}/>
<Button size="compact" icon={<Icon name="refresh"/>} onClick={()=>setReset(v=>v+1)}>Сброс вида</Button>
<SpatialToolbarActions activeTool={tool} openLayers={()=>setTool(v=>v==='layers'?null:'layers')} openDisplay={()=>setTool(v=>v==='display'?null:'display')}/>
{t.state==='running'&&t.planning_phase==='lost'&&!t.tracking_established&&<Button size="compact" icon={<Icon name="refresh"/>} loading={p.busy} onClick={()=>void p.retryInitialization()}>Переинициализировать</Button>}
{!terminal&&<Button size="compact" loading={p.busy} onClick={()=>void p.finish()}>Завершить исследование</Button>}
</>;
const footer=<div className="planning-live__summary">
<span>{t.draft.name} · эталон {t.draft.zone.label} · {t.draft.route.length_m.toFixed(1)} м</span>
<span>Серый эталон · цветной новый проход · зелёный совпавшие точки</span>
{t.query_session_id&&<span>Запись прохода сохранена отдельно от расчёта исследования.</span>}
{terminal&&t.result&&<span>Последний расчёт: {t.result.status==='candidate'?'кандидат совмещения':'совпадение не подтверждено'} · {(t.result.overlap*100).toFixed(1)}%.</span>}
</div>;
return <>
<SpatialWorkspace {...props} recordedReplay={null} recordedSessionAdmission={null} visualProfile={{renderer,toolbar,status,footer,activity:planningActivity(t,p.error),
metrics:<><div><span>Пройдено в исследовании</span><strong>{t.distance_m.toFixed(1)} м</strong></div>
<div><span>Точек</span><strong>{(t.query_points??0).toLocaleString('ru-RU')}</strong></div>
<div><span>Совпадение</span><strong>{readyMatch&&t.result?`${(t.result.overlap*100).toFixed(1)}%`:'—'}</strong></div></>,
mediaFallback:<div className="planning-live__camera-note"><Icon name="camera"/><span>Нет активного изображения камеры</span></div>,
tools:boundsRef=>tool&&<PlanningSceneToolWindow boundsRef={boundsRef} title={tool==='layers'?'Слои':'Отображение'} onClose={()=>setTool(null)}>
{tool==='layers'?(['reference','query','trajectory'] as const).map((key,i)=><Checker key={key} checked={layers[key]} label={['Эталон','Новый проход','Траектории'][i]} onChange={value=>setLayers(v=>({...v,[key]:value}))}/>)
:<><RangeControl label="Размер точки" value={pointSize} min={.5} max={12} step={.5} exactValueBounds={{min:.5,max:12}} onChange={setPointSize}/><Checker label="Сетка" checked={layers.grid} onChange={grid=>setLayers(v=>({...v,grid}))}/></>}
</PlanningSceneToolWindow>,
}}/>
</>;
}
@@ -0,0 +1,63 @@
import { useState } from "react";
import { Button, GlassSurface, LoadingRegion, SplitPane } from "@nodedc/ui-react";
import { useSessionOverview } from "../../core/observation/useSessionOverview";
import { SessionOverviewScene } from "../../components/observation/SessionOverviewScene";
import { SessionIntervalChart } from "../../components/observation/SessionIntervalChart";
import "../../styles/session-overview.css";
const fmt = (n: number | null | undefined, digits = 0, suffix = "") => n == null || !Number.isFinite(n)
? "—" : `${n.toLocaleString("ru-RU", { maximumFractionDigits: digits })}${suffix}`;
const duration = (n: number | null | undefined) => n == null ? "—" : `${Math.floor(n / 60)} мин ${Math.round(n % 60)} с`;
function initialSize(key: string, fallback: number) {
try { const n = Number(localStorage.getItem(key)); return n >= 25 && n <= 80 ? n : fallback; } catch { return fallback; }
}
function usePaneSize(name: string, fallback: number) {
const key = `missioncore.session-overview.layout.v1.${name}`;
const [size, setSize] = useState(() => initialSize(key, fallback));
return [size, (n: number) => { setSize(n); try { localStorage.setItem(key, String(n)); } catch { /* layout remains usable */ } }] as const;
}
export function SessionOverviewWorkspace({ sessionId }: { sessionId: string }) {
const { data, error, retry } = useSessionOverview(sessionId);
const [top, setTop] = usePaneSize("top", 70);
const [left, setLeft] = usePaneSize("left", 68);
const pending = !error && (!data || data.state === "queued" || data.state === "preparing");
const failure = error || (data?.state === "error" ? data.message : null);
const m = data?.metrics;
const facts: [string, string][] = [
["Начало записи", data?.session.started_at_utc ? new Date(data.session.started_at_utc).toLocaleString("ru-RU") : "—"],
["Длительность сессии", duration(data?.session.duration_seconds)],
["Поток облака", duration(m?.stream_seconds)],
["Кадры облака", fmt(m?.point_frames)],
["Наблюдения точек", fmt(m?.point_count)],
["Положения сканера", fmt(m?.pose_frames)],
["Путь по траектории", fmt(m?.path_m, 2, " м")],
["От старта до финиша", fmt(m?.start_end_m, 2, " м")],
["Средняя частота", fmt(m?.mean_hz, 2, " Гц")],
["Максимальный интервал", fmt(m?.interval_max_s, 3, " с")],
["Интервалы больше секунды", fmt(m?.gaps_over_second)],
["Пропуски номеров", fmt(m?.sequence_gaps)],
["Ошибки чтения кадров", fmt(m?.decode_errors)],
["Объём исходных данных", fmt(data ? data.session.total_bytes / 1_000_000 : null, 1, " МБ")],
];
return <div className="session-overview" aria-label="Информация о записи">
<LoadingRegion loading={pending} label={data?.state === "queued" ? "Ожидание подготовки обзора" : "Подготовка обзора записи"} className="session-overview__loading">
{failure ? <div className="session-overview__empty" role="alert"><span>{failure}</span><Button onClick={retry}>Повторить</Button></div> : pending ? null : <SplitPane orientation="horizontal" primarySize={top} onPrimarySizeChange={setTop} minPrimarySize={35} minSecondarySize={20}
separatorLabel="Высота графика интервалов" className="session-overview__split"
primary={<SplitPane primarySize={left} onPrimarySizeChange={setLeft} minPrimarySize={35} minSecondarySize={25} separatorLabel="Ширина облака и сводки" className="session-overview__split"
primary={<GlassSurface className="session-overview__panel" radius="panel">
{m?.spatial_available && data?.scene_url ? <SessionOverviewScene sourceUrl={data.scene_url} /> : <><h2>Облако и траектория</h2><div className="session-overview__empty">Пространственный обзор для этой записи недоступен.</div></>}
{!m?.spatial_available && <span className="session-overview__note">Доступны сведения из каталога записи.</span>}
</GlassSurface>}
secondary={<GlassSurface className="session-overview__panel" radius="panel">
<h2>Сведения о записи</h2><div className="session-overview__facts">
<dl>{facts.map(([name, value]) => <div key={name}><dt>{name}</dt><dd>{value}</dd></div>)}</dl>
<p>Наблюдения точек включают повторные измерения. Длина пути и положения получены из записи и не являются независимой проверкой точности.</p>
</div>
</GlassSurface>} />}
secondary={<GlassSurface className="session-overview__panel" radius="panel"><h2>Интервалы поступления кадров</h2>
<SessionIntervalChart chart={m?.chart ?? []} bucketSeconds={m?.chart_bucket_seconds ?? 1} />
</GlassSurface>} />}
</LoadingRegion>
</div>;
}
@@ -0,0 +1,642 @@
import {SpatialScene, EmptySpatialStage} from '../../../../../packages/spatial-ui/src';
import {SpatialToolbarActions} from "../../../../../packages/spatial-ui/src/SpatialToolbarActions";
import { type ReactNode, type RefObject, useCallback, useEffect, useRef, useState } from "react";
import { Button, Icon, IconButton } from "@nodedc/ui-react";
import {
ObservationMedia,
ObservationSourcePicker,
} from "../../components/ObservationSources";
import { ObservationTimeline } from "../../components/ObservationTimeline";
import { FloatingObservationWindow } from "../../components/FloatingObservationWindow";
import type {
RecordedAdmissionPhase,
RecordedCameraAdmissionState,
} from "../../core/observation/recordedSessionAdmission";
import { liveRerunRecoveryAuthorityIdentity } from "../../core/observation/liveReceiverWatchdog";
import { liveAcquisitionRerunProfile, recordedSessionRerunProfile } from "../../core/observation/viewerProfile";
import {
RerunViewport,
isRecordedPlaybackPresentationReady,
rerunPresentationStatus,
type RerunPlaybackController,
type RerunPlaybackState,
type RerunSelection,
type RerunViewportStatus,
type RecordedPerceptionLoadState,
type RecordedPointColorLoadState,
} from "../../components/RerunViewport";
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../../presentation";
import type { WorkspaceRendererProps } from "../contracts";
export interface SpatialWorkspaceProfile {
renderer: ReactNode;
toolbar: ReactNode;
status: {label: string; tone: 'neutral'|'success'|'warning'|'danger'; message?: string; pulse?: boolean};
metrics: ReactNode;
footer: ReactNode;
sourceControls?: ReactNode;
mediaFallback?: ReactNode;
tools?: (boundsRef: RefObject<HTMLDivElement|null>) => ReactNode;
activity?: import("../../core/device-plugins/contracts").SpatialActivityPresentation;
}
export function SpatialWorkspace({
state,
sourceUrl,
requestedPlaybackSeconds,
recordedReplay,
recordedSessionAdmission,
sceneSettings,
accumulationSeconds,
onAccumulationChange,
onAccumulationCommit,
livePerceptionLayers,
onLivePerceptionLayersChange,
observationLayout,
navigation,
spatialControls,
visualProfile,
}: WorkspaceRendererProps & { visualProfile?: SpatialWorkspaceProfile }) {
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
const [viewerMessage, setViewerMessage] = useState("");
const [selection, setSelection] = useState<RerunSelection | null>(null);
const [playbackState, setPlaybackState] = useState<RerunPlaybackState | null>(null);
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
const lastRequestedPlaybackSeconds = useRef<number | null>(null);
const [recordedViewResetGeneration, setRecordedViewResetGeneration] = useState<0 | 1>(0);
const [followRecordedTrajectory, setFollowRecordedTrajectory] = useState(false);
const [perceptionLoad, setPerceptionLoad] = useState<RecordedPerceptionLoadState>({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
const [perceptionRetryGeneration, setPerceptionRetryGeneration] = useState(0);
const [pointColorLoad, setPointColorLoad] = useState<RecordedPointColorLoadState>({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
const [showDetections2d, setShowDetections2d] = useState(false);
const [showSegmentation, setShowSegmentation] = useState(false);
const [showCuboids3d, setShowCuboids3d] = useState(false);
const recordedSource = !visualProfile && (Boolean(recordedReplay) || /\.rrd(?:$|[?#])/i.test(sourceUrl));
const liveRerunSource = !recordedSource && /^rerun\+https?:\/\//i.test(sourceUrl.trim());
const recordedSessionGate: RecordedAdmissionPhase = recordedSource
? recordedSessionAdmission?.phase ?? "loading"
: "ready";
const recordedPlaybackReady = !recordedSource ||
(recordedSessionGate === "ready" &&
isRecordedPlaybackPresentationReady(viewerStatus, playbackState));
const streamActive = state?.sourceMode === "live" || state?.sourceMode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
// An explicit manual gRPC source has no Mission Core metrics producer. Its
// native Rerun range is therefore the only available activity proof.
const livePresentationActivitySequence = metrics?.publishedFrameCount ??
(liveRerunSource && !streamActive ? 1 : null);
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frameRateHz);
const points = finiteMetric(metrics?.pointCount);
const aiLatency = finiteMetric(metrics?.aiLatencyMs);
const aiFrameRate = finiteMetric(metrics?.aiFrameRateHz);
const observationSources = (state?.observationSources ?? []).filter(source => !visualProfile || source.transport !== "recording");
const pointCloudSource = observationSources.find((source) => source.modality === "point-cloud");
const pointCloudVisible = pointCloudSource
? observationLayout.visibleSourceIds.has(pointCloudSource.id)
: Boolean(sourceUrl.trim());
const mediaSources = observationSources.filter(
(source) => source.capabilities.overlay && source.modality !== "point-cloud",
);
const recordedPerceptionSupported =
recordedSource && perceptionLoad.phase !== "unavailable";
const recordedPerceptionLoading = recordedSource && perceptionLoad.phase === "loading";
const recordedPerceptionEnabled =
showDetections2d || showSegmentation || showCuboids3d;
// The native recorded camera remains the authoritative original. Only 2D
// image-space overlays need Rerun's paired camera/world composition; 3D
// cuboids are added directly to the stable spatial view.
const unifiedPerception = recordedPerceptionSupported &&
(showDetections2d || showSegmentation);
const livePerceptionAvailable = !recordedSource && streamActive;
const detections2dActive = recordedSource
? showDetections2d
: livePerceptionLayers.detections2d;
const segmentationActive = recordedSource
? showSegmentation
: livePerceptionLayers.segmentation;
const cuboids3dActive = recordedSource
? showCuboids3d
: livePerceptionLayers.cuboids3d;
const visibleMediaSources = mediaSources.filter((source) =>
observationLayout.visibleSourceIds.has(source.id) &&
!source.id.startsWith("recorded.perception."),
);
const initialRecordedPlaybackStartSeconds = recordedSource
? mediaSources.reduce<number | undefined>((earliest, source) => {
if (
source.id.startsWith("recorded.perception.") ||
source.delivery?.kind !== "recorded-fmp4-manifest"
) return earliest;
const start = source.delivery.timelineStartSeconds;
return earliest === undefined ? start : Math.min(earliest, start);
}, undefined)
: undefined;
const presentedMediaSourceCount = unifiedPerception ? 0 : visibleMediaSources.length;
const pointCloudFocused = Boolean(
pointCloudSource && observationLayout.focusedSourceId === pointCloudSource.id,
);
useEffect(() => {
if (!pointCloudFocused) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
observationLayout.setFocusedSourceId(null);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [observationLayout.setFocusedSourceId, pointCloudFocused]);
const floatingSourceMaximized = !unifiedPerception &&
Boolean(observationLayout.maximizedFloatingSourceId);
const timeline = state?.observationTimeline;
const viewportRef = useRef<HTMLDivElement>(null);
const intentionalSourceEnd = !recordedSource && state?.sourceMode === "idle" && ["awaiting_external_stop", "stopping", "finalizing", "completed",].includes(state?.acquisition?.state ?? "");
const presentedViewerStatus = intentionalSourceEnd
? "idle"
: rerunPresentationStatus(
viewerStatus,
recordedSessionGate,
recordedSource,
);
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
setViewerStatus(status);
setViewerMessage(message ?? "");
if (recordedSessionAdmission) {
recordedSessionAdmission.reportSpatial(
recordedSessionAdmission.key,
status === "ready" ? "ready" : status === "error" ? "error" : "loading",
);
}
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportSpatial]);
const onRecordedAdmissionChange = useCallback((
sourceId: string,
next: RecordedCameraAdmissionState,
) => {
if (!recordedSessionAdmission) return;
if (next.admissionKey !== recordedSessionAdmission.key) return;
recordedSessionAdmission.reportCamera(recordedSessionAdmission.key, sourceId, next);
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportCamera]);
const shouldPrepareRecordedSource = useCallback((sourceId: string) => {
if (!recordedSessionAdmission) return false;
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
["ready", "error"].includes(recordedSessionAdmission.cameras[sourceId]?.phase ?? "loading");
}, [recordedSessionAdmission]);
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
const onPlaybackChange = useCallback(
(next: RerunPlaybackState | null) => setPlaybackState(next),
[],
);
const onPlaybackControllerChange = useCallback(
(next: RerunPlaybackController | null) => setPlaybackController(next),
[],
);
const onPerceptionLoadChange = useCallback((next: RecordedPerceptionLoadState) => {
setPerceptionLoad(next);
if (next.phase === "unavailable" || next.phase === "error") {
setShowDetections2d(false);
setShowSegmentation(false);
setShowCuboids3d(false);
}
}, []);
const onPointColorLoadChange = useCallback((next: RecordedPointColorLoadState) => {
setPointColorLoad(next);
}, []);
useEffect(() => {
setPerceptionLoad({
phase: recordedSource ? "loading" : "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: recordedSource ? "Ожидаем канал AI-слоёв." : "",
});
setPerceptionRetryGeneration(0);
setShowDetections2d(false);
setShowSegmentation(false);
setShowCuboids3d(false);
setRecordedViewResetGeneration(0);
setFollowRecordedTrajectory(false);
setPointColorLoad({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
}, [recordedSource, sourceUrl]);
useEffect(() => {
if (pointCloudVisible && sourceUrl.trim()) return;
setViewerStatus("idle");
setViewerMessage("");
setSelection(null);
setPlaybackState(null);
setPlaybackController(null);
setPerceptionLoad({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
setShowDetections2d(false);
setShowSegmentation(false);
setShowCuboids3d(false);
setFollowRecordedTrajectory(false);
setPointColorLoad({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
}, [pointCloudVisible, sourceUrl]);
useEffect(() => {
lastRequestedPlaybackSeconds.current = null;
}, [sourceUrl]);
useEffect(() => {
if (
!recordedSource
|| !playbackController
|| requestedPlaybackSeconds === null
|| requestedPlaybackSeconds === undefined
|| !Number.isFinite(requestedPlaybackSeconds)
|| lastRequestedPlaybackSeconds.current === requestedPlaybackSeconds
) return;
lastRequestedPlaybackSeconds.current = requestedPlaybackSeconds;
playbackController.setPlaying(false);
playbackController.seek(Math.round(requestedPlaybackSeconds * 1_000_000_000));
}, [playbackController, recordedSource, requestedPlaybackSeconds]);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
const publishViewportSize = () => {
const bounds = viewport.getBoundingClientRect();
if (bounds.width < 1 || bounds.height < 1) return;
observationLayout.setViewportSize({
width: bounds.width,
height: bounds.height,
});
};
publishViewportSize();
const observer = new ResizeObserver(publishViewportSize);
observer.observe(viewport);
return () => observer.disconnect();
}, [observationLayout.setViewportSize]);
const viewerStatusLabel = {
idle: intentionalSourceEnd ? "Источник отключён" : "Источник не назначен",
loading: "Подключение",
ready: "Визуализатор готов",
error: "Ошибка источника",
}[presentedViewerStatus];
const viewerStatusTone = presentedViewerStatus === "ready"
? "success"
: presentedViewerStatus === "error"
? "danger"
: "neutral";
const rerunViewerProfile = recordedSource
? recordedSessionRerunProfile({
sourceUrl,
artifact: recordedReplay,
autoplayWhenReady: true,
presentationGate: recordedSessionGate,
expectedTimelineStartSeconds: state?.observationTimeline?.range?.startSeconds,
expectedTimelineEndSeconds: state?.observationTimeline?.range?.endSeconds,
initialPlaybackStartSeconds: initialRecordedPlaybackStartSeconds,
view: "spatial",
viewResetGeneration: recordedViewResetGeneration,
followTrajectory: followRecordedTrajectory,
perceptionLayers: {
enabled: recordedPerceptionSupported && recordedPerceptionEnabled,
detections2d: showDetections2d,
segmentation: showSegmentation,
cuboids3d: showCuboids3d,
},
perceptionRetryGeneration,
lockPerceptionCameraInteraction: unifiedPerception,
})
: liveAcquisitionRerunProfile({
sourceUrl,
liveActivitySequence: livePresentationActivitySequence,
liveStreamId: state?.spatialSource?.id ?? null,
liveRecoveryAuthorityIdentity: streamActive
? liveRerunRecoveryAuthorityIdentity(pointCloudSource, state?.spatialSource)
: null,
});
return <SpatialScene viewportRef={viewportRef} focused={pointCloudFocused||floatingSourceMaximized}
primaryFocused={pointCloudFocused} mediaMaximized={floatingSourceMaximized}
toolbar={visualProfile?.toolbar ?? <> {recordedPerceptionSupported || livePerceptionAvailable ? (
<div className="spatial-toolbar__view-switch" role="group" aria-label="Слои распознавания сцены">
<Button
size="compact"
variant="primary"
icon={<Icon name="video" />}
aria-pressed="true"
disabled
>
Оригинал
</Button>
<Button
size="compact"
variant={detections2dActive ? "primary" : "secondary"}
icon={<Icon name="target" />}
aria-pressed={detections2dActive}
disabled={recordedPerceptionLoading}
onClick={() => recordedSource
? setShowDetections2d((current) => !current)
: onLivePerceptionLayersChange({
...livePerceptionLayers,
detections2d: !livePerceptionLayers.detections2d,
})}
>
Объекты 2D
</Button>
<Button
size="compact"
variant={segmentationActive ? "primary" : "secondary"}
icon={<Icon name="image" />}
aria-pressed={segmentationActive}
disabled={recordedPerceptionLoading}
onClick={() => recordedSource
? setShowSegmentation((current) => !current)
: onLivePerceptionLayersChange({
...livePerceptionLayers,
segmentation: !livePerceptionLayers.segmentation,
})}
>
Сегментация
</Button>
<Button
size="compact"
variant={cuboids3dActive ? "primary" : "secondary"}
icon={<Icon name="apps" />}
aria-pressed={cuboids3dActive}
disabled={recordedPerceptionLoading}
onClick={() => recordedSource
? setShowCuboids3d((current) => !current)
: onLivePerceptionLayersChange({
...livePerceptionLayers,
cuboids3d: !livePerceptionLayers.cuboids3d,
})}
>
Кубы 3D
</Button>
</div>
) : null}
{recordedSource && presentedViewerStatus === "ready" ? (
<Button
size="compact"
variant={followRecordedTrajectory ? "primary" : "secondary"}
icon={<Icon name="target" />}
aria-pressed={followRecordedTrajectory}
title="Удерживать orbital-пивот на риге до повторного нажатия"
onClick={() => setFollowRecordedTrajectory((current) => !current)}
>
Следовать
</Button>
) : null}
{recordedSource && presentedViewerStatus === "ready" ? (
<Button
size="compact"
variant="secondary"
icon={<Icon name="refresh" />}
onClick={() => setRecordedViewResetGeneration((current) => current === 0 ? 1 : 0)}
>
Сброс вида
</Button>
) : null}
<SpatialToolbarActions openLayers={navigation.openLayers} openDisplay={navigation.openDisplay}/></>} renderer={visualProfile?.renderer ?? <> {sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
<RerunViewport
profile={rerunViewerProfile}
sceneSettings={sceneSettings}
onPerceptionLoadChange={onPerceptionLoadChange}
onPointColorLoadChange={onPointColorLoadChange}
onStatusChange={onStatusChange}
onSelectionChange={onSelectionChange}
onPlaybackChange={onPlaybackChange}
onPlaybackControllerChange={onPlaybackControllerChange}
/>
) : (
<EmptySpatialStage settings={sceneSettings} />
)}
</>}
deviceControls={spatialControls&&!recordedSource?( <spatialControls.View
model={spatialControls.model}
spatialActivity={visualProfile?.activity}
host={{
openSpatialScene: () => navigation.openView("spatial-scene"),
activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource,
}}
/>):null}
sourceControls={visualProfile?.sourceControls ?? <> {pointCloudFocused ? (
<IconButton
className="scene-focus-exit"
label="Выйти из полноэкранного режима облака точек"
onClick={() => observationLayout.setFocusedSourceId(null)}
>
<Icon name="minimize" size={16} />
</IconButton>
) : !floatingSourceMaximized ? (
<div className="scene-source-controls">
<ObservationSourcePicker
sources={unifiedPerception
? observationSources.filter((source) => source.modality === "point-cloud")
: observationSources}
visibleSourceIds={observationLayout.visibleSourceIds}
pendingSourceIds={observationLayout.pendingSourceIds}
onToggle={observationLayout.toggleSource}
/>
{pointCloudSource?.capabilities.fullscreen && pointCloudVisible && (visualProfile || sourceUrl.trim()) ? (
<IconButton
className="scene-source-control"
label="Развернуть облако точек"
onClick={() => observationLayout.setFocusedSourceId(pointCloudSource.id)}
>
<Icon name="expand" size={16} />
</IconButton>
) : null}
</div>
) : null}
</>}
status={visualProfile?.status ?? {label:viewerStatusLabel,tone:viewerStatusTone,message:!intentionalSourceEnd?viewerMessage:undefined}}
metrics={visualProfile?.metrics ?? <> <div>
<span>КАДР/С</span>
<strong>{formatNumber(frameRate)}</strong>
</div>
<div>
<span>Точек</span>
<strong>{points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}</strong>
</div>
<div>
<span>До публикации</span>
<strong>{formatNumber(latency)}<small> мс</small></strong>
</div>
{streamActive ? (
<div>
<span>AI</span>
<strong>
{aiLatency === null ? "—" : formatNumber(aiLatency)}
<small>{aiLatency === null ? "" : " мс"}</small>
{aiFrameRate === null ? null : <small> · {formatNumber(aiFrameRate)} Гц</small>}
</strong>
</div>
) : null}</>} overlays={<> {!visualProfile && !pointCloudFocused && !floatingSourceMaximized && state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
<div className="scene-adapter-note">
<Icon name="alert" />
<span>
Локальный поток <strong>{sourceModeLabel(state.sourceMode).toLocaleLowerCase("ru-RU")}</strong> активен,
Rerun-мост запускается и опубликует адрес автоматически.
</span>
</div>
) : null}
{!pointCloudFocused && !floatingSourceMaximized && selection ? (
<div className="scene-selection">
<span>Выбрано</span>
<strong>{selection.entityPath}</strong>
{selection.viewName ? <small>{selection.viewName}</small> : null}
</div>
) : null}
{!floatingSourceMaximized && recordedSource && (
perceptionLoad.phase === "loading" ||
perceptionLoad.phase === "error" ||
pointColorLoad.phase === "loading" ||
pointColorLoad.phase === "error"
) ? (
<div className="scene-operation-status-stack">
{perceptionLoad.phase === "loading" ? (
<div className="scene-operation-status" role="status">
<span className="busy-indicator" aria-hidden="true" />
<span>{perceptionLoad.message || "Готовим AI-слои."}</span>
</div>
) : null}
{perceptionLoad.phase === "error" ? (
<button
type="button"
className="scene-operation-status scene-operation-status--action"
onClick={() => setPerceptionRetryGeneration((generation) => generation + 1)}
>
<Icon name="refresh" size={12} />
<span>Повторить AI</span>
</button>
) : null}
{pointColorLoad.phase === "loading" ? (
<div className="scene-operation-status" role="status">
<span className="busy-indicator" aria-hidden="true" />
<span>{pointColorLoad.message}</span>
</div>
) : null}
{pointColorLoad.phase === "error" ? (
<div className="scene-operation-status scene-operation-status--error" role="status">
<span>{pointColorLoad.message}</span>
</div>
) : null}
</div>
) : null}
</>}
navigationReady={visualProfile ? false : presentedViewerStatus==='ready'} timeline={visualProfile ? null : <> {!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
<ObservationTimeline
active={presentedViewerStatus === "ready"}
sourceCount={Math.max(1, (unifiedPerception ? 2 : 1) + presentedMediaSourceCount)}
mode={recordedSource && playbackState?.rangeNs
? "recorded"
: timeline?.mode}
seekable={recordedSource && playbackState?.rangeNs
? true
: timeline?.seekable}
synchronization={timeline?.synchronization}
rangeNs={playbackState?.rangeNs}
currentNs={playbackState?.currentNs}
playing={playbackState?.playing}
onSeek={playbackController?.seek}
onPlayingChange={playbackController?.setPlaying}
onJumpToEnd={playbackController?.jumpToEnd}
accumulationSeconds={accumulationSeconds}
onAccumulationChange={onAccumulationChange}
onAccumulationCommit={onAccumulationCommit}
className="scene-timeline"
/>
) : null}
</>}
media={<>{visibleMediaSources.length === 0 ? visualProfile?.mediaFallback : null} {visibleMediaSources.map((source, index) => (
<FloatingObservationWindow
key={source.id}
source={source}
index={index}
count={visibleMediaSources.length}
boundsRef={viewportRef}
rect={observationLayout.windowRects[source.id]}
maximized={observationLayout.maximizedFloatingSourceId === source.id}
active={observationLayout.activeFloatingSourceId === source.id}
hidden={pointCloudFocused || unifiedPerception}
onRectChange={(rect) => observationLayout.setWindowRect(source.id, rect)}
onMaximizedChange={(maximized) =>
observationLayout.setFloatingMaximized(source.id, maximized)}
onActivate={() => observationLayout.activateFloatingSource(source.id)}
playback={recordedSource && playbackState ? {
currentSeconds: playbackState.currentNs / 1_000_000_000,
playing: playbackState.playing,
} : null}
prepareRecorded={!recordedSource || shouldPrepareRecordedSource(source.id)}
recordedSessionGate={recordedSessionGate}
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
onRecordedAdmissionChange={onRecordedAdmissionChange}
onClose={() => {
if (observationLayout.pendingSourceIds.has(source.id)) return;
observationLayout.setFloatingMaximized(source.id, false);
void observationLayout.hideSource(source.id);
}}
/>
))}
{visualProfile?.tools?.(viewportRef)}
{recordedSource ? (
<div className="recorded-session-preloaders" aria-hidden="true">
{mediaSources.filter((source) => (
source.delivery?.kind === "recorded-fmp4-manifest" &&
!observationLayout.visibleSourceIds.has(source.id) &&
shouldPrepareRecordedSource(source.id)
)).map((source) => (
<ObservationMedia
key={source.id}
source={source}
playback={playbackState ? {
currentSeconds: playbackState.currentNs / 1_000_000_000,
playing: false,
} : null}
prepareRecorded
recordedSessionGate="loading"
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
onRecordedAdmissionChange={onRecordedAdmissionChange}
/>
))}
</div>
) : null}</>} footer={visualProfile?.footer ?? <div className="spatial-contract-strip">
<span><i data-state="ready" />Облако точек</span>
<span><i data-state="ready" />Траектория</span>
<span><i data-state="ready" />Преобразования</span>
<span><i data-state="contract" />Камеры в 3D</span>
<span><i data-state={detections2dActive ? "ready" : "contract"} />Объекты 2D</span>
<span><i data-state={segmentationActive ? "ready" : "contract"} />Сегментация</span>
<span><i data-state={cuboids3dActive ? "ready" : "contract"} />Кубы 3D</span>
<span><i data-state="contract" />Компоновка</span>
</div>}/>;
}
@@ -7673,6 +7673,26 @@ test("spatial controls explain the bounded K1 calibration wait before point data
}
});
test("planning status extends only its current authoritative acquiring session", () => {
const state = runtimeState();
const spatialActivity = {sessionId: "data-session-001", label: "Привязка к эталону", detail: "Ожидание на месте.", busy: true};
const render = (current, activity = spatialActivity, controllerPatch = {}) => renderToStaticMarkup(createElement(K1SpatialControlsView, {
controller: {...acquisitionController(current), ...controllerPatch}, spatialActivity: activity,
}));
assert.match(render(state), /Привязка к эталону/);
assert.match(render(state), /Ожидание на месте/);
assert.doesNotMatch(render(state, {...spatialActivity, sessionId: "other-capture"}), /Привязка к эталону/);
for (const acquisitionState of ["awaiting_external_start", "starting", "awaiting_external_stop", "stopping", "finalizing"]) {
const current = structuredClone(state); current.acquisition.state = acquisitionState;
assert.doesNotMatch(render(current), /Привязка к эталону/);
}
const lost = structuredClone(state); lost.connection_supervisor = supervisor({control: true, data: false, dataPlaneState: "stalled"});
assert.doesNotMatch(render(lost), /Привязка к эталону/);
assert.doesNotMatch(render(state, spatialActivity, {physicalStopInFlight: true}), /Привязка к эталону/);
const cleanup = structuredClone(state); cleanup.acquisition.cleanup_pending = true;
assert.doesNotMatch(render(cleanup), /Привязка к эталону/);
});
test("contour health never promotes selection or replay metrics to live authority", () => {
const selectedOnly = {
phase: "starting",
@@ -804,7 +804,7 @@ test("E40 reports historical visible evaluation with bounded camera-LiDAR case r
});
test("the primary point-cloud viewer restores from fullscreen on Escape", async () => {
const workspacesSource = await readFile(workspacesUrl, "utf8");
const workspacesSource = await readFile(new URL("../src/workspaces/spatial/SpatialWorkspace.tsx", import.meta.url), "utf8");
assert.match(workspacesSource, /if \(!pointCloudFocused\) return;/);
assert.match(workspacesSource, /event\.key !== "Escape"/);
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import { before, after, test } from 'node:test';
import { createServer } from 'vite';
let server, api;
before(async () => { server = await createServer({ appType: 'custom', logLevel: 'silent', server: { middlewareMode: true } }); api = await server.ssrLoadModule('/src/core/missions/planner.ts'); });
after(async () => { await server?.close(); });
const source = { schema_version:'missioncore.planning-source/v1', session_id:'A', generation:'a'.repeat(64), units:'m', poses:Array.from({length:5}, (_, i) => ({index:i, position:[i*3,i*4,0], distance_m:i*5})) };
test('route reverses source order without changing geometry or source', () => {
const result = api.selectedPoses(source, 1, 3, 'reverse');
assert.deepEqual(result.map(p => p.index), [3,2,1]); assert.equal(api.routeLength(result), 10); assert.equal(source.poses[0].index,0);
});
test('bounded section uses travelled distance, including out-and-back', () => {
assert.equal(api.endAtDistance(source, 1, 7),3); assert.equal(api.endAtDistance(source, 1, 30),4); assert.deepEqual(api.selectedPoses(source,4,2,'forward'),[]);
});
test('catalog excludes derived LAB parent and nonspatial sessions from zone binding', () => {
const item={replayable:true,modalities:['point-cloud','trajectory']}; assert.equal(api.canSelectSession(item),true);
assert.equal(api.canSelectSession({...item,lab:{}}),false); assert.equal(api.canSelectSession({...item,modalities:['video']}),false);
});
test('source contract rejects a different session and nonfinite coordinates', () => {
assert.equal(api.validatePlanningSource(source,'A'),source); assert.throws(() => api.validatePlanningSource(source,'B'));
assert.throws(() => api.validatePlanningSource({...source,poses:[{index:0,position:[0,0,NaN],distance_m:0},source.poses[1]]},'A'));
});
test('meter input selects the correct ordered pose and respects the selected branch', () => {
assert.equal(api.indexAtDistance(source, 10), 2);
assert.equal(api.indexAtDistance(source, 10, 3), 3);
assert.equal(api.indexAtDistance(source, 100), 4);
});
test('live distance has no upper cap and endpoint selection does not overshoot typed metres', () => {
for (const n of [3, 30, 50, 100, 200, 300, 10000]) assert.equal(api.canStartPlanningRoute(n, 'scanner'), true);
for (const n of [2.9, NaN, Infinity]) assert.equal(api.canStartPlanningRoute(n, 'scanner'), false);
assert.equal(api.canStartPlanningRoute(50, 'recording'), false);
assert.equal(api.indexAtDistance(source, 9.9, 1, 4, true), 1);
assert.equal(api.indexAtDistance(source, 10, 1, 4, true), 2);
assert.equal(api.indexAtDistance(source, 1, 1, 4, true), 1);
});
test('planning match never promotes stopped or stale evidence', async()=>{
const {planningMatchCurrent}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
const t={state:'running',tracking_state:'tracking',stale:false,frame_age_s:0,result_age_s:2,result:{status:'candidate'}};
assert.equal(planningMatchCurrent(t),true);
for(const patch of [{state:'completed'},{state:'waiting'},{tracking_state:'acquiring'},{tracking_state:'lost'},{tracking_state:undefined},{stale:true},{frame_age_s:8},{result_age_s:8},{result_age_s:null},{result:{status:'rejected'}}])assert.equal(planningMatchCurrent({...t,...patch}),false);
assert.equal(planningMatchCurrent(t,true),false);
});
test('planning failure is visible even when its last sample is stale', async()=>{
const {planningStatus}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
const t={state:'error',message:'Разрыв координат',stale:true,frame_age_s:null,result_age_s:null,result:null};
assert.deepEqual(planningStatus(t),{label:'Совмещение остановлено',tone:'danger',message:'Разрыв координат',pulse:true});
assert.equal(planningStatus({...t,state:'waiting'}).label,'Ожидание данных');
});
test('planning activity continues scanner preparation without presenting a prior as tracking', async()=>{
const {planningStatus,planningActivity}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
const t={state:'running',query_session_id:'B',tracking_state:'acquiring',stale:false,frame_age_s:0,result_age_s:null,result:null,message:'Ожидание на месте.'};
assert.equal(planningActivity({...t,planning_phase:'waiting-cloud'}),undefined);
for(const [planning_phase,label] of [['collecting','Накопление данных'],['searching','Поиск положения на маршруте'],['refreshing','Подтверждение привязки'],['validating','Подтверждение привязки']]){
assert.deepEqual(planningActivity({...t,planning_phase}),{sessionId:'B',label,detail:'',busy:true});
assert.equal(planningStatus({...t,planning_phase}).tone,'neutral');
}
const live={...t,planning_phase:'tracking',tracking_state:'tracking',result_age_s:0,result:{status:'candidate'},message:'Можно начинать проверочный проход.'};
assert.equal(planningActivity(live).busy,false);
assert.equal(planningStatus(live).tone,'success');
const stale=planningStatus({...live,frame_age_s:9});
assert.equal(stale.label,'Привязка потеряна');assert.doesNotMatch(stale.message,/Можно начинать/);
assert.equal(planningActivity({...t,planning_phase:'searching'},'Нет связи').busy,false);
assert.equal(planningActivity({...live,query_session_id:null}),undefined);
assert.equal(planningActivity({...live,state:'completed'}),undefined);
const recovery=planningStatus({...t,planning_phase:'recovering',tracking_established:true});
assert.equal(recovery.pulse,true);
assert.equal(recovery.tone,'danger');
assert.match(recovery.message,/запись продолжается/);
});
test('project archive restores exact run identity and never promotes a failed live probe', async()=>{
const {defaultPlanningProject,planningProjectStatus}=await server.ssrLoadModule('/src/core/missions/planningProjects.ts');
const items=[{key:'live:failed',kind:'live',state:'error',result_status:null},{key:'recorded:second',kind:'recorded',state:'ready',result_status:'candidate'},{key:'recorded:first',kind:'recorded',state:'ready',result_status:'candidate'}];
assert.equal(defaultPlanningProject(items,null),'recorded:second');
assert.equal(defaultPlanningProject(items,'recorded:first'),'recorded:first');
assert.equal(defaultPlanningProject(items,'missing'),'recorded:second');
assert.equal(planningProjectStatus(items[0]),'Без результата совмещения');
assert.equal(defaultPlanningProject([],null),'');
});
test('failed initial binding never claims previously established tracking was lost', async()=>{
const {planningStatus,planningActivity}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
const t={state:'running',query_session_id:'B',planning_phase:'lost',tracking_state:'lost',tracking_established:false,
stale:true,frame_age_s:null,result_age_s:null,result:null,message:'Начальный поиск не завершён.'};
const failed=planningStatus(t);
assert.equal(failed.label,'Маршрут не синхронизирован');
assert.equal(failed.message,'Начальный поиск не завершён.');
assert.deepEqual(planningActivity(t),{sessionId:'B',label:failed.label,detail:failed.message,busy:false});
assert.equal(planningStatus({...t,tracking_established:true}).label,'Привязка потеряна');
assert.equal(planningStatus({...t,state:'completed'}).label,'Исследование завершено');
});
test('freshness of displayed alignment fences green independently of the fit age',async()=>{
const {planningMatchCurrent,planningStatus}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
const t={state:'running',tracking_state:'tracking',planning_phase:'tracking',tracking_established:true,
stale:false,frame_age_s:.2,result_age_s:4,result:{status:'candidate'},message:'Привязка подтверждена.',presentation_state:'live'};
assert.equal(planningMatchCurrent(t),true);
assert.equal(planningMatchCurrent({...t,presentation_state:'historical'}),false);
assert.equal(planningMatchCurrent({...t,frame_age_s:2.1}),false);
assert.match(planningStatus({...t,presentation_state:'historical'}).message,/последней принятой/);
assert.match(planningStatus({...t,state:'completed',presentation_state:'historical'}).message,/не текущее положение/);
});
@@ -1066,7 +1066,7 @@ test("recording preparation never presents phase heartbeats as fake percentages"
test("unified recorded AI view keeps the raw camera mounted but does not cover overlays", async () => {
const workspaceSource = await readFile(
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
new URL("../src/workspaces/spatial/SpatialWorkspace.tsx", import.meta.url),
"utf8",
);
@@ -27,7 +27,7 @@ async function read(relativePath) {
test("Observatory is the third independent Polygon workspace", () => {
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.deepEqual(
productModel.workspaceById("observatory"),
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {readFileSync} from 'node:fs';
test('planning live scene reuses the canonical vertical height range inside the existing stage',()=>{
const source=readFileSync(new URL('../src/components/missions/PlanningLiveScene.tsx',import.meta.url),'utf8');
assert.match(source,/RangeControl orientation="vertical" limitSide="left" label="Срез"/);
assert.match(source,/className="session-overview__height"/);
assert.match(source,/ceiling_m:ceilingRef\.current/);
assert.match(source,/formatLimit=\{value=>value\.toFixed\(1\)\.replace\('\.',','\)\}/);
assert.match(source,/heightBounds=null/);
assert.match(source,/const CLIP_CEILING_M=80/);
assert.match(source,/max:CLIP_CEILING_M/);
assert.match(source,/setBounds\(next\);setCeiling\(null\);/);
});
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import {before,after,test} from 'node:test';
import {createServer} from 'vite';
let server,createReporter;
before(async()=>{
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
({createPlanningPresentationReporter:createReporter}=await server.ssrLoadModule(
'/src/core/missions/planningPresentationTelemetry.ts'));
});
after(async()=>{await server?.close();});
const settle=()=>new Promise(resolve=>setImmediate(resolve));
test('presentation observations batch separately from the scene channel and retain the proxy boundary',async()=>{
let timer,posted;
const reporter=createReporter({url:'/observations',schedule:callback=>{timer=callback;return 1;},cancel:()=>{},
fetcher:async(url,init)=>{posted={url,init};return new Response(null,{status:204});},
});
reporter.record({presentation:'live',cloudAgeMs:200,fitAgeMs:100,requestMs:40,
cloudRevision:3,cloudSequence:4,displayEpoch:'5d8c5d13-dfde-4ab6-9da0-475ab1f22c12'},
{rerunAdmissionMs:2,firstAnimationFrameMs:12,secondAnimationFrameMs:28});
assert.ok(timer);timer();await settle();
assert.equal(posted.url,'/observations');
const body=JSON.parse(posted.init.body);
assert.equal(body.schema_version,'missioncore.planning-browser-presentation/v1');
assert.deepEqual(body.samples,[{cloud_revision:3,cloud_sequence:4,
display_epoch:'5d8c5d13-dfde-4ab6-9da0-475ab1f22c12',request_ms:40,
rerun_admission_ms:2,first_animation_frame_ms:12,second_animation_frame_ms:28,
frame_timeout:false,source_to_second_animation_frame_upper_bound_ms:270}]);
reporter.dispose();
});
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import { before, after, test } from 'node:test';
import { readFile } from 'node:fs/promises';
import { createServer } from 'vite';
let server, api;
before(async () => {
server = await createServer({appType: 'custom', logLevel: 'silent', server: {middlewareMode: true}});
api = await server.ssrLoadModule('/src/core/missions/planningProjects.ts');
});
after(async () => { await server?.close(); });
const item = {key: 'live:a', id: 'a', name: 'same name', kind: 'live', state: 'completed', revision: 2};
test('only terminal runs or drafts admit catalog deletion', () => {
for (const state of ['completed', 'cancelled', 'error', 'interrupted']) assert.equal(api.planningProjectDeletable({...item, state}), true);
for (const state of ['running', 'waiting', 'preparing', 'new-unknown']) assert.equal(api.planningProjectDeletable({...item, state}), false);
assert.equal(api.planningProjectDeletable({...item, kind: 'draft', state: 'draft'}), true);
assert.equal(api.planningProjectDeletable({...item, kind: 'recorded', state: 'ready'}), true);
});
test('delete sends exact kind/id/revision and requires a matching receipt', async t => {
const calls = [];
let receipt = {key: item.key, deleted: true};
t.mock.method(globalThis, 'fetch', async (...args) => { calls.push(args); return {ok: true, json: async () => receipt}; });
await api.deletePlanningProject(item);
assert.equal(calls[0][0], '/api/v1/mission-planner/projects/live/a');
assert.equal(calls[0][1].method, 'DELETE');
assert.deepEqual(JSON.parse(calls[0][1].body), {revision: 2});
receipt = {key: 'live:another-same-name', deleted: true};
await assert.rejects(api.deletePlanningProject(item), /не подтверждено/);
const count = calls.length;
await assert.rejects(api.deletePlanningProject({...item, state: 'running'}), /завершите/);
assert.equal(calls.length, count);
});
test('server refusal stays an error, never a successful deletion', async t => {
t.mock.method(globalThis, 'fetch', async () => ({ok: false, json: async () => ({detail: 'Проект изменён.'})}));
await assert.rejects(api.deletePlanningProject(item), /Проект изменён/);
});
test('UI uses canonical row actions and modal, and stale loads cannot resurrect a deleted item', async () => {
const component = await readFile(new URL('../src/components/missions/PlanningProjectSelect.tsx', import.meta.url), 'utf8');
const hook = await readFile(new URL('../src/core/missions/usePlanningProjects.ts', import.meta.url), 'utf8');
assert.match(component, /<Select/); assert.match(component, /<ConfirmationModal/);
assert.match(component, /disabled: !planningProjectDeletable\(item\)/);
assert.match(component, /await onRemove\(target\); setTarget\(null\)/);
assert.match(component, /<ToastStack/);
assert.match(hook, /removedKeys.current.has\(item.key\)/);
assert.match(hook, /removedKeys.current.has\(key\)/);
assert.match(hook, /if \(selectedKey.current === project.key\) select\(''\)/);
});
@@ -0,0 +1,114 @@
import assert from 'node:assert/strict';
import {before,after,test} from 'node:test';
import {createServer} from 'vite';
import {readFileSync} from 'node:fs';
let server,start;
before(async()=>{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,/<IconButton\s+className="scene-source-control"\s+label="Развернуть облако точек"/);
assert.match(source,/event.key !== "Escape"/);
});
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import {before,after,test} from 'node:test';
import {createServer} from 'vite';
import {createElement} from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import {readFileSync} from 'node:fs';
let server,Tool,Actions;
before(async()=>{
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,/<Window\s|openSource=|openView\('mission-planner'\)/);
assert.match(workspace,/tools:boundsRef=>tool&&<PlanningSceneToolWindow/);
const viewer=readFileSync(new URL('../src/components/missions/PlanningLiveScene.tsx',import.meta.url),'utf8');
assert.match(viewer,/\},\[runId,retry\]\)/); // Presentation never owns viewer lifetime.
});
@@ -324,7 +324,7 @@ test("Polygon exposes one dataset surface and keeps legacy links compatible", ()
assert.equal(workspaceById("datasets").kind, "datasets");
assert.deepEqual(
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(
workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
@@ -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);
@@ -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\) => \(/);
@@ -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(
@@ -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, /<span>\{(?: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; }
});
@@ -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;/);
});
@@ -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 по записи, затем такой же расчёт на живом потоке. Текущая кнопка «Проверить маршрут» проверяет исходные файлы и последовательность пути; она не определяет положение сканера.
@@ -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
+34
View File
@@ -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 2030 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.
@@ -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 2030 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/<UUID>` 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.
@@ -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.
@@ -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:5513: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.
@@ -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 340 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, 130155 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 2030 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.314Z14:24:33.127Z; started monotonic ns `514814164352083`.
- Forward draft `6159d3fa-dda4-4223-acdf-ceeceedefaad`, revision 1, **JA-SADOVAYA · проверка 30 м**. A: `20260911T085226Z_viewer_live`, poses 0577, 30.012 m.
- B: `20260911T134352Z_viewer_live`, poses 0251, 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.
@@ -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] Проверки и сборка прошли.
@@ -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.
@@ -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.0630.351 s | 0.0720.333 s |
| Latest input to completed result | 0.2900.863 s | 0.2880.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.0230.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.956.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.6298.72%, with inlier RMSE
0.1480.179 m. A post-run diagnostic of the rejected transforms found entry-point
corrections around 2.7452.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 130155 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.
+169
View File
@@ -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 130155 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.0680.117 s numerically, 0.2630.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 130155 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.
@@ -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.22613: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.2024230.329659 s; accepted source ages 0.2416671.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 2030 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.
@@ -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 0577, map 0785,
`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.1498.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.2460.517 s; the
latest input age at result admission was 0.3111.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:4364`: 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:233244`: snapshots that same buffer on a separate
500 ms gate. Changing the poll interval does not remove the upstream sampling.
- `missions/live_scene.py:2543`: 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:2542`: 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:218249,358381` 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.
@@ -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.
@@ -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: 0577, 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 9798% 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.00349.009 m interval, poses 339775. 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.1980.466 s; newest input age at
completion approximately 0.2460.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.
@@ -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 046 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.
@@ -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 130160 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 9698%, 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.
@@ -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.
@@ -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 A130155 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.261Z12: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.218Z12: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 | 4447 | 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.176Z12:04:46.942Z;
launcher monotonic interval 837121521919208837164567013166 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 A130155 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.
@@ -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.348Z18: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 0577, map indices 0785
(approximately 050 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 130160 m
and context approximately 110180 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 3442% 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 2030 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.
@@ -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.18224.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
(4849% 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.
@@ -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 340 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,48273,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,48073,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 5054%, 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.
@@ -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
130155 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.52712: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.2242750.328083 s including process overhead; overlaps 98.19899.260%,
inlier surface RMSE 0.1487570.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 130155 m: 12:27:58.10812: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.
@@ -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.77014:04:52.857 UTC. All 108 hypotheses completed in
each final probe. Physical-replay fresh fits took 0.2520.412 seconds; accepted
source ages were 0.2690.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.
@@ -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.
@@ -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.
@@ -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.
@@ -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 01320, length 102.59360280377184 m; context map
indices 01516, `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/<run-id>/`, raw source under
`evidence/sessions/<query-id>/`. 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 11.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.
@@ -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 13 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 13 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 3040 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.
@@ -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.
@@ -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.
@@ -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.
@@ -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 12 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.
@@ -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 12 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.
@@ -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
130160 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 2030 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.
@@ -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 4045 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.270.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.
@@ -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.
+11 -7
View File
@@ -7,22 +7,26 @@ export function SpatialScene({viewportRef, focused, primaryFocused, mediaMaximiz
navigationReady=false}: {
viewportRef: RefObject<HTMLDivElement|null>; 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 <div className="spatial-workspace" data-focused={focused?'true':undefined}>
<div className="spatial-toolbar" data-viewer-controls="v1"><div className="spatial-toolbar__actions">{toolbar}</div></div>
<div ref={viewportRef} className="spatial-viewport-shell" data-primary-focused={primaryFocused?'true':undefined} data-media-maximized={mediaMaximized?'true':undefined}>
{renderer}
{deviceControls&&<div className="scene-device-controls">{deviceControls}</div>}
{sourceControls}
<div className="scene-status scene-status--top-left" aria-hidden={primaryFocused||mediaMaximized}>
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
<StatusBadge tone={status.tone}>{status.label}</StatusBadge>
{status.message?<small>{status.message}</small>:null}
{detailsHidden ? sourceControls : null}
<div className="scene-information" aria-hidden={detailsHidden}>
{!detailsHidden ? sourceControls : null}
<div className="scene-status">
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
<StatusBadge tone={status.tone} pulse={status.pulse}>{status.label}</StatusBadge>
{status.message?<small>{status.message}</small>:null}
</div>
<div className="scene-metrics" aria-label="Метрики пространственной сцены">{metrics}</div>
</div>
<div className="scene-metrics" aria-label="Метрики пространственной сцены" aria-hidden={primaryFocused||mediaMaximized}>{metrics}</div>
{overlays}
{navigationReady&&!mediaMaximized&&<div className="scene-navigation-hint" aria-label="Навигация по 3D-сцене"><span>Колесо · зум к курсору</span><span>WASD · свободный проход</span></div>}
{timeline}{media}
@@ -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 <>
<Button size="compact" variant="secondary" icon={<Icon name="network"/>} onClick={openSource}>Движок</Button>
<Button size="compact" variant="secondary" icon={<Icon name="list"/>} onClick={openLayers}>Слои</Button>
<Button size="compact" variant="secondary" icon={<Icon name="sliders"/>} onClick={openDisplay}>Отображение</Button>
{openSource&&<Button size="compact" variant="secondary" icon={<Icon name="network"/>} onClick={openSource}>Движок</Button>}
<Button size="compact" variant="secondary" icon={<Icon name="list"/>} aria-pressed={activeTool==='layers'} onClick={openLayers}>Слои</Button>
<Button size="compact" variant="secondary" icon={<Icon name="sliders"/>} aria-pressed={activeTool==='display'} onClick={openDisplay}>Отображение</Button>
</>;
}
-14
View File
@@ -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);
}
+27 -7
View File
@@ -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%;
@@ -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 (
<K1SpatialSession phase={phase} telemetry={telemetry}>
<K1SpatialSession phase={presentedPhase} telemetry={telemetry}>
{actionFailure ? (
<div className="xgrids-k1-spatial-controls__error" role="alert">
<strong>{actionFailure.title}</strong>
@@ -240,7 +249,7 @@ export function K1SpatialControlsView({
);
}
export function K1SpatialControls(_props: DevicePluginConnectionProps) {
export function K1SpatialControls(props: DevicePluginConnectionProps) {
const controller = useXgridsK1Controller();
return <K1SpatialControlsView controller={controller} />;
return <K1SpatialControlsView controller={controller} spatialActivity={props.spatialActivity} />;
}
@@ -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}
}

Some files were not shown because too many files have changed in this diff Show More