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:
@@ -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), []) };
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Mission planner: recorded zone and route draft
|
||||
|
||||
Date: 2026-09-11. Ops: MISSIONCOR-81. Canonical service: `http://127.0.0.1:8000`.
|
||||
|
||||
## Delivered boundary
|
||||
|
||||
The existing Missions/Planner workspace now selects a source from the shared observation-session catalog, reads its complete scanner trajectory, selects a contiguous interval in travelled metres and either direction, and persists a versioned draft without a vehicle. The original disabled five-step mock and its placeholder readiness badge are removed. The three blocks are Apparatus, Zone and Route. Parameters stay inside their blocks for this increment; the wider three-panel inspector composition remains a later refinement.
|
||||
|
||||
The cloud view reuses the existing bounded Rerun overview, top/3D and visual height clip. The route view uses an equal-axis XY schematic and a position slider over the selected original pose indices. This is inspection of a recorded scanner path, not measured localization or a certified chassis path. Selected interval and direction are editable; arbitrary waypoints, direct Rerun drawing and timed playback are not implemented.
|
||||
|
||||
## Source and persistence contracts
|
||||
|
||||
- `missions/sources.py`: immutable generation from validated source identity, approved-prefix staging, input digests before/after preparation and source-bound cache. One preparation at a time, maximum 100,000 poses / 30 MiB derived JSON, parser time/message bounds.
|
||||
- The plugin's `planning_source.py` preserves all pose indices, message ordinals, metric positions, elapsed arrival times when available, and cumulative distance. No second pose transform is applied. One decoded nonempty point-cloud frame confirms spatial evidence. Other cloud messages are counted without decoding every point again; this is not a full point-cloud integrity/accuracy audit. Full overview diagnostics remain separate.
|
||||
- `missions/drafts.py`: SQLite transactions and compare-and-swap revisions, frozen source identity, selected source indices and resolved points. Changing/missing source never removes a saved draft.
|
||||
- `web/mission_planner_api.py`: bounded request models; no vehicle ID or execution parameter is accepted. Data checks bind an exact saved revision, reverify source digests, measure length and largest position step, and retain an immutable report. A concurrent draft change rejects publication of a stale report.
|
||||
- `core/missions`, `components/missions`, `workspaces/missions`: separated domain contracts, reusable renderers and workspace composition. Existing Design Guideline Select, RangeControl, TextField, Window and actions are reused.
|
||||
- Raw captures, cached poses, SQLite drafts/checks and real scene media remain runtime data outside Git. Nothing is installed on the board.
|
||||
|
||||
## Validation
|
||||
|
||||
- 846 frontend tests passed before final bounded UX refinements. The subsequent metre-input change passed the five focused planner tests plus four architecture tests. Final production build includes TypeScript checking.
|
||||
- 16 focused backend tests passed (planner + existing overview); 10 planner tests passed again after removing unnecessary cloud decoding.
|
||||
- First real source preparation after the optimization: HTTP 200 in 2.35 seconds for 4,959 poses and a 487.61 m recorded path. This is a local functional measurement, not a board or localization benchmark.
|
||||
- Browser on the actual 8000: selected the existing road session, obtained the initial 30.01 m / 578-pose interval, saved a named draft without a vehicle, ran its data check (matching source digests; largest step about 0.10 m), reloaded the client and reopened it. Reverse direction starts at the selected final source pose. Exact interval entry uses metres, not hidden frame indices.
|
||||
- Another source named `35` loaded independently with 283 poses / 0.089 m, proving the selector is not bound to the road session. Unsupported derived LAB entries remain visible with a reason.
|
||||
- UI QA used the actual narrow in-app browser surface and expanded window, including Escape. Desktop-width rendering is not separately qualified here.
|
||||
- All checks/builds/browser work ran sequentially. No duplicate backend or port 8765 was introduced. Canonical 8000 remains durable operator state.
|
||||
|
||||
## Next experiment
|
||||
|
||||
No new scan is required to inspect the saved draft. Before claiming live testing, implement a bounded geometric-registration runner, prove known synthetic transforms and reject a wrong reference segment. Then acquire an independent B recording in a new K1 project over the same 20–30 m section, retaining both Mission Core session and native capture. Freeze reference A. Compare B windows against A without future B frames, report failures/ambiguity, timing and memory. Only after offline evidence passes should the same pipeline feed a live testing viewport.
|
||||
|
||||
The current “Проверить маршрут” action does not compare clouds, estimate the scanner's location, determine passability, detect obstacles or send commands. The starting-position tolerance and target-board capacity remain experimental questions.
|
||||
@@ -0,0 +1,91 @@
|
||||
# LAB planning profile — 2026-09-11
|
||||
|
||||
Owner decision: move the research planner to LAB, remove the decorative vehicle
|
||||
step, and make Testing lead through the existing K1 connection/naming/start
|
||||
workflow into a planning-specific spatial scene. The ordinary recorded-pair
|
||||
comparison remains a separate action. This is an operator diagnostic instrument,
|
||||
not a mission executor or a new root/navigation system.
|
||||
|
||||
## Operator workflow
|
||||
|
||||
1. LAB → Planner; open a saved reference draft. A remains immutable. Select the
|
||||
reference interval in trajectory metres and its travel direction; save.
|
||||
2. Testing prepares the reference once and opens the canonical Window containing
|
||||
the existing DeviceWorkspace/K1 connection flow. The server owns the frozen
|
||||
draft/revision; its planning profile survives navigation and browser reload.
|
||||
3. Create a **new K1 project** and a separate capture B at the physical entry of
|
||||
the chosen reference interval. Press the existing acquisition/connect action.
|
||||
Its existing host callback opens Spatial Scene with the planning profile.
|
||||
4. Walk 20–30 m once, approximately the same scanner height and direction. Initial
|
||||
position is the operator's entry hypothesis; first ≥3 m displacement supplies
|
||||
yaw. It is not global place recognition or a guarantee of arbitrary-start
|
||||
recovery. A remains separate from B; no LAS or Gaussian processing is needed.
|
||||
5. Gray shows reference geometry. New query geometry uses a blue-to-magenta
|
||||
height palette. Only accepted per-point nearest-reference distances ≤0.5 m
|
||||
become green. GICP candidate rejection gates remain fixed v1. Live green is
|
||||
removed after 8 s sample age, stopped input, changed identity, or termination.
|
||||
6. Device and recording controls retain the existing K1 stop/finalization path.
|
||||
Finishing the *research* only releases its derived-data lease; it cannot stop
|
||||
capture or a vehicle. The last completed calculation is retained for review.
|
||||
|
||||
## Architecture and resource bounds
|
||||
|
||||
- `sessions/live_planning.py` is the normalized read-only input contract. K1's
|
||||
`planning_live.py` adapts its **existing committed-evidence ingress**; no second
|
||||
MQTT receiver, device command, network scan, or raw-capture owner is introduced.
|
||||
Both verified current and legacy protocol decoders are reused. Published K1
|
||||
map-space points are not transformed by pose a second time.
|
||||
- Planning occupies the existing exclusive derived-data consumer lease. An AI
|
||||
worker already owning it yields an explicit busy response; neither profile
|
||||
silently steals the other's lease. No inference service is started or stopped.
|
||||
- `missions/live_tests.py` captures baseline generation while idle, requires a
|
||||
new session ID/generation, rejects reuse of A/prior capture, and fences every
|
||||
sample/result. A changed producer session terminates the research. Independent
|
||||
project/SLAM reset remains an operator requirement (`slam_reset_verified=false`).
|
||||
- Reference preparation uses existing immutable source validation and submap
|
||||
extraction (≤40 m, ≤100,000 points, ≤90 s). Native GICP uses the same isolated
|
||||
short-lived worker and one shared compute lock as offline comparison.
|
||||
- Live preview samples at most 2 cloud frames/s, requires a preceding pose no
|
||||
older than 0.5 s, crops within 20 m and relative height −3…+6 m, voxelizes at
|
||||
0.25 m, retains 40 frames × ≤4,000 points, then bounds the fit to 40,000 points.
|
||||
Original recording is unaffected by preview thinning or queue overflow.
|
||||
- Fit requests are at least 5 s apart, one active numerical child, 30 s deadline.
|
||||
Capture ingestion continues while fitting; no future query frames enter a job.
|
||||
Research is capped at 40 m or 300 s after session admission, waiting at 30 min.
|
||||
Local synthetic checks are bounded functionality tests, **not load tests** or
|
||||
board resource acceptance.
|
||||
- Rerun uses one isolated viewer/channel. Static reference is sent once;
|
||||
subsequent entity updates replace the query without resetting the camera.
|
||||
Expand/restore and unmount dispose the isolated WASM realm.
|
||||
- Private `data_dir/missions/live-tests/<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:55–13:47:03 UTC. The archive
|
||||
catalog marks its point-cloud and trajectory sources ready: 959 cloud messages,
|
||||
960 poses, 37,812,938 raw bytes. Raw SHA-256:
|
||||
`fe1366a177c20204f26e709649521611f0fc3aae138cb857bcabe84a5809268a`.
|
||||
The device's last distance report is 62.532 m; sum of received pose steps is
|
||||
62.974 m, including gaps, and is not independent ground truth.
|
||||
|
||||
The research failed before its first registration result: 2.776 m of accumulated
|
||||
movement and 14,101 preview points were retained in its status. Received pose 161
|
||||
moves 10.407 m after a 9.973 s receipt gap. The first implementation rejected any
|
||||
step above 3 m without considering elapsed time. Therefore no green was produced;
|
||||
this is an ingestion failure, not an experiment disproving localization.
|
||||
|
||||
The selected frozen draft was the prior internal reverse-direction comparison
|
||||
of the first 30 m of A, not the forward 30 m draft. This source selection is
|
||||
preserved, not silently corrected. Start/direction must be checked before using
|
||||
B to claim a registration result.
|
||||
|
||||
B has no archived camera frames. Its camera epoch sealed with zero media segments
|
||||
and `stale-activation-commit`. This is separate from the missing floating camera
|
||||
UI. Camera authority fences are retained; no unsafe retry or physical command is
|
||||
introduced by this patch. An unavailable recording cannot be reconstructed as
|
||||
video. The next actual camera run still requires hardware acceptance.
|
||||
|
||||
## Implementation
|
||||
|
||||
SpatialWorkspace is extracted unchanged in ownership from the workspace hub to
|
||||
`workspaces/spatial`. A typed visual-profile slot supplies the research renderer,
|
||||
status, metrics and toolbar; the existing K1 controls, source picker and floating
|
||||
camera transport remain owned by the shared scene. The profile no longer puts
|
||||
stop controls behind an extra modal or exposes a button clearing the research.
|
||||
Terminal errors outrank generic waiting/staleness. Saved research selection
|
||||
restores its frozen reference and verified preview across navigation/restart,
|
||||
without running a new capture or fit. Future previews are persisted with hashes.
|
||||
|
||||
Receipt gaps above 2 s clear the bounded fit window and invalidate old accepted
|
||||
samples. A step is rejected above max(3 m, 3 m/s × min(receipt gap, 30 s)); this
|
||||
is a diagnostic plausibility bound, not odometry acceptance. Jobs carry the
|
||||
segment identity and cannot commit across a gap. Slow results remain recorded
|
||||
as historical evidence; the existing 8 s green freshness rule is unchanged.
|
||||
|
||||
The planner uses canonical Inspector/InspectorSelectField with full-width
|
||||
stacked actions. One right-aligned host toolbar owns cloud/route, top/3D and
|
||||
expand actions; no nested host title. For the pinned single-view Rerun runtime,
|
||||
its measured 54 CSS px canvas chrome (28 px top allocation + 26 px view strip)
|
||||
is allocated outside the clipped content viewport. The Inspector scrolls independently
|
||||
inside the available panel height. Scene content height and native pointer handling are preserved.
|
||||
|
||||
## Validation
|
||||
|
||||
Focused Python registration/planner/live tests pass (24 cases), including the
|
||||
10 s receipt-gap regression, rejection of a fast coordinate jump, bounded sample
|
||||
reset, persisted preview restoration and no restored live-green authority.
|
||||
Frontend architecture and planner checks pass. Full frontend validation and
|
||||
canonical browser smoke are recorded at completion below.
|
||||
|
||||
The original failing report and raw session are retained. No localization success,
|
||||
new camera capture, autonomy, control or navigation acceptance is claimed.
|
||||
|
||||
## Completed acceptance on canonical 8000
|
||||
|
||||
Production typecheck/build passed. The complete frontend run executed 849 cases
|
||||
(840 passed; nine obsolete architecture/source-location expectations failed).
|
||||
After updating those expectations for LAB placement/shared scene extraction and
|
||||
removing generic-shell vendor labels, focused reruns passed; the final remaining
|
||||
four affected files passed all 50 cases. No failing assertions remain from that run.
|
||||
|
||||
Browser inspection on the built localhost service verified: equal action widths
|
||||
(315.2 CSS px in the observed Inspector), split canonical selects, independent
|
||||
Inspector scrolling, one aligned toolbar, no visible Rerun title/help strip,
|
||||
real A cloud, expand/restore/Escape, preserved forward draft, and reopening the
|
||||
actual failed research with its frozen reference and explicit failure. The old
|
||||
failed run has no persisted query preview; its 14,101 status points are not
|
||||
reconstructed or presented as a successful result. Raw B remains intact.
|
||||
|
||||
The shared source picker shows K1 cloud and both camera channels; cameras are
|
||||
unconfirmed/disabled while capture is stopped. Camera/recording controls retain
|
||||
the ordinary scene owners, but a new physical camera run was not performed.
|
||||
The next action is analysis of existing A/B in the correct forward draft, not
|
||||
an immediate repeat scan. This UI repair is not hardware-camera acceptance.
|
||||
@@ -0,0 +1,76 @@
|
||||
# Recorded passage registration — 2026-09-11
|
||||
|
||||
The mission planner now compares a selected saved reference route with a bounded interval of another saved recording. The operator opens **Тестирование**, selects the query recording and interval, and starts **Сопоставить проходы**. The report, inputs and 3D evidence remain available after the window closes or the service restarts. This is a recorded-data experiment; no live localization or vehicle commands are produced.
|
||||
|
||||
## Initial hypothesis and scope
|
||||
|
||||
The selected query entry is assumed to be near the selected reference route entry, with matching travel direction. Initial translation maps those entry poses together; initial yaw aligns the first displacement of at least 3 m. Roll/pitch initially agree with the sessions' vertical axes. GICP refines all six degrees of freedom. The hypothesis is explicitly shown in the UI. This is not global place recognition and does not establish a 20 m acquisition radius.
|
||||
|
||||
Both intervals must be 3–40 m. The reference comes from the immutable saved draft revision. Different recording IDs do not, by themselves, prove independent K1 projects. For B the operator must start a new K1 project and a separate Mission Core recording. Overlapping intervals of the same recording are rejected. Disjoint A outbound/return intervals are permitted as an explicitly labelled internal diagnostic, retaining shared SLAM limitations.
|
||||
|
||||
## Implementation
|
||||
|
||||
- `missions/registration.py`: pinned small_gicp 1.0.1 CPU GICP; source-to-reference rigid transform; finite/bounded input validation; origin-independent centering; nearest-surface evaluation and candidate rejection policy.
|
||||
- `missions/registration_worker.py`: one short-lived numerical process, one computation thread, 30 s timeout. The process exits after each result. No permanent worker or second backend.
|
||||
- `missions/registration_runs.py`: single admitted background job, submitted draft snapshot, generation and SHA-256 source binding, immutable per-run JSON/NPZ/RRD artifacts in the existing private data directory. Interrupted queued/running reports are marked as errors on restart. No implicit retry that would duplicate a run.
|
||||
- Plugin-owned K1 extraction reads only cloud messages inside the selected pose-message interval. It selects at most 120 frames, records original message indices and receipts, crops within 20 m of the preceding recorded scanner position and between −3/+6 m relative height, then keeps the first point per 0.25 m voxel. It never applies the scanner pose a second time to K1 map-space points. No future query frames beyond the selected end enter the cloud.
|
||||
- Extraction is bounded by 90 s, 2 million raw points, 1 million cropped observations and 100,000 final points. Missing real receipt timing remains null. Immutable source digests are checked before and after prefix staging/extraction.
|
||||
- `web/mission_registration_api.py`: separate run/history/report/scene API. `components/missions/` and `core/missions/` own the UI. Existing route data checks retain their distinct purpose. The background cloud viewer is unmounted while the test window is open; one result Rerun realm is active, with expand/restore and teardown.
|
||||
|
||||
Install only in the repository environment: `uv sync --extra localization --inexact`. Core remains usable without the optional numeric module; a calculation failure is retained as an error report. Upstream method: [small_gicp](https://github.com/koide3/small_gicp), version [1.0.1](https://pypi.org/project/small-gicp/1.0.1/). The local/global registration distinction is also described in [Open3D's registration tutorial](https://www.open3d.org/docs/release/tutorial/pipelines/global_registration.html).
|
||||
|
||||
## Fixed candidate policy v1
|
||||
|
||||
0.25 m preprocessing; 1.5 m correspondence search; 40 iterations. Required: convergence, ≥55% of query points within 0.5 m of the reference, inlier point-to-point RMSE ≤0.25 m, correction at the patch center ≤3 m and rotation ≤30°. Shape covariance and diagonal-normalized information eigenvalue ratios must exceed 0.002 and 0.0001. These are experimental rejection gates fixed before the real probe, not validated safety thresholds. A candidate is not an accepted robot pose. `localization_confirmed=false`, `vehicle_control=false` on every report.
|
||||
|
||||
## Measurements
|
||||
|
||||
One bounded functional probe on the local arm64 Mac; no load/stress test and no Mini resource qualification. Raw source A digests remained unchanged. Display-only overview sampling was not used as registration geometry.
|
||||
|
||||
| Probe | Result | Query within 0.5 m | Inlier RMSE | Registration |
|
||||
|---|---|---:|---:|---:|
|
||||
| Known synthetic rigid transform, 0.7/−0.4/0.2 m and 5.73° yaw | Candidate; transform recovered within 1 cm / 0.1° assertions | 100% | 0.065 m | about 0.03 s |
|
||||
| A outbound 30 m vs final return 30 m; initial hypothesis perturbed by 0.7/−0.4/0.2 m and 6° | Candidate | 90.70% | 0.188 m | 0.23 s |
|
||||
| A different road section, 130–155 m, deliberately seeded at reference entry | Rejected: non-convergence and residual | 59.38% | 0.274 m | 0.57 s |
|
||||
| Same clouds with initial offset 1 km on each axis | Rejected: no overlap/information | 0% | unavailable | 3.27 s |
|
||||
| Coincident synthetic plane | Rejected: insufficient 3D geometry | — | — | unit test |
|
||||
|
||||
The A return fit differs from the original common frame by about 0.716 m / 0.906°. This is a fitting correction, not localization ground-truth error. Independent survey measurements are absent. Only one wrong-region example was checked; false-positive frequency and repeated-scene robustness remain unmeasured.
|
||||
|
||||
The real server/UI comparison used a reversed 30 m reference and a 20.06 m inbound query with the route-entry/travel-heading hypothesis. Result: 89.32%, 0.194 m inlier RMSE; correction 2.94 m / 3.5°. It completed in 35.0 s initially, of which 1.60 s was registration; most time was bounded Python data extraction. A repeat with the isolated numerical process completed in 31.2 s (1.46 s registration). The first run appeared stalled during interactive inspection but did finish; a native-library deadlock was not established. Timeout isolation was retained as containment, not presented as proof of that diagnosis.
|
||||
|
||||
## Validation and retained limitations
|
||||
|
||||
23 focused backend tests passed (registration, planner, overview), including known-transform recovery, no-overlap/plane rejection, input limits, no future-frame inclusion, no second pose transform, immutable report/revision behavior and one-job admission. Nine planner/architecture frontend tests and typecheck/build passed. Optional localization tests require the localization extra. Real 8000 browser flow, result display, history reopening after server restart, expanded 3D and Escape restore were checked on the available narrow in-app viewport. Full desktop layout and a clean deployment host are not qualified by this check.
|
||||
|
||||
Not implemented: independent B proof, sequential causal tracking, consensus over several windows, arbitrary-start/global relocalization, ground-truth accuracy, hardware timing, LAS comparison, obstacle policy or chassis control. The test consumes an already saved interval; it does not run while the operator walks.
|
||||
|
||||
## Next acquisition
|
||||
|
||||
A is already available. Record only B: new K1 project + separate Mission Core session, start near A's first physical start with the same travel direction, hold the scanner at roughly the same height, walk the first 20–30 m once, finish and save. Keep original A unchanged. Choose the forward 30 m mission draft, then B in Testing; the first comparison uses B's first 20 m. If acquisition starts elsewhere, record the location/direction and select the corresponding reference entry instead of silently treating it as the first start. LAS and Gaussian generation are unnecessary for this probe.
|
||||
|
||||
## Independent B recording — saved comparison at 14:24 UTC
|
||||
|
||||
The acquisition instruction above is now complete: JA-SADOVAYA-002 is saved. No new field recording is needed for the next analysis. One bounded comparison was executed through the canonical server on port 8000, without source changes, a restart, hardware commands or a new capture.
|
||||
|
||||
- Run `820571ba-6076-482b-be34-29ceb5328c80`, 2026-09-11T14:24:04.314Z–14:24:33.127Z; started monotonic ns `514814164352083`.
|
||||
- Forward draft `6159d3fa-dda4-4223-acdf-ceeceedefaad`, revision 1, **JA-SADOVAYA · проверка 30 м**. A: `20260911T085226Z_viewer_live`, poses 0–577, 30.012 m.
|
||||
- B: `20260911T134352Z_viewer_live`, poses 0–251, 20.078 m, 39.530 s. Its whole recorded trajectory is approximately 62.974 m. Evidence relation is `different_recordings`; K1 SLAM reset is not independently verified by this label.
|
||||
- GICP candidate, converged in 23 iterations: **91.212%** of 23,135 query evaluation points within **0.5 m** of the 30,280-point reference; inlier surface RMSE **0.1763 m**. These are geometric consistency measures, not scanner/rover pose accuracy.
|
||||
- Correction from the entry/direction hypothesis: **2.940 m / 6.920°**. Translation is close to the fixed 3 m rejection gate; this run does not establish a reliable startup radius. Gates were unchanged. No arbitrary-location search was performed.
|
||||
- Numerical registration **1.048 s**, complete preparation/evaluation/persistence **28.810 s** on the local Darwin arm64 Python 3.12.13 environment. This is one recorded-data probe, not a live-rate or onboard-computer qualification.
|
||||
- `localization_confirmed=false`, `vehicle_control=false`. No independent ground truth or sequential-window/false-positive qualification for B yet.
|
||||
|
||||
Report and artifacts are retained under private `data_dir/missions/registration-runs/820571ba-6076-482b-be34-29ceb5328c80/`. SHA-256 verification passed for all four declared artifacts:
|
||||
|
||||
| Artifact | SHA-256 |
|
||||
|---|---|
|
||||
| report.json | `97236f89d73f63745a77558bf585dcac5ead5ffaa8191c27afb73b52c6271d59` |
|
||||
| clouds.npz | `174e60a37685c83553a97500bbe89e0a7da5ec39543629c03fff1691e0c09717` |
|
||||
| scene.rrd | `2824f1d3f0064d5d62dbe6823febd6c6013c162ad08ecfc57281abfe7c9069a4` |
|
||||
| registration-input.npz | `5eedfda3e792bf291bab16c723646b2dfb4e8a1ca3b117a3c7e26dd15bfd4a52` |
|
||||
| registration-result.json | `1c0faf1ed677391a126e8fbb5ee6d7bacdf48343581ad0c503f15f9a5d2fd52e` |
|
||||
|
||||
The saved result was opened in the actual browser comparison history at **17:24:04 Moscow time** and its expanded Rerun view inspected: gray reference, colored B and green distance-qualified correspondences rendered. The short-lived numerical worker exited; port 8000 still returned HTTP 200. Docker backend/VM was not running during this probe. No new permanent process was introduced.
|
||||
|
||||
Next planned experiment: replay successive bounded B windows against fixed A, retain causal evidence and assess discontinuities; add wrong-region controls before claiming stable localization. New scanning can wait for those results. The previous failed live report remains unchanged and is not reclassified by this recorded result. Missing archived B camera frames are not restored by point-cloud registration.
|
||||
@@ -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.063–0.351 s | 0.072–0.333 s |
|
||||
| Latest input to completed result | 0.290–0.863 s | 0.288–0.863 s |
|
||||
| Maximum replay delivery lateness | 0.154 s | 0.145 s |
|
||||
| Sustained tracking | Not established | Not established |
|
||||
|
||||
The second mode never reached its previous-result branch: no initial candidate
|
||||
passed the existing acceptance policy. These are two functional traces, not a
|
||||
throughput benchmark, full live-capture/UI qualification or Mini capacity claim.
|
||||
Loading the already prepared, hash-verified reference took 0.023–0.028 s; this
|
||||
does not replace the earlier measurement of preparing a reference from raw data.
|
||||
|
||||
## Cause of rejection
|
||||
|
||||
B has a 9.973 s receipt gap ending at 30.803 s, with 10.407 m between adjacent
|
||||
received poses. The live accumulator correctly clears its query window and
|
||||
starts a new segment. The first usable direction appears only after that gap:
|
||||
there was no demonstrated stationary startup or stable acquisition in 30 s.
|
||||
|
||||
All six windows exceeded the fixed 3 m **correction at the observed patch center**:
|
||||
3.146, 3.258, 3.469, 3.638, 3.770 and 4.105 m. The fourth also failed convergence.
|
||||
The corresponding heading correction is approximately 6.95–6.98 degrees. The
|
||||
3 m policy is not a measured capture radius or the distance from the real rover
|
||||
to the reference start.
|
||||
|
||||
Despite rejection, nearest-surface coverage was 92.62–98.72%, with inlier RMSE
|
||||
0.148–0.179 m. A post-run diagnostic of the rejected transforms found entry-point
|
||||
corrections around 2.745–2.759 m; the moving patch-center correction grows with
|
||||
distance under the initial yaw error. Consecutive rejected transforms change the
|
||||
current scanner's mapped position by at most 0.016 m and orientation by at most
|
||||
0.143 degrees. This is consistency of rejected geometric fits, not measured
|
||||
pose accuracy, accepted tracking, or grounds for silently widening the gate.
|
||||
Rejected results never initialize the tracking mode.
|
||||
|
||||
## Negative controls
|
||||
|
||||
The fixed third causal snapshot (22.046 m of thinned path, requested at 42.015 s)
|
||||
was reused in two separate controls. Neither uses the final offline B fit.
|
||||
|
||||
- Hint translated +1000 m on all axes: rejected, 0% overlap, residual unavailable,
|
||||
insufficient geometric information; calculation 1.928 s.
|
||||
- Different A road patch at cumulative trajectory 130–155 m, seeded at that
|
||||
patch's entry using only the snapshot's preceding path: rejected by residual
|
||||
0.279 m, overlap 56.16%; calculation 0.125 s. This narrowly passing overlap
|
||||
reinforces why green cannot depend on overlap alone. One wrong region is not
|
||||
a false-positive rate estimate.
|
||||
|
||||
## Implementation and verification
|
||||
|
||||
- `device_plugins/xgrids_k1/planning_live.py`: extracted the existing decoder so
|
||||
committed live input and archive replay use the same point/pose interpretation.
|
||||
- `planning_replay.py`: lazy recorded events, mandatory aligned monotonic receipt
|
||||
metadata, no synthetic clock or hardware transport.
|
||||
- `missions/causal_replay.py`: receipt-paced bounded replay, same live accumulator,
|
||||
one isolated fit worker, causal snapshots, per-step provenance and hashes.
|
||||
- `missions/causal_tracking.py`: separate experimental freshness/consistency
|
||||
evaluator. It is not activated in the product live controller by this work.
|
||||
- `scripts/replay_planning_registration.py` and
|
||||
`scripts/check_planning_replay_controls.py`: explicit private inputs, checksum
|
||||
validation before/after, immutable experiment outputs.
|
||||
- `tests/test_causal_planning_replay.py`: prefix-only geometry, stale and
|
||||
inconsistent fits, three-candidate qualification, gap fencing, mandatory real
|
||||
clock, and late completion cannot restore authority after input end.
|
||||
|
||||
All 32 focused causal/live/registration/native-replay tests passed; targeted Ruff
|
||||
checks passed. A subsequent terminal-state ordering fix clears authority at input
|
||||
end before waiting for the final child; a regression asserts this ordering. In
|
||||
both real traces all jobs completed before input end, so that fix does not alter
|
||||
their results. No frontend code changed or build was needed.
|
||||
|
||||
Private evidence is under `data_dir/missions/causal-replays/`:
|
||||
`20260919-baseline-001`, `20260919-tracking-001`, `20260919-controls-001` and
|
||||
`20260919-executed-source-001`. The latter seals the executed source before
|
||||
post-run formatting plus all three report hashes. Raw geometry stays out of Git.
|
||||
|
||||
## Decision and next bounded experiment
|
||||
|
||||
Numerical latency is not the observed blocker at this bounded cloud size. Robust
|
||||
initial acquisition is. The owner-accepted startup wait gives time to evaluate
|
||||
several **predeclared** entry-position/heading hypotheses and reject ambiguity;
|
||||
it does not justify accepting a failed local fit. A new acquisition experiment
|
||||
must keep this failed baseline, distinguish the entry search area from the local
|
||||
tracking correction limit, and require consistent subsequent causal windows.
|
||||
Repeat wrong-region controls with that initializer before admitting field work.
|
||||
Do not use the known final B transformation as its seed or truth. The same saved
|
||||
recordings remain sufficient for the next engineering iteration. Stationary
|
||||
start, recovery after already-established tracking, broader locations and actual
|
||||
onboard compute remain unqualified.
|
||||
@@ -0,0 +1,169 @@
|
||||
# Entry acquisition — protocol fixed before execution
|
||||
|
||||
This increment implements a bounded initial search separately from local tracking.
|
||||
It preserves the failed causal baseline and GICP policy v1. The owner accepts
|
||||
roughly 30 s of preparation, but a stale calculation cannot confer current
|
||||
localization. There are no hardware commands or autonomous-control authority.
|
||||
|
||||
## Frozen experiment v1
|
||||
|
||||
- Existing reference A forward 30.012 m, original B receipt-paced 1× input. The
|
||||
old fitted B transform, correspondence mask and future B poses are forbidden.
|
||||
- Initial heading still comes from the already observed first >=3 m displacement.
|
||||
Standing-start heading recognition is not added in this increment.
|
||||
- 27 initial hypotheses: along/across-entry offsets {-3,0,3} m in the reference
|
||||
route heading basis, times yaw offsets {-15,0,15} degrees. Yaw is applied around
|
||||
the query entry, not the arbitrary map origin. No vertical search.
|
||||
- Each seed runs unchanged local GICP v1 (including its <=3 m patch-center
|
||||
correction gate). A final acquisition must also put the query entry inside a
|
||||
5 m horizontal radius, within 1 m vertically, and within 30 degrees of the
|
||||
route-entry/travel-heading hypothesis. This bounds initial search separately
|
||||
from each local optimization; it does not establish a measured capture radius.
|
||||
- Group accepted solutions with pairwise entry-position distance <=0.5 m and
|
||||
rotation <=5 degrees. Rank by overlap, then residual. Require >=3 agreeing
|
||||
hypotheses from >=2 distinct translation grid positions. Multiple seeds are
|
||||
robustness checks, not independent observations or a probability of correctness.
|
||||
- If any other solution group has overlap within 5 percentage points and RMSE
|
||||
within 0.03 m of the best solution, reject as ambiguous, even if it has fewer
|
||||
seeds. Never accept a partial/timed-out search as unambiguous.
|
||||
- One numeric child / CPU thread, 27 seeds per initial search, total internal
|
||||
search deadline 25 s and external child deadline 30 s. Maximum two acquisition
|
||||
attempts per continuous receipt segment, separated by >=10 s on rejection.
|
||||
Replay remains bounded by 120 s / 40 m and existing point limits.
|
||||
- A fresh accepted search is only candidate #1. Tracking still needs three fresh
|
||||
compatible causal windows at >=5 s cadence, with the unchanged 8 s freshness,
|
||||
0.5 m / 5 degree continuity checks. Following fits use the previous fresh
|
||||
candidate. Gap, stale result, ambiguity, end of input or an incompatible fit
|
||||
removes current tracking. Failed results never seed subsequent tracking.
|
||||
|
||||
## Acceptance and controls
|
||||
|
||||
First prove anchor-preserving seeds, known synthetic transform recovery,
|
||||
ambiguous repeated geometry rejection, outside-search-bound rejection, budget
|
||||
exhaustion and no partial-search acceptance in focused tests. Then run one real
|
||||
positive causal trace and negative searches on the already frozen wrong-region
|
||||
snapshot A 130–155 m / B causal step 3 and on a query displaced 1000 m with an
|
||||
unchanged entry hypothesis. Preserve all attempts and checksum inputs/outputs.
|
||||
The far-seed control represents this displacement by adding 1000 m to each
|
||||
translation component of the initial transform, with the actual query unchanged.
|
||||
All compute probes run sequentially and are bounded functional checks, not load
|
||||
tests or qualification of the future onboard computer. No new field capture.
|
||||
|
||||
Record first search, first accepted candidate, first 3-window tracking, time spent
|
||||
tracking, losses, worker latency/input age, search clusters, replay lateness and
|
||||
integrity. Wrong-region acceptance, unexplained position jumps or ambiguity block
|
||||
advancement. Success on this pair does not establish pose accuracy or robustness
|
||||
across locations. No parameter adjustment after a failing probe without a new,
|
||||
explicitly versioned protocol and retained old result.
|
||||
|
||||
## Executed evidence
|
||||
|
||||
Reference A: JA-SADOVAYA-001, session `20260911T085226Z_viewer_live`, forward
|
||||
30.012 m from the immutable run `820571ba-6076-482b-be34-29ceb5328c80`.
|
||||
Query B: JA-SADOVAYA-002, session `20260911T134352Z_viewer_live`, raw MQTT and
|
||||
original monotonic receipt metadata. Only the reference cloud/path were read
|
||||
from the offline artifact; its fitted B transform and query cloud were not used.
|
||||
|
||||
Positive run `20260919-acquisition-001`: 2026-09-19T10:54:19.671Z through
|
||||
10:55:22.797Z, start monotonic ns 832936970922333. Darwin arm64, Python 3.12.13,
|
||||
small_gicp 1.0.1, one numerical child and one CPU thread; 974 causal pose/cloud
|
||||
deliveries in 63.126 s, stopped at 40.038 m. Fixed reference preparation took
|
||||
0.033 s from the existing artifact and is not raw-map preparation performance.
|
||||
|
||||
- First available heading/window: 30.812 s. The original receipt stream has a
|
||||
9.973 s gap and a 10.407 m pose displacement; it was preserved and fenced.
|
||||
Less than 3 m displacement had been available before this gap.
|
||||
- Initial search evaluated all 27 hypotheses in 1.984 s inside the worker
|
||||
(2.208 s full worker wall time). Six accepted seeds formed one solution
|
||||
cluster; no alternative eligible cluster. First fresh candidate at 33.033 s,
|
||||
input age 2.221 s. The search used no previously known fitted transform.
|
||||
- Three-window consistency first passed at 42.362 s, at the window whose query
|
||||
path length was 22.046 m. Tracking stayed qualified until input ended at
|
||||
63.126 s, when it was explicitly cleared. There was no post-lock real gap in
|
||||
this trace, so recovery after loss was not physically demonstrated.
|
||||
|
||||
| Window | Query distance, m | Input time, s | Overlap | Inlier RMSE, m | Input age at completion, s | Temporal state |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | --- |
|
||||
| 1, initial search | 13.268 | 30.812 | 98.72% | 0.146 | 2.221 | acquiring |
|
||||
| 2 | 17.470 | 35.950 | 98.86% | 0.154 | 0.837 | acquiring |
|
||||
| 3 | 22.046 | 42.015 | 98.68% | 0.153 | 0.346 | tracking |
|
||||
| 4 | 26.826 | 47.786 | 98.33% | 0.156 | 0.323 | tracking |
|
||||
| 5, beyond reference path end | 31.455 | 52.940 | 97.59% | 0.161 | 0.486 | tracking |
|
||||
| 6, beyond reference path end | 36.019 | 58.873 | 92.97% | 0.177 | 0.522 | tracking |
|
||||
|
||||
Tracking fits took 0.068–0.117 s numerically, 0.263–0.457 s including the worker.
|
||||
Maximum replay delivery lateness was 0.184 s; maximum buffer ingestion 0.00332 s.
|
||||
Successive accepted transforms differed at the current scanner position by at
|
||||
most 0.01494 m and 0.161 degrees. These are consistency measurements, not ground
|
||||
truth pose errors. Overlap counts points within 0.5 m; it is not a correctness
|
||||
probability. The last two windows test cloud coverage beyond the reference path
|
||||
end and must not extend route acceptance. Only windows 3 and 4 demonstrate the
|
||||
qualified tracking state while still within the selected 30 m path.
|
||||
|
||||
## Negative controls
|
||||
|
||||
`20260919-acquisition-controls-001`, 2026-09-19T10:55:45.043Z through
|
||||
10:56:19.476Z, used the previously frozen causal step-003 query.
|
||||
|
||||
- Wrong region A 130–155 m: all 27 hypotheses evaluated in 7.294 s, no eligible
|
||||
solution cluster, `no-admissible-entry`. No false acceptance on this control.
|
||||
- Far initial translation (+1000 m on all axes): 13 hypotheses in 26.801 s;
|
||||
internal soft budget expired between seeds. `incomplete-search`, no candidate.
|
||||
This proves rejection on budget exhaustion, not full search of all 27 seeds.
|
||||
|
||||
The original input digests, all 31 positive and 6 control artifacts, and the
|
||||
executed source digests were checked again after completion. Private evidence
|
||||
is under `data_dir/missions/causal-replays/`; no geometry entered Git. Exact
|
||||
sources and the later live-integration source are copied to
|
||||
`20260919-acquisition-executed-source-001` with a manifest. Report SHA-256:
|
||||
|
||||
- positive: `bf1a8c44e2a9960d06748d7061c19a3ae47e945242896359c57462bc402d3235`
|
||||
- controls: `77cb3ca147105439439560649ad4564266f0a53fac4b280a660525e4fac511b2`
|
||||
|
||||
## Implementation and validation
|
||||
|
||||
`entry_acquisition.py` separates bounded entry search, complete-link solution
|
||||
clustering, ambiguity checks and support gates from unchanged local GICP.
|
||||
`entry_acquisition_worker.py` isolates the search with a 30 s hard child deadline.
|
||||
The acquisition mode in `causal_replay.py` uses it until a fresh candidate exists,
|
||||
then seeds local refinement from that candidate. No rejected fit seeds tracking.
|
||||
|
||||
The existing `PlanningLiveTests` profile now uses the same entry search and
|
||||
`CausalTracking` evaluator. It retains session/generation ownership, bounds the
|
||||
attempt count per receipt segment, and records seed and policy provenance.
|
||||
Green correspondences require three fresh consistent windows. Gaps, stale input,
|
||||
rejection and input end remove green qualification; a final historical worker
|
||||
cannot restore current tracking. It still has no scanner or vehicle commands.
|
||||
|
||||
49 focused tests passed: entry/causal/live/projects/registration/viewer replay.
|
||||
These include synthetic known-transform recovery, ambiguity with unequal support,
|
||||
complete-link clustering, anchor-preserving yaw, region/height/angle limits,
|
||||
partial-search rejection, prefix-only input, stale/gap fencing and live controller
|
||||
integration (one initializer, then two local fits; green only on the third;
|
||||
silence expires the result and releases the consumer lease). Ruff passed on new
|
||||
and replay files; `git diff --check` passed. One existing Starlette/httpx
|
||||
deprecation warning remains. No frontend code or layout changed this increment.
|
||||
|
||||
The canonical service was restarted only after verifying idle source, no physical
|
||||
scan and no camera recording. On 2026-09-19T11:04:37.611Z the new backend started
|
||||
on 127.0.0.1:8000 (PID 60284); `/` and `/api/health` return 200. Nothing listens on
|
||||
8765; no numerical children or replay jobs remain. Docker VM was not started.
|
||||
The engineering summary, fixed protocol, controls, limitations and acceptance
|
||||
checker were saved in MISSIONCOR-81 (34 preserved/appended structured blocks).
|
||||
|
||||
## Decision and next stage
|
||||
|
||||
The bounded initial search resolves the observed entry-acquisition failure on
|
||||
this A/B pair while preserving the previous local-refinement policy. It supports
|
||||
continuing the teach-and-repeat research without another capture for this step.
|
||||
It does not establish arbitrary-start localization, stationary calibration,
|
||||
independent metric accuracy, a reliable 5 m capture radius, or onboard capacity.
|
||||
The 8 s freshness and 5 s cadence are diagnostic settings, not navigation limits.
|
||||
|
||||
Next isolate recovery and initialization from travel: test deliberate receipt
|
||||
loss after tracking and wrong-start/repeated-geometry cases; then develop a
|
||||
stationary accumulation/heading hypothesis protocol. Preserve this successful
|
||||
trace as a fixed regression. A fresh physical short pass, concurrent camera/UI
|
||||
acceptance and qualification on the actual onboard computer follow separately.
|
||||
No need for the operator to repeat the route now. Autonomous steering, collision
|
||||
avoidance and vehicle-control authority remain outside this increment.
|
||||
@@ -0,0 +1,124 @@
|
||||
# Stationary bootstrap in the existing LAB planning profile
|
||||
|
||||
The owner approved extending the scanner's existing preparation sequence with
|
||||
reference alignment, then testing it by hand. The LAB planner remains the entry:
|
||||
new named project → saved reference and section → new scanner pass or recorded
|
||||
pass. This increment changes the new scanner pass. Recorded comparison retains
|
||||
its existing algorithm and opening an archived result does not recalculate it.
|
||||
|
||||
## Runtime and presentation
|
||||
|
||||
`PlanningLiveTests` now delegates live ingestion to `stationary_live.py`, using
|
||||
the already qualified `stationary-fresh-bootstrap/v1` state machine. The initial
|
||||
ten seconds start at the first usable cloud with a recent matching pose, not at
|
||||
session opening or during hardware calibration. Original session/generation,
|
||||
receipt times, continuity and exclusive derived-consumer ownership are retained.
|
||||
The numerical policy, thresholds, single prior trial and no-fallback rule are
|
||||
unchanged. Capture and physical START/STOP remain owned by the K1 plugin.
|
||||
|
||||
The provisional initialization is stored separately from current results. It
|
||||
does not publish a tracking transform or green points. Three consistent fits on
|
||||
disjoint, fresh receipt windows are required. Cancellation, source end/change,
|
||||
gap, stale input, failed or expired fitting removes live authority. Numerical
|
||||
work is bounded, serialized and joined before the exclusive lease is released.
|
||||
|
||||
The shared plugin SDK accepts optional session-bound presentation through
|
||||
`SpatialActivityPresentation`. `SpatialWorkspace` passes it into the existing
|
||||
device controls. K1 shows it only while acquiring authoritative data for that
|
||||
exact capture session; calibration, stopping, recovery and cleanup take priority.
|
||||
The existing `K1SpatialSession` and canonical activity indicator present:
|
||||
|
||||
1. Hardware calibration, unchanged.
|
||||
2. «Накопление данных» — stationary input collection.
|
||||
3. «Привязка к эталону» — bounded initial search.
|
||||
4. «Подтверждение привязки» — new observations and temporal checks.
|
||||
5. «Сопровождение» — three current consistent fits; the operator may begin the
|
||||
diagnostic walk.
|
||||
6. «Привязка потеряна» / completed / error — no readiness claim.
|
||||
|
||||
The presentation no longer promotes one geometric candidate to success. Stale
|
||||
tracking also removes the old invitation to begin walking. Camera ownership,
|
||||
scene toolbar, device buttons, layout and saved-project selection remain in the
|
||||
shared spatial workspace. The planner inspector and connection window both
|
||||
explain waiting at the selected entry until «Сопровождение».
|
||||
|
||||
## Integrated archive qualification
|
||||
|
||||
Private evidence: `data_dir/missions/causal-replays/20260919-live-bootstrap-001`.
|
||||
`scripts/check_planning_live_bootstrap.py` freezes protocol, exact source copies
|
||||
and hashes before running the actual `PlanningLiveTests` service in isolated
|
||||
storage. A read-only archive adapter supplies B at its original 1× receipt
|
||||
intervals, rebased to the current monotonic clock; original receipt clocks and
|
||||
epoch timestamps remain in evidence. No HTTP source replacement, synthetic
|
||||
device authority, new capture, hardware commands or second server is involved.
|
||||
|
||||
Fixed A is the predecessor's forward 39.926109 m / 52,965 point reference; B is
|
||||
the independent saved pass. Prior fitted B transforms or future B headings are
|
||||
not supplied. The original 9.972693 s / 10.406525 m receipt gap is retained.
|
||||
|
||||
Run `a30fc78f-6d41-4272-9ebd-5da8528feb02`:
|
||||
|
||||
- 2026-09-19 13:04:41.226–13:05:44.543 UTC; replay monotonic origin
|
||||
`840758859762625`. 974 delivered events, maximum delivery delay 0.164782 s.
|
||||
- Initial search starts after ten seconds. Provisional seed at 31.486786 s;
|
||||
worker wall 21.464897 s. Its 21.802489 s old input is never accepted as live.
|
||||
- Six fresh fits; the third establishes tracking at 44.613764 s.
|
||||
Fresh worker wall 0.202423–0.329659 s; accepted source ages 0.241667–1.530939 s.
|
||||
All fit receipt IDs are disjoint and strictly after their fresh-window floors.
|
||||
- The real live-scene method exports a valid RRD while tracking. At the distance
|
||||
bound, state is completed/lost and accepted live geometry is cleared. The
|
||||
periodic UI distance field last published 39.232500 m; the terminal pose crosses
|
||||
the 40 m bound. That field is not a higher-frequency trajectory measurement.
|
||||
- Initialization is only a prior; input/code hashes, cleanup, temporal admission
|
||||
and every one of the 72 sealed evidence files pass verification.
|
||||
|
||||
SHA-256:
|
||||
|
||||
- manifest: `114e823dced5d5f6b2075706b8fc12c1b2fff29969be1d2d290397da52d9b1d1`
|
||||
- summary: `359f2285290215d14fe08c6e813c0f9b995db6d56d117f26acd7663c396013c2`
|
||||
- seal: `d0f2485680f0d593e2b5662c2be7d0f51ff8fbc5c808870ad29a6efa9c201bc8`
|
||||
|
||||
This qualification does not replace the predecessor's wrong-region controls.
|
||||
The algorithm is unchanged and its existing negative/unit cases remain tested.
|
||||
The archived B moved while its initialization was running. A physical scanner
|
||||
held stationary until full confirmation is the next experiment, not a proved
|
||||
result. Startup time is one measurement, not a fixed countdown. Geometric
|
||||
agreement is not an independent measurement of navigation accuracy.
|
||||
|
||||
## Validation and operator acceptance
|
||||
|
||||
58 focused backend tests passed in 4.14 s: live orchestration, stationary prior,
|
||||
recovery, causal replay, entry acquisition, project archive and registration.
|
||||
New orchestration tests cover a long hardware-calibration period before the
|
||||
first cloud, continued input during search, separate prior/current results,
|
||||
three fresh confirmations, stale tracking, identity change and cancellation
|
||||
during a worker. Existing Starlette/httpx deprecation warning remains.
|
||||
|
||||
Frontend architecture: 4 checks; full typecheck; 852 sequential unit tests;
|
||||
production build. Added presentation coverage checks exact capture identity,
|
||||
calibration/stop/cleanup precedence, no one-candidate success, stale instruction
|
||||
removal, recorded/end-state isolation. No new visual primitive, color, CSS rule,
|
||||
native control or page composition was introduced. Ruff on the three changed
|
||||
Python implementation/qualification files and `git diff --check` pass.
|
||||
|
||||
In-app browser on canonical 8000: saved A/B result renders; normal and expanded
|
||||
application panels retain the cloud; new project opens the existing inspector;
|
||||
new/recorded pass selection changes the appropriate fields and disabled action;
|
||||
Escape closes the inspector and resets its toolbar toggle. Live phase rendering
|
||||
is covered by component tests, not claimed as a physical K1 browser test.
|
||||
The application panel's expansion is toggled by its button; Escape acceptance
|
||||
here refers to the inspector.
|
||||
|
||||
All tests, replay, builds and browser QA run sequentially on Darwin arm64. No
|
||||
Docker VM or second backend is started. Before restart, K1 is idle, with no
|
||||
active acquisition, and the old live study is terminal. The canonical LaunchAgent
|
||||
is restarted to load the handler; backend PID changes from 60284 to 68323.
|
||||
Port 8765 remains unused. Raw geometry and RRD evidence stay outside Git.
|
||||
|
||||
Next physical check: create a LAB project with A's first 30 m, select «Новый
|
||||
проход», use a new K1 project name, start near that section's entry, keep the
|
||||
scanner stationary through both hardware preparation and reference confirmation,
|
||||
then walk 20–30 m slowly after «Сопровождение». Finish with the existing K1
|
||||
device/recording stop control. If confirmation fails or tracking is lost, stop
|
||||
the walk and retain the recording for diagnosis. Arbitrary starts, measured
|
||||
position accuracy, actual onboard compute and autonomous motion remain unaccepted.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Physical repeat: 35.30 m and slow planning cloud
|
||||
|
||||
Date: 2026-09-19. Read-only diagnosis requested after the operator reported a
|
||||
successful approximately 35 m walk, stable video, and an unusually slow new cloud.
|
||||
No product code, device configuration, runtime, or Ops card was changed. The
|
||||
registration-stabilization changes remain loaded in the canonical 8000 service.
|
||||
|
||||
## Identity and integrity
|
||||
|
||||
The new project is also named `ja-sun-006`; it must not be confused with the
|
||||
earlier failed run under that same name.
|
||||
|
||||
- Run: `27a20878-9697-4735-a86e-763de28e4b67`.
|
||||
- Query: `20260919T185447Z_viewer_live`, generation 1.
|
||||
- Reference: `20260911T085226Z_viewer_live`; route 0–577, map 0–785,
|
||||
`route-context-map/v1`, 70,870 points.
|
||||
- All 83 run artifacts matched their stored hashes. Raw MQTT and metadata hashes
|
||||
also matched the capture summary. Raw SHA-256:
|
||||
`0723c51b7555740e802db669597e42e3f21f4438ef909e1d6ee4e89284119a88`.
|
||||
- Browser diagnostics identify the deployed build `app-D2qqIbEW.js`.
|
||||
|
||||
## Registration result
|
||||
|
||||
The complete 108-hypothesis stationary search finished at 18:55:38.734 UTC.
|
||||
After three independent fresh checks, tracking began at 18:55:51.852 UTC,
|
||||
approximately 38.47 s after the first pose. The first six calculation windows
|
||||
were stationary or nearly stationary; the operator moved after tracking began.
|
||||
|
||||
All 15 fresh-validation results were accepted. There was no registration rejection
|
||||
or receipt-gap transition during the walk. The final distance was 35.300 m
|
||||
(scanner's own reported distance 35.215 m). During moving windows, overlap was
|
||||
96.14–98.14%; final stationary checks were 99.37% and 99.16%. Final inlier RMSE
|
||||
was 0.14384 m. This is surface agreement, not independent position accuracy.
|
||||
|
||||
Accepted transform yaw ranged approximately −3.028° to −2.831°, a span of
|
||||
0.197°. These are map-frame alignment angles, not an absolute heading error.
|
||||
The large apparent rotation/fallback observed in the earlier run is not present
|
||||
in these saved transforms. Fresh fit worker wall times were 0.246–0.517 s; the
|
||||
latest input age at result admission was 0.311–1.845 s. Fits remain scheduled
|
||||
approximately every five seconds, not at native pose rate.
|
||||
|
||||
## End-of-run chronology
|
||||
|
||||
| Event | UTC |
|
||||
| --- | --- |
|
||||
| Last substantial pose increment (>3 cm between adjacent receipts) | 18:56:43.810 |
|
||||
| Last accepted fit, distance 35.300 m | 18:56:53.617 |
|
||||
| Correlated physical STOP response | 18:56:54.625 |
|
||||
| Last raw point cloud | 18:56:54.747 |
|
||||
| Registration freshness expired | 18:57:00.643 |
|
||||
| Camera archive sealed | 18:57:15.245 |
|
||||
| Input/session closed | 18:57:44.599 |
|
||||
|
||||
The final `lost/stale` is after confirmed STOP and cessation of raw cloud input,
|
||||
not a loss while walking. The final report ends `input-ended`. Cleanup took longer
|
||||
than physical STOP; those are separate events and must not be conflated. Current
|
||||
acquisition is completed, receiver stopped, device STOP protocol-confirmed,
|
||||
source/control idle, physical command inactive. No STOP command was sent by this
|
||||
diagnostic work.
|
||||
|
||||
## Why the cloud remains slow
|
||||
|
||||
The implementation still has separate ordinary-live and planning display paths.
|
||||
The previous change improved snapshot/poll cadence and alignment ownership, but
|
||||
did not turn the planning preview into a native-rate streaming view.
|
||||
|
||||
| Layer | Evidence |
|
||||
| --- | --- |
|
||||
| Raw K1 receipts | 1,013 poses and 1,013 point-cloud frames; both approximately 9.984 Hz |
|
||||
| Raw cloud spacing | median 87.58 ms; p95 194.01 ms; maximum 1.001 s |
|
||||
| Existing planning buffer applied offline to this raw recording | 169 cloud frames, 1.663 Hz; median spacing 0.560 s, p95 0.935 s, maximum 1.207 s |
|
||||
| Fresh map-fit/evidence layer | roughly one update per five seconds |
|
||||
| Browser planning requests | next request starts 500 ms after the previous fetch/send finishes; this is not guaranteed 2 FPS |
|
||||
|
||||
The offline buffer count is deterministic sampling analysis, not a measurement of
|
||||
the physical browser's rendering rate. The actual planning ingress consumed
|
||||
1,000/1,013 cloud events (13 overflow), all 1,013 poses (zero overflow), and
|
||||
941/942 camera frames (one overflow). These derived-queue counts do not indicate
|
||||
loss of the raw recording.
|
||||
|
||||
Source trace:
|
||||
|
||||
- `missions/live_buffer.py:43–64`: accepts cloud no more often than every 500 ms;
|
||||
0.25 m voxel, up to 4,000 points/frame, 40 accumulated frames, 40,000 points
|
||||
in a snapshot. At the end, these 40 selected frames cover 23.61 s of history.
|
||||
This is historical accumulation, not a 23.61 s delay before adding a frame.
|
||||
- `missions/stationary_live.py:233–244`: snapshots that same buffer on a separate
|
||||
500 ms gate. Changing the poll interval does not remove the upstream sampling.
|
||||
- `missions/live_scene.py:25–43`: clears/replaces the entire query/path and
|
||||
validation entities for each response. Green points belong to the previous
|
||||
accepted fit window; they intentionally do not follow every new cloud.
|
||||
- `PlanningLiveScene.tsx:25–42`: sequential HTTP download, RRD handoff, then a
|
||||
500 ms wait. No recorded per-frame fetch/decode/present acknowledgement exists
|
||||
for this physical pass, so exact browser delay and its dominant cost are unknown.
|
||||
- Ordinary `viewer/rerun_bridge.py:218–249,358–381` publishes each admitted
|
||||
point-cloud envelope to its live Rerun stream. That is not the planning
|
||||
accumulated-snapshot transport.
|
||||
|
||||
The ordinary receiver recorded zero decode errors and receipt-to-publish p95
|
||||
85.212 ms. That metric belongs to the ordinary bridge, not planning's browser.
|
||||
The raw stream contains approximately one-second receipt stalls, so it should not
|
||||
be described as perfectly uniform. Nonetheless it supplies much more frequent
|
||||
data than the planning preview admits.
|
||||
|
||||
Conclusion: the complaint has a concrete implementation basis. There is no
|
||||
evidence that K1 SLAM itself needs multiple seconds per new cloud in this pass.
|
||||
The planning display remains deliberately downsampled and snapshot-driven.
|
||||
Exact user-visible latency is not reconstructible from the retained evidence.
|
||||
|
||||
## Camera
|
||||
|
||||
Complete archive: 942 segments, 75,970,859 bytes. Init, index, all segment hashes,
|
||||
and stream digest verified. Browser diagnostics for the recording interval show
|
||||
successful playback without a decode-error/restart event. Single-threaded
|
||||
offline FFmpeg decoding completed with exit 0 and no corrupt-frame/macroblock
|
||||
diagnostic. It emitted 51 non-monotonic DTS lines plus two repeat notices at the
|
||||
null-output muxer. Therefore decoded pictures are not showing the earlier 005
|
||||
corruption pattern, but timestamp cleanliness is not fully accepted. Exit 0
|
||||
alone is not treated as proof of a flawless video path.
|
||||
|
||||
## Decision and next step
|
||||
|
||||
Accept this as a successful short physical registration repeat, not as autonomous
|
||||
navigation acceptance. The slow view should not be dismissed as irrelevant:
|
||||
operators need a current view, and a future controller needs explicitly bounded
|
||||
pose freshness rather than the age of a five-second fit or a painted cloud.
|
||||
|
||||
Recommended implementation next: feed a bounded fresh cloud/pose display from the
|
||||
existing ingress independently of the numerical accumulation buffer; reuse the
|
||||
admitted live transport where compatible; apply the last accepted alignment to
|
||||
fresh data, keeping fit evidence separate. Preserve identity, segment, freshness,
|
||||
loss and no-authority fences. Do not simply increase ICP frequency or expand its
|
||||
buffer. Instrument receive → sample → export → browser-present and frame sequence
|
||||
so a physical display delay can actually be measured. Qualify this on the current
|
||||
saved recording before requesting another field walk. No such change was made
|
||||
in this diagnostic turn.
|
||||
|
||||
Runtime remained `snapshot-runtime-564d78ca9bd1eb86e13bdce9b4b5be27`, PID 99603,
|
||||
healthy on 8000. Temporary offline analyses and FFmpeg completed; no service,
|
||||
camera, replay publisher, or device-control job was started. Ops was read only.
|
||||
@@ -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: 0–577, route length 30.011684 m.
|
||||
- All 82 report-bound artifact hashes verified. Original reference raw capture
|
||||
SHA-256 verified:
|
||||
`61ae4631e6d59b57cd32235871153760976f18706607c7759b8dc28060a56e51`.
|
||||
- Current and related architecture context: MISSIONCOR-81, with linked
|
||||
MISSIONCOR-79/80. The handheld scanner trace is not an accepted chassis route.
|
||||
- User cannot establish whether the visible rotation happened during tracking
|
||||
or after loss/completion; no screen recording is available. Do not present the
|
||||
particular screen moment or a physical 10-degree scanner drift as proven.
|
||||
|
||||
## Actual progression
|
||||
|
||||
All times UTC; add three hours for the operator's Moscow clock.
|
||||
|
||||
| Event | Time | Evidence |
|
||||
|---|---|---|
|
||||
| Usable cloud / collecting | 17:47:00.338 | Stationary prefix begins |
|
||||
| Initial search | 17:47:10.350 | 108-seed bounded search |
|
||||
| Initial candidate ready | 17:47:24.739 | Yaw -1.003°, overlap 98.05%, inlier RMSE 0.144 m |
|
||||
| Three-window tracking established | 17:47:37.370 | Accepted fresh validation; not just point color |
|
||||
| Scanner lifted and carried | approximately 17:47:47 onward | Local z rises about 1 m; quaternion changes with body turn |
|
||||
| Last accepted fit | 17:48:33.310 | Distance 33.959 m, yaw -0.679°, overlap 81.0%, RMSE 0.215 m |
|
||||
| Fit rejected | 17:48:39.335 | Distance 38.100 m, overlap 49.36%, RMSE 0.266 m, nonconvergence |
|
||||
| Lab test ends | 17:48:41.069 | Last published distance 39.486 m; loop has a 40 m cap |
|
||||
|
||||
Accepted moving fits retain yaw between approximately -1.018° and -0.661°.
|
||||
After the lift/turn, overlap stays approximately 97–98% for much of the walk.
|
||||
This evidence does not support blaming the scanner's approximately one-metre
|
||||
height change or changed carrying orientation for a 10-degree registration turn.
|
||||
It does not qualify arbitrary mounting heights/orientations either.
|
||||
|
||||
`input-ended` in the final report is not proof that scanner input stopped:
|
||||
`stationary_live.py` uses the same terminal reason after all normal loop exits,
|
||||
including the distance cap. Raw pose/cloud continue until approximately 17:49:00.
|
||||
The saved acquisition reports a later protocol-confirmed device stop.
|
||||
|
||||
## Confirmed defect: live view falls back to a different transform
|
||||
|
||||
`PlanningLiveTests.scene()` uses `accepted_sample` only while the source is
|
||||
active, the run is running and the accepted sample is sufficiently fresh.
|
||||
Otherwise it supplies the global buffer sample with no accepted result.
|
||||
`live_scene.scene_bytes()` then uses `sample['hint']`.
|
||||
|
||||
That hint is computed by `registration.path_hint()` from the two trajectories'
|
||||
first >=3 m horizontal displacement. It assumes their early travel headings
|
||||
should agree; it is not geometric localization. The stationary bootstrap
|
||||
explicitly disables this fallback for localization, but the renderer still uses it.
|
||||
|
||||
A bounded test called the existing `PlanningLiveTests.scene()` selection logic
|
||||
on saved 006 inputs, with only clock/source/render sink substituted:
|
||||
|
||||
| Display selection | Applied yaw | Path size |
|
||||
|---|---:|---:|
|
||||
| Last accepted tracking window | -0.678689° | 50 points |
|
||||
| After rejected fit | -21.929674° | 530 points |
|
||||
| After lab completion | -21.929674° | 530 points |
|
||||
|
||||
Green is correctly removed in the fallback, but geometry changes by about
|
||||
21.25° without a new accepted localization. This is a reproducible presentation
|
||||
defect and a plausible explanation of the operator's observation. Screen-right
|
||||
and perceived angle depend on camera viewpoint; the exact observed event is unknown.
|
||||
|
||||
The archived project viewer uses a separate path: `projects.live_scene()`
|
||||
selects the committed last calculation, including a rejected result, with its
|
||||
own input window. It does not apply this preview hint. Live completion and
|
||||
archive inspection must therefore not be conflated.
|
||||
|
||||
## Confirmed submap coverage limitation: counterfactual control
|
||||
|
||||
The run uses a single fixed reference submap extracted from the selected 30 m
|
||||
route interval, while allowing a new walk of about 40 m. Reference cloud points
|
||||
extend beyond the route endpoint (up to 48.49 m along the route chord), but
|
||||
sparse distant visibility is not equivalent to mapping the later section.
|
||||
|
||||
Control: read the same original reference capture and extract its later
|
||||
10.003–49.009 m interval, poses 339–775. This respects the existing <=40 m
|
||||
extraction-window bound; 120 frames yielded 52,159 retained voxel points in
|
||||
2.172 seconds. No original artifact, route or runtime configuration was changed.
|
||||
Use exactly the same step-016 query and the same last-accepted transform:
|
||||
|
||||
| Reference data | Raw-query overlap within 0.5 m | Inlier RMSE |
|
||||
|---|---:|---:|
|
||||
| Original frozen submap | 50.8236% | 0.2581 m |
|
||||
| Later reference interval | 98.0735% | 0.1572 m |
|
||||
|
||||
This comparison changes reference coverage only, not query pose or alignment.
|
||||
A subsequent single bounded GICP control on the later reference also converged:
|
||||
97.6789% preprocessed overlap, RMSE 0.157108 m, five iterations, 0.0431 m
|
||||
centre correction, 0.4348° rotation correction, native calculation 0.0281 s.
|
||||
Its transform yaw is approximately -0.789°.
|
||||
|
||||
Thus loss of overlap at the end of 006 is explained by the selected reference
|
||||
coverage rather than requiring a failed scanner SLAM or changed mount hypothesis.
|
||||
This retrospective control is not a new successful live run and does not grant
|
||||
localization/control authority. A route endpoint and a localization map boundary
|
||||
need separate handling; thresholds should not simply be relaxed.
|
||||
|
||||
## Cadence: sensor arrival, calculation and display are different
|
||||
|
||||
Raw capture contains 1,200 consecutive pose sequence numbers and 1,200 cloud
|
||||
messages over approximately 120 seconds. Average receipt frequency is 10 Hz
|
||||
for each. During the lab interval:
|
||||
|
||||
| Stream | Median interarrival | p95 | Maximum |
|
||||
|---|---:|---:|---:|
|
||||
| Pose | 0.0892 s | 0.1735 s | 1.0285 s |
|
||||
| Cloud | 0.0867 s | 0.1776 s | 1.0577 s |
|
||||
|
||||
These are host receipt intervals, not calibrated sensor-to-actuator latency.
|
||||
No >=2 s pose/cloud receipt gap appears, but the approximately one-second pauses
|
||||
are real evidence against assuming a hard real-time guarantee. The planning
|
||||
ingress snapshot also contains 14 lidar overflow drops out of 1,009 publications;
|
||||
the raw recording and derived-consumer accounting are distinct.
|
||||
|
||||
- `LiveCloudBuffer.ingest`: cloud selection limited to at most 2 Hz.
|
||||
- `StationaryBootstrap.validation`: independent fresh fits approximately every 5 s.
|
||||
- `PlanningLiveTests.scene`: displays the accepted calculation's frozen window,
|
||||
rather than latest pose/cloud under the accepted transform.
|
||||
- `PlanningLiveScene.tsx`: next fetch starts 2 s after the previous fetch/render
|
||||
submission. This is at best 0.5 Hz polling, not a guaranteed display rate.
|
||||
- Fresh fit worker wall time is approximately 0.198–0.466 s; newest input age at
|
||||
completion approximately 0.246–0.850 s. A five-second collection window also
|
||||
contains older observations, so newest age is not the age of every point.
|
||||
|
||||
Slow drawing is not proof of a 2-second scanner sampling rate. Nevertheless,
|
||||
the current LAB is not a proven rover perception/control loop. It has no vehicle
|
||||
authority and admits tracking evidence up to 8 s old. Fast local pose/perception,
|
||||
slower global map correction, and operator rendering must be separately timed,
|
||||
bounded and measured; simply refreshing the browser faster is insufficient.
|
||||
|
||||
## Mounting and route semantics
|
||||
|
||||
Cloud-to-cloud registration aligns the scanner's SLAM session map with the
|
||||
reference map in six degrees of freedom. A different scanner height/orientation
|
||||
does not inherently require rebuilding that map or a separate SLAM algorithm.
|
||||
006 already provides limited positive evidence for lift and turn after startup.
|
||||
|
||||
Knowing the scanner pose is different from knowing a rover's chassis/control
|
||||
point pose. Ground geometry alone does not identify an unknown chassis's forward
|
||||
direction, wheel axle/control point, scanner lever arm or footprint. The missing
|
||||
scanner-to-chassis transform must be known or estimated and validated at runtime.
|
||||
It need not be a manually entered per-rover SLAM profile: a keyed standardized
|
||||
dock or an independently qualified calibration using chassis motion/odometry
|
||||
are candidate product approaches. Chassis geometry and kinematics remain real
|
||||
inputs even when the sensor/computer box is transferable.
|
||||
|
||||
Current planning decoding retains position but discards the already decoded K1
|
||||
orientation. This suffices to draw a scanner trajectory and register map points,
|
||||
not to expose a complete chassis pose for control. Do not equate a handheld
|
||||
scanner path, including lift/start manoeuvres, with the ground route to drive.
|
||||
|
||||
Primary references checked:
|
||||
|
||||
- [XGRIDS K1 FAQ](https://docs.xgrids.com/en-us/02-lingguang-k/01-lingguang-k1/v2.4.0/09-faq.html): stationary initialization followed by lifting is an intended scanning workflow.
|
||||
- [ROS REP-105](https://github.com/ros-infrastructure/rep/blob/master/rep-0105.rst): separate continuous local odometry, global map correction and robot base frame.
|
||||
- [ROS REP-103](https://github.com/ros-infrastructure/rep/blob/master/rep-0103.rst): body and optical coordinate conventions.
|
||||
|
||||
## Recommended next implementation, not performed
|
||||
|
||||
1. Never replace a validated transform with the travel-heading hint when tracking
|
||||
expires or the test ends. Preserve clearly stale historical alignment or hide
|
||||
unlocalized geometry; do not represent it as current localization. Make live
|
||||
completion and archive transform selection explicit and consistent.
|
||||
2. Decouple reference-map coverage from selected route length. For a short test,
|
||||
constrain the evaluation to the covered corridor; later qualify rolling or
|
||||
route-ahead submaps with their own evidence and ambiguity guards.
|
||||
3. Separate latest scanner pose/cloud, accepted map transform and fit evidence.
|
||||
Measure arrival/queue/compute/display ages independently, preserve orientation,
|
||||
and define loss/stale behavior before enabling any rover consumption.
|
||||
4. Qualify the scanner localization contract first, then the transferable-box
|
||||
mounting/chassis contract and the sensor-path-to-drivable-route conversion.
|
||||
5. First regress these changes against saved 006, existing independent passes
|
||||
and wrong-place controls. Only then request a new short physical walk inside
|
||||
the covered reference corridor; do not repeat the same ambiguous field test now.
|
||||
|
||||
Numeric diagnostic scripts are retained under
|
||||
`/private/tmp/mission-core-006.AWd3FO/`; original evidence remains under the
|
||||
canonical runtime data root, outside normal Git.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Stationary physical start: initial acquisition did not complete
|
||||
|
||||
Read-only investigation of the operator's stationary attempt on 19 September
|
||||
2026. No replay, numerical recalculation, threshold change, application restart,
|
||||
scanner command or new capture was performed during this review.
|
||||
|
||||
## Evidence and method
|
||||
|
||||
The completed live report, input arrays, per-hypothesis registration results,
|
||||
receipt metadata and raw transport were inspected together with the current
|
||||
entry-search and presentation code. A private immutable copy is retained at
|
||||
`data_dir/missions/diagnostics/20260919-physical-start-001`: 37 files, 26,806,716
|
||||
bytes, all SHA-256 verified. Manifest SHA-256:
|
||||
`bf1f5810cf9052fe4766d862c230e323ea1d66b5e66293a31eac2a0d6ea84b20`.
|
||||
Its UTC/monotonic creation time, source identifiers and operator account are
|
||||
retained privately. The code copy records the inspected dirty working tree,
|
||||
not independent attestation of the original running process's build.
|
||||
|
||||
The selected reference was the previously recorded second pass, first 30.050 m,
|
||||
43,886 reference points. It differs from the first-pass reference used by the
|
||||
earlier successful archived qualification. The reference trajectory contains
|
||||
the already known 10.407 m step; this review does not establish that the step
|
||||
caused the initial-search rejection.
|
||||
|
||||
## Observed sequence (UTC)
|
||||
|
||||
- 13:33:14.066: collection of the stationary prefix.
|
||||
- 13:33:24.366: bounded entry search.
|
||||
- 13:33:51.695: `lost / initialization-rejected`.
|
||||
- 13:37:46.709: input ended; no tracking had been established.
|
||||
|
||||
The ten-second prefix contained 188 derived source events, 6,098 query points
|
||||
and maximum K1-reported movement of 0.006441 m. The stationary gate passed. This
|
||||
movement is a scanner estimate, not an independent physical measurement.
|
||||
|
||||
Search completed 47 of the required 108 hypotheses in 25.234 s against a 25 s
|
||||
limit. The outer worker finished normally after 27.221 s; its 30 s process
|
||||
timeout did not fire. The persisted rejection is `incomplete-search`, with no
|
||||
selected candidate and no confirmation on fresh windows.
|
||||
|
||||
All 47 hypotheses were rejected. Two converged fits had overlap of 96.749% and
|
||||
96.603%, with inlier RMSE of approximately 0.165 m. Both were rejected solely by
|
||||
the local correction bound: 3.087 m and 4.901 m against 3 m. These corrections
|
||||
are measured at the observed patch centre relative to each artificial seed;
|
||||
they are not the operator's physical error relative to the recorded start.
|
||||
Overlap counts query points within 0.5 m of a reference point. It is not
|
||||
independent localization accuracy or proof of unique place recognition.
|
||||
|
||||
Seed iteration is lexicographic over translation and yaw. Indices 0–46 were
|
||||
evaluated. The central translation (0,0), beginning at index 48, was not reached.
|
||||
No accepted cluster existed at termination. Raising the time limit alone is
|
||||
not evidence that the full search would pass support, ambiguity and fresh-data
|
||||
checks. The final report's zero overlap is the rejected-result fallback, not
|
||||
evidence that the actual clouds had zero overlap.
|
||||
|
||||
## Additional findings
|
||||
|
||||
The bootstrap performs one initial search. Its rejection terminates acquisition;
|
||||
waiting longer does not retry it. Both backend and frontend presentation map
|
||||
this initial failure to the generic lost-binding wording. The observed message
|
||||
incorrectly suggests that previously established tracking was lost.
|
||||
|
||||
The derived planning ingress recorded 102 overflow drops out of 851 published
|
||||
LiDAR events and two out of 889 pose events. An empty receipt-gap list applies
|
||||
only to delivered pose-window gaps; it does not establish lossless ingestion.
|
||||
These counters do not establish raw-recorder loss, and they have not been
|
||||
isolated as the cause of this rejection. They require a separate bounded check.
|
||||
|
||||
## Decision and next stage
|
||||
|
||||
The physical start has not passed acceptance. It failed in initial acquisition,
|
||||
before tracking or movement, and is not evidence of localization loss during a
|
||||
walk. Preserve the passing stationary observation and the failed result.
|
||||
|
||||
Next, use the frozen input to qualify a complete, bounded search, examine seed
|
||||
ordering and repeated preprocessing cost, and retain the multistart support,
|
||||
ambiguity and fresh-confirmation gates. Compare against the previous positive
|
||||
and wrong-reference controls. Distinguish unsuccessful initial binding from
|
||||
loss of established tracking in the shared product status. Verify derived
|
||||
ingress under the same bounded scenario before another physical walk. Do not
|
||||
weaken acceptance thresholds merely to turn this example green.
|
||||
|
||||
Only this diagnostic report and private evidence were added. Product code and
|
||||
running device state were unchanged; no new algorithmic acceptance is claimed.
|
||||
@@ -0,0 +1,158 @@
|
||||
# Planning: native receipts, bounded scene deltas, unchanged five-second fits
|
||||
|
||||
Owner request: keep the expensive map check at its existing cadence; separate
|
||||
fresh cloud/pose presentation from registration. Assess whether the retained
|
||||
35 m physical result supports eventual supervised rover trials. No new device
|
||||
command, capture, network mutation, extrinsic profile or motion authority.
|
||||
|
||||
## Sources and decision
|
||||
|
||||
The physical source is run `27a20878-9697-4735-a86e-763de28e4b67`, also named
|
||||
`ja-sun-006`, query `20260919T185447Z_viewer_live`. Do not confuse it with the
|
||||
earlier failed run bearing the same label. See the separate
|
||||
`2026-09-19-physical-repeat-006-results.md` audit for its 83 verified artifacts,
|
||||
camera evidence, accepted fits and STOP chronology.
|
||||
|
||||
The old display reused `LiveCloudBuffer`: 169 of 1,013 native cloud packets were
|
||||
selected at the numerical half-second cadence. Each browser request then sent
|
||||
the entire accumulated query, followed by another 500 ms wait. This was a
|
||||
presentation bottleneck, not evidence of a two-second native pose rate.
|
||||
|
||||
## Implementation
|
||||
|
||||
- `live_display_buffer.py`: presentation-only tap after input identity and pose
|
||||
continuity validation. No 500 ms packet gate. Finite/range/causal-pose filters,
|
||||
0.25 m visual voxel, half-second chunks updated on every accepted packet,
|
||||
2,000 points per chunk, 40 fixed slots and 40,000 total displayed points.
|
||||
Old slot contents are immutable; eviction carries revisioned tombstones.
|
||||
This is a bounded rolling visual cloud, not a replacement for full raw capture.
|
||||
- `stationary_live.py`, `live_tests.py`: attach that tap without changing the
|
||||
numerical buffer, bootstrap, GICP settings, five-second fresh-check cadence,
|
||||
or three-independent-window gate. Fast points/pose advance only under the last
|
||||
accepted transform, in the same segment and while cloud/fit remain fresh.
|
||||
Loss freezes historical geometry. Saving/restoring still binds the retained
|
||||
projection to verified artifacts. Raw pose remains scanner telemetry, not a
|
||||
chassis pose or navigation contract.
|
||||
- `live_scene_delta.py`, `live_scene.py`: native Rerun static entities. Send
|
||||
changed chunk slots, pose/path, transform or validated window only. Keep cloud
|
||||
coordinates in their source frame under one accepted parent transform; do not
|
||||
apply the pose twice. Green indices stay attached to their own fitted input.
|
||||
Reference/blueprint is resent on initial load or display-setting changes.
|
||||
- `planning_live_api.py`: read-only same-origin delta GET with bounded cursor,
|
||||
run/view identity, no-store, 204 for unchanged data, and server-relative ages.
|
||||
No socket, auxiliary backend, extra sensor consumer, or per-client queue.
|
||||
- `planningSceneStream.ts`, `PlanningLiveScene.tsx`: one outstanding request,
|
||||
100 ms target start-to-start cadence, bounded request deadline, cursor reset
|
||||
after error, mode/end-state response fencing, disposal abort, and no transfer
|
||||
for unchanged completed views. Reject cloud/fit ages that expire during the
|
||||
full request duration. Transport failure hides stale evidence. A delayed
|
||||
`recording_open` event cannot restore a failed scene.
|
||||
|
||||
Rerun's static entity replacement is intentional: its viewer can physically
|
||||
discard superseded static values; fixed slot names avoid unlimited entity growth.
|
||||
This is not a claim of measured browser heap stability over long durations.
|
||||
[Upstream static-data semantics](https://rerun.io/docs/concepts/logging-and-ingestion/static).
|
||||
|
||||
## Validation evidence
|
||||
|
||||
`check_planning_live_bootstrap.py --fast-scene --queue-ingress` replays original
|
||||
receipt intervals through the real decoder, bounded queues, planning service,
|
||||
registration subprocess and production ASGI delta route. TestClient is in-process;
|
||||
it opens no second server and cannot send commands to K1. All input/code digests
|
||||
and executed sources are retained under the existing private causal-replay root.
|
||||
|
||||
First positive qualification: `20260919-fast-display-006-positive-001`, isolated
|
||||
run `7037b8d7-260c-4312-b4fa-21f552f0a15f`.
|
||||
|
||||
- All 11 acceptance checks passed; complete 108-hypothesis search, three fresh
|
||||
windows before tracking, subsequent accepted checks, distance 35.300 m.
|
||||
- Final overlap 99.1581%, inlier RMSE 0.14398 m. These are surface-registration
|
||||
statistics, not independent position error.
|
||||
- All 1,013 lidar and 1,013 pose receipts consumed, zero queue overflow;
|
||||
1,007 packets admitted to the bounded visual accumulator. Not every receipt
|
||||
must produce a visual frame: causal freshness and filtering remain active.
|
||||
- 931 scene requests. During accepted live display: 668 requests over 73.183 s,
|
||||
497 distinct latest cloud revisions (about 6.8 updates/s at this polling phase,
|
||||
with intermediate packets accumulated/coalesced rather than queued).
|
||||
- Delta export through ASGI: median 1.94 ms, p95 25.07 ms, max 60.29 ms.
|
||||
Latest receipt age at snapshot: median 75.17 ms, p95 518.40 ms, max 1.122 s.
|
||||
Typical reply 43.5 KB, p95 50.3 KB; full initial projection up to 1.63 MB.
|
||||
- These are receipt-to-server-snapshot/export measurements. They do not measure
|
||||
network-to-browser delay, native Rerun ingestion completion or GPU presentation.
|
||||
|
||||
Wrong-region qualification: `20260919-fast-display-006-negative-001`, same
|
||||
independent query but reference route 130–160 m with its own surrounding context.
|
||||
All 11 acceptance checks passed. Search ended without accepted tracking; the
|
||||
fast path did not invent an alignment or green confirmation.
|
||||
|
||||
The final delivery-age guard and preservation of stored display diagnostics were
|
||||
added after these first two replay seals. They do not change the numerical inputs
|
||||
or registration algorithm. Final-source validation is recorded below at handoff.
|
||||
|
||||
Final-source replay: `20260919-fast-display-006-positive-002`, isolated run
|
||||
`0a4d0d53-1877-473c-be94-926291dc415e`. All 11 checks passed, all 2,026 input
|
||||
receipts delivered, 1,007 visual packets, no queue overflow. 917 scene requests;
|
||||
accepted display 659 requests / 73.264 s and 494 distinct latest cloud revisions
|
||||
(6.74/s). Snapshot age median 73.47 ms, p95 521.19 ms, maximum 1.040 s. Export
|
||||
median 1.98 ms, p95 25.72 ms, maximum 62.20 ms. This final seal includes the age
|
||||
headers/guard and executed scene sources; it is still not a browser frame-rate test.
|
||||
|
||||
Final focused Python run: 103 passed. Architecture and stream tests, TypeScript
|
||||
typecheck and full frontend suite: 860 passed after the fullscreen correction.
|
||||
Production build passed. Ruff passed on eight changed Python files; git diff
|
||||
whitespace check passed. Known warnings: existing Vite large chunks and installed
|
||||
Starlette/httpx TestClient deprecation (no package installation attempted).
|
||||
|
||||
The browser check found that the existing fullscreen button depended on a live
|
||||
gRPC URL even for an independently rendered planning profile. The condition now
|
||||
admits that profile after capture ends; the existing expand/restore actions use
|
||||
canonical IconButton and retain the existing Escape handler. No new layout or
|
||||
fullscreen mechanism was added.
|
||||
|
||||
Controlled reload used the versioned LaunchAgent plan/apply path, unchanged plist
|
||||
SHA-256 `5d7a58122c016f8021fede27d1b21891ee20238fc38c281e5b33574ff8204e3f`,
|
||||
after fresh checks of idle control/source/camera, completed acquisition, resolved
|
||||
physical command and protocol-confirmed STOP. New canonical PID 7662 accepted
|
||||
health/readiness; 8765 had no listener. It restored the original physical run as
|
||||
completed/historical, not as live localization. The final frontend-only rebuild
|
||||
does not require another backend restart.
|
||||
|
||||
In-app browser QA on the final `app-BRD3dS1N.js`: actual retained physical run
|
||||
rendered through the new delta endpoint in normal 3D and top view. Expand,
|
||||
Escape restore, query-layer off (only gray reference remains) and on all passed.
|
||||
Historical status remained explicit; no live camera/green localization was
|
||||
invented. The real 3D case was left open for owner review, with one viewer only.
|
||||
No final-source browser live cadence or simultaneous camera+scene run is claimed;
|
||||
that is the next physical acceptance on the same known route.
|
||||
|
||||
Ops MISSIONCOR-81 updated through the direct Tasker MCP: 12 titled report/checker
|
||||
blocks appended, all previous 96 blocks preserved (108 total). Physical fast-view
|
||||
acceptance and independent navigation/failure-stop acceptance remain unchecked.
|
||||
|
||||
## Product and authority boundaries
|
||||
|
||||
The product-UI skill retained the existing Rerun host, scene modes, canonical
|
||||
controls, expand/restore and error surfaces. No new shared component, navigation
|
||||
root or LAB layout was introduced. Engineering diagnostics stay in evidence/Ops.
|
||||
|
||||
This change qualifies faster presentation, not obstacle perception or a control
|
||||
loop. A five-second map fit can coexist with fresh native pose, but the laboratory
|
||||
8 s fit-age allowance is not a safe vehicle reaction/stop budget. That requires a
|
||||
separate speed-dependent contract and measured failure response.
|
||||
|
||||
## Assessment for supervised motion
|
||||
|
||||
The physical 35.3 m walk is genuinely encouraging: all 15 fresh fits accepted,
|
||||
moving overlap about 96–98%, accepted transform yaw span about 0.197°, and no
|
||||
loss while walking. Terminal stale occurred after confirmed device STOP.
|
||||
This supports progressing toward a slow, bounded, supervised test on the known
|
||||
route; it does not establish a universal positional accuracy or safe clearance.
|
||||
|
||||
Missing evidence: surveyed/cross-measured route position and heading errors,
|
||||
repeat starts with varied offsets/orientations/heights, ambiguous/wrong locations,
|
||||
occlusion and receipt loss, and verified stopping before an unsafe distance is
|
||||
covered. High overlap and low fit RMSE cannot substitute for those tests. The
|
||||
previous 25 cm clearance idea is not validated by a 14 cm surface-fit RMSE.
|
||||
|
||||
Retain `vehicle_control=false` and `localization_confirmed=false`. No rover was
|
||||
moved and no K1 start/stop/calibration command was issued by this implementation.
|
||||
@@ -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 A130–155 m region as a negative control. If the
|
||||
correct prefix is ambiguous or rejected, record that limitation. Stationary
|
||||
snapshot acceptance alone is not temporal tracking or operational readiness.
|
||||
|
||||
## Validation and decision gate
|
||||
|
||||
Focused fixtures cover fault boundaries and unchanged event identity/time,
|
||||
post-gap three-window qualification, no old segment authority, prefix-only
|
||||
selection, motion rejection, yaw-anchor preservation and full-search rejection.
|
||||
Record UTC/monotonic times, input/code/output hashes, all seeds and clusters.
|
||||
Do not install the stationary initializer into the product merely because one
|
||||
snapshot looks good. Decide the next increment from positive and negative
|
||||
evidence. No new operator scan is needed for these bounded experiments.
|
||||
|
||||
## Results: controlled loss and recovery
|
||||
|
||||
Private experiment root: `data_dir/missions/causal-replays/20260919-recovery-stationary-001`.
|
||||
The fixed A interval is 39.926109 m and 52,965 retained points. A is session
|
||||
`20260911T085226Z_viewer_live`; B is `20260911T134352Z_viewer_live`. All prior A/B
|
||||
artifacts and failed/successful traces remain unchanged. The reference derives
|
||||
solely from A; B geometry does not select reference frames.
|
||||
|
||||
Baseline: 2026-09-19T12:01:13.261Z–12:02:16.501Z; start monotonic ns
|
||||
836950850212541. 974 delivered events, 63.125 s, 40.038 m. Initial candidate
|
||||
33.100 s, first tracking 42.343 s. Six accepted windows. Maximum delivery lag
|
||||
0.1672 s. This reproduces the previous outcome on the explicitly longer A map.
|
||||
|
||||
Loss probe: 2026-09-19T12:02:37.218Z–12:03:40.468Z; start monotonic ns
|
||||
837034809904750. 60 events suppressed, 914 delivered, 63.123 s / 40.027 m;
|
||||
maximum delivery lag 0.1525 s. No survivor payload or timestamp was changed.
|
||||
Original 9.973 s gap remains; the injected interval produces a second observed
|
||||
pose gap of 3.331 s / 2.569 m displacement.
|
||||
|
||||
| Event | Time from input start, s | State / evidence |
|
||||
| --- | ---: | --- |
|
||||
| Three-window qualification | 42.358 | tracking, segment 1 |
|
||||
| Deliberately absent input | 44–47 | last result still within existing 8 s freshness limit |
|
||||
| Resumed receipt reveals gap | 47.097 | lost; old matrix/windows cleared, segment 2 |
|
||||
| New bounded search completes | 49.637 | acquiring, new-segment candidate 1 |
|
||||
| Next fresh local refinement | 53.178 | acquiring, candidate 2 |
|
||||
| Third new-segment window | 58.602 | tracking restored |
|
||||
| Input ends at distance bound | 63.122 | lost; no current result retained |
|
||||
|
||||
The first post-gap candidate used a new 27-seed search, not the old fitted
|
||||
transform. It completed 2.540 s after observed resumption, at input age 2.572 s.
|
||||
Post-gap windows were at 26.138, 31.198 and 35.911 m, all within the selected A
|
||||
path, with overlap 99.09%, 98.55%, 98.02% and RMSE 0.1453, 0.1536, 0.1571 m.
|
||||
Requalification took 11.505 s from observed resumption. Thus recovery from this
|
||||
injected receipt loss is demonstrated, with the diagnostic 5 s fit cadence.
|
||||
This does not qualify a moving vehicle's reaction time. The short silence was
|
||||
recognized on the next pose; immediate silence detection is not claimed.
|
||||
|
||||
## Results: heading-free stationary prefix
|
||||
|
||||
The prefix contained 168 pose/cloud events through 9.933688 s and 8,227 retained
|
||||
points. Maximum recorded displacement was **0.0051505 m**. This is K1's own pose
|
||||
estimate, not an independent physical stillness measurement. No later B points,
|
||||
direction, fitted transform or correspondence mask was used. The wrong-region
|
||||
entry and basis came directly from A's saved trajectory, not a previous B seed.
|
||||
|
||||
The two sequential snapshot probes ran 2026-09-19T12:04:04.176Z–12:04:46.942Z;
|
||||
launcher monotonic interval 837121521919208–837164567013166 ns. The experiment
|
||||
uses a frozen already-collected prefix; it is not a second wall-paced replay.
|
||||
|
||||
| Control | Hypotheses | Compute wall time, s | Result |
|
||||
| --- | ---: | ---: | --- |
|
||||
| Correct A entry | 108/108 | 24.548 | candidate; one eligible cluster, 7 seeds |
|
||||
| Wrong A130–155 m entry | 108/108 | 16.560 | rejected; no eligible cluster |
|
||||
|
||||
Correct-entry overlap 89.815% within 0.5 m; inlier RMSE 0.17042 m. These are
|
||||
surface-fit statistics, not localization accuracy. Seven agreeing initial
|
||||
seeds are not seven independent observations. After both runs, a read-only
|
||||
comparison with the earlier causal A40 candidate showed 0.10251 m difference
|
||||
at the entry and 0.78413 degrees rotation. That comparison was not an input or
|
||||
gate, and is not ground truth.
|
||||
|
||||
An operational implementation would need about 10 s accumulation plus 25 s
|
||||
search on this host/input, before fresh validation. The successful search was
|
||||
close to the unchanged 25 s soft budget; a slower/ambiguous search must reject.
|
||||
Its old prefix already exceeds the current 8 s freshness limit on completion.
|
||||
Therefore **the snapshot candidate is not activated as live tracking**. It
|
||||
needs a separate provisional initialization result and fresh-window validation,
|
||||
followed by the temporal gate. The three-metre-heading requirement remains in
|
||||
the currently active product profile; stationary entry is an engineering probe.
|
||||
|
||||
## Implementation and verification
|
||||
|
||||
- `replay_faults.py`: explicit lazy time-window filtering, preserving survivor
|
||||
identity/timestamps and an audit of dropped sequence numbers.
|
||||
- `stationary_entry.py`: bounded prefix, motion/gap/completeness checks and
|
||||
`stationary-entry/v1` full-yaw policy. No heading extraction from B motion.
|
||||
- `entry_acquisition.py`: the existing clustering and search accept an explicit
|
||||
policy; the default travel-entry constants remain unchanged. Complete-search
|
||||
cardinality comes from the policy's translation/yaw grid.
|
||||
- `entry_acquisition_worker.py`: optional stationary mode in the same isolated
|
||||
worker, retaining one thread and a 30 s hard limit. Existing callers use the
|
||||
default travel mode. No hardware path or product selector added.
|
||||
- `check_planning_recovery.py`: fixed-reference preparation, separate baseline,
|
||||
loss and stationary stages, provenance and before/after digest verification.
|
||||
- `test_recovery_stationary.py`: fault boundaries, identity/time preservation,
|
||||
prefix-only geometry, motion rejection, 108 anchor-preserving hypotheses,
|
||||
partial-search rejection, gap reset and three new-segment candidates.
|
||||
|
||||
54 focused tests passed in 3.12 s, covering entry, causal replay, live profile,
|
||||
project results, registration and viewer replay; one existing Starlette/httpx
|
||||
deprecation warning. Ruff and `git diff --check` passed. Numerical jobs ran
|
||||
sequentially on Darwin arm64 / Python 3.12.13 / small_gicp 1.0.1, one CPU thread.
|
||||
No load test, Docker VM, new capture or vehicle commands. Frontend unchanged.
|
||||
All probe processes completed. The existing canonical server was left running;
|
||||
its `/api/health` returned operational `ok` after the experiments. No second
|
||||
server was started or product connection restarted. The engineering results and
|
||||
acceptance checker were saved in MISSIONCOR-81, preserving 40 structured blocks.
|
||||
|
||||
All input digests, reference artifact, executed-source hashes and 31/31/7
|
||||
baseline/loss/stationary artifact hashes were rechecked. Exact executed sources
|
||||
and focused tests are stored privately in `executed-source/`, with `seal.json`.
|
||||
Report SHA-256:
|
||||
|
||||
- baseline: `0c4b3d4f096f0deb065bfd21e0917f0414772861c8c6c72f44380d3f6e794f5d`
|
||||
- loss: `2ac13eae870a4c4eac5d0d95d35bdb72ab46c84e4a10bf29285fae593d6bcafb`
|
||||
- stationary: `1390f4b5ceb3188ff89c9a70ff34fb20cb02140335483f13ba88571a80ff2815`
|
||||
- seal: `619aec16a6f5921cdfbec2f2e7fda520bd6756c8c4caaf60f26a0f97b7083339`
|
||||
|
||||
## Decision
|
||||
|
||||
Controlled receipt-loss recovery passed on this recording. Stationary geometry
|
||||
supports a bounded heading-free initialization candidate on this pair, with
|
||||
the tested wrong region rejected. Neither result establishes arbitrary-site
|
||||
localization, independent pose accuracy, repeatability across locations or
|
||||
onboard resource qualification.
|
||||
|
||||
Next increment: make the stationary search result a provisional prior only;
|
||||
validate against a current cloud before temporal qualification, while the input
|
||||
continues. Separate startup waiting from maximum age while moving. Reduce
|
||||
repeated fixed-reference preparation if measured helpful, retaining frozen
|
||||
regression and ambiguity controls. Then qualify starts/orientations on held-out
|
||||
data and the physical camera/UI path. No new operator scan is needed yet.
|
||||
@@ -0,0 +1,152 @@
|
||||
# Registration stabilization after physical run 006
|
||||
|
||||
Date: 2026-09-19. Scope: LAB registration, live presentation, and reproducible
|
||||
qualification. Rover geometry, mounting profiles, obstacle clearance, controller
|
||||
integration, and autonomous motion are explicitly outside this change. Both
|
||||
`vehicle_control` and `localization_confirmed` remain false.
|
||||
|
||||
## Evidence and decision
|
||||
|
||||
The preceding diagnosis is in
|
||||
`2026-09-19-physical-start-006-diagnosis.md`. Physical run `ja-sun-006`
|
||||
(`ebee2e65-5d74-49f2-9489-1b2fecf30a76`) initially registered successfully.
|
||||
Its late rejection coincided with inadequate reference-map coverage past the
|
||||
selected 30.01 m route. The old renderer then selected the travel-heading hint
|
||||
instead of retaining the accepted transform. Neither the user's uncertain
|
||||
viewing angle nor this reconstruction establishes exactly when the user noticed
|
||||
the apparent rotation. The original report and raw recordings are not rewritten.
|
||||
|
||||
The change addresses these demonstrated boundaries, without weakening GICP,
|
||||
initial-search completeness, freshness, or temporal consistency acceptance.
|
||||
|
||||
## Implemented contracts
|
||||
|
||||
- `missions/reference_map.py`, `sources.py`: a live reference map includes up to
|
||||
20 m of recorded trajectory context on each side of the selected route.
|
||||
`route-context-map/v1` limits extraction to three tiles of at most 40 m,
|
||||
0.25 m voxel deduplication, and 100,000 points. Source identity and hashes are
|
||||
checked by the existing bound/submap path. Invalid distance ordering, excessive
|
||||
interval length, insufficient continuity, and oversized maps fail closed.
|
||||
Route geometry and its selected interval are unchanged. Recorded A/B comparison
|
||||
remains on its existing interval-based submap implementation.
|
||||
- `missions/live_presentation.py`, `live_tests.py`: latest display data, accepted
|
||||
alignment, and numerical evidence are separate. A newer same-segment cloud may
|
||||
advance under the last accepted transform only while its age is at most 2 s
|
||||
and the accepted fit age is at most 8 s. A rejected result cannot replace that
|
||||
transform. There is no travel-heading fallback before or after registration.
|
||||
- `missions/live_scene.py`: newer points use ordinary height colouring. Green
|
||||
correspondences belong only to the exact accepted fitted window and its own
|
||||
indices/transform. Loss, expiry, source change, and completion remove live
|
||||
highlighting. A terminal display is explicitly historical, not current pose.
|
||||
- New runs save a hashed `aligned-preview.npz`. Restored legacy runs use their
|
||||
last temporally accepted, hash-verified fit window. For old 006 this is a
|
||||
historical window, not a newly reconstructed full terminal cloud. Failed or
|
||||
absent integrity evidence does not authorize a substitute alignment.
|
||||
- `missions/projects.py`: archive rendering follows the same accepted-alignment
|
||||
rule; a versioned derived-scene cache prevents reuse of the old renderer output.
|
||||
Original result metrics remain visible with a note that the scene uses the last
|
||||
accepted alignment while the metrics describe the last calculation.
|
||||
- `sessions/live_planning.py`, K1 `planning_live.py`: preserve native pose
|
||||
orientation in the neutral event ABI. `live_tests.py` receives pose telemetry
|
||||
independently of the five-second fit cycle. This is K1 session-frame telemetry,
|
||||
not a chassis pose or a new navigation/control authority.
|
||||
- `stationary_live.py`: scene snapshots every 0.5 s; separate distance-limit,
|
||||
time-limit, cancellation, and input-end reasons. The 40 m LAB cap is unchanged.
|
||||
- Frontend `PlanningLiveScene.tsx`, `PlanningSpatialWorkspace.tsx`,
|
||||
`PlanningTestContext.tsx`, `planningPresentation.ts`, `planningProjects.ts`, and
|
||||
`PlanningProjectResult.tsx`: 0.5 s polling with unchanged-terminal revision
|
||||
suppression; explicit live/historical state and stricter cloud-age display
|
||||
gating. No new design primitive, control, CSS surface, or alternative viewer.
|
||||
|
||||
## Recorded-input qualification
|
||||
|
||||
The extended `scripts/check_planning_live_bootstrap.py` and
|
||||
`planning_archive_source.py` replay original receipt intervals at 1x through the
|
||||
actual ingress queue and K1 decoder. The production reference-map builder is used
|
||||
before sealing the reference. No physical K1 access, second server, accelerated
|
||||
load, camera replay, or browser timing simulation was used. A 120 s input budget
|
||||
allows the service's existing 40 m cap to terminate the run.
|
||||
|
||||
Private evidence lives under the canonical data directory at
|
||||
`missions/causal-replays/20260919-stabilization-006-positive-001` and
|
||||
`missions/causal-replays/20260919-stabilization-006-negative-001`.
|
||||
Each contains input/source hashes, copies of the 34 executed implementation files,
|
||||
reference provenance, receipt deliveries, decisions, scene measurements, RRDs,
|
||||
summary and artifact seal. The 82 original 006 artifacts were verified.
|
||||
|
||||
| Check | Correct entry | Wrong reference region |
|
||||
| --- | --- | --- |
|
||||
| Complete initial search | 108 hypotheses; ready at 25.405 s | Complete search, not timeout |
|
||||
| Tracking | Established at 38.356 s | Never established |
|
||||
| End | Distance limit at 100.691 s | Initial registration rejected |
|
||||
| Final accepted geometry | 98.232% overlap; inlier RMSE 0.1533 m | No accepted localization |
|
||||
| Delivered pose/cloud events | 2,014 | 2,016 |
|
||||
| Maximum replay delivery lag | 0.1679 s | 0.0555 s |
|
||||
| Qualification checks | 11/11 | 11/11 |
|
||||
|
||||
Positive run: `396d1cd4-e80e-4b87-9a9b-6d1022637404`,
|
||||
2026-09-19T18:29:55.348Z–18:31:36.318Z. Tracking persisted through the final
|
||||
accepted fit at 99.758 s, including the former failure region. The reference
|
||||
contains 70,870 points in two tiles, route indices 0–577, map indices 0–785
|
||||
(approximately 0–50 m). The published final distance is 39.486 m; the subsequent
|
||||
input reaches the 40 m termination gate. Terminal display sequence 1995 retains
|
||||
alignment sequence 1983, with no live green authority.
|
||||
|
||||
Negative run: `75cad066-9b20-496a-87f0-eda98a783997`; reference route 130–160 m
|
||||
and context approximately 110–180 m. Rejection at 24.443 s, without tracking.
|
||||
This is one wrong-region control, not a false-positive-rate estimate.
|
||||
|
||||
The positive replay produced 191 scene exports: server-side export latency
|
||||
p50 3.04 ms, p95 38.49 ms, maximum 171.82 ms. Maximum observed native pose age
|
||||
was 0.884 s. Pose queue overflow was zero; lidar overflow was 1 of 1,007
|
||||
published frames. Unconsumed terminal queue depths (pose 3, lidar 4) are separate
|
||||
from overflow. These are private replay/server measurements, not physical
|
||||
sensor-to-browser or sensor-to-controller latency guarantees. Surface overlap
|
||||
and inlier RMSE are not independent localization accuracy.
|
||||
|
||||
## Verification and runtime acceptance
|
||||
|
||||
- 99 focused Python cases passed: stabilization, live/project/failure boundaries,
|
||||
planner, stationary bootstrap, recovery, causal replay, entry, registration,
|
||||
and recording ingress.
|
||||
- Architecture 4/4, full frontend 854/854, TypeScript typecheck, then production
|
||||
build passed sequentially. Existing bundle-size warnings remain. Production
|
||||
asset: `app-D2qqIbEW.js`.
|
||||
- Ruff check passed for the nine selected changed/new Python modules and harness
|
||||
files; this is not a whole-repository lint claim. `git diff --check` passed.
|
||||
- Browser QA on real 8000: old 006 opens with the historical accepted transform;
|
||||
the original rejected result remains 49.4% / 0.266 m. Its explanatory note is
|
||||
readable after inspector scrolling. Planner and spatial normal/expanded modes,
|
||||
3D/top views, reset view, inspector maximize, and Escape were exercised. No
|
||||
browser console errors were reported. Narrow spatial mode retains existing
|
||||
limited toolbar/overlay space; expanded mode was restored. No live device was
|
||||
connected and no live end-to-end browser latency is claimed.
|
||||
- Before restart: source/control idle, completed acquisition, receiver stopped,
|
||||
physical command inactive. Versioned LaunchAgent plan/apply succeeded with
|
||||
backup and health acceptance; current and desired plist SHA-256 both
|
||||
`5d7a58122c016f8021fede27d1b21891ee20238fc38c281e5b33574ff8204e3f`.
|
||||
No repository/environment migration. PID 91643 → 99603.
|
||||
- Runtime started 2026-09-19T18:39:44.252799Z,
|
||||
`snapshot-runtime-564d78ca9bd1eb86e13bdce9b4b5be27`. Health operational,
|
||||
plugin runtimes 1/1, only canonical 8000 listening and no 8765 listener.
|
||||
Restored 006 is completed/ended/historical, both authority flags false.
|
||||
- No Docker workloads were started. Sequential replay/test/build/browser work
|
||||
respected the 18 GB host; reported free memory was 34–42% around these checks.
|
||||
Replay publishers and numerical workers terminated and released leases.
|
||||
|
||||
## Next physical acceptance
|
||||
|
||||
Ready for another short manually carried K1 test near the known start, with a
|
||||
new recording name. Wait stationary for actual tracking, then walk 20–30 m with
|
||||
the same reference. Do not interpret a fixed wait time, scanner calibration
|
||||
success alone, or one green cloud as permission to start. If tracking is lost,
|
||||
stop the walk, retain the evidence, and finish recording through normal controls.
|
||||
Evaluate startup, motion, height/orientation change, scene freshness, terminal
|
||||
view, recording closure and replay together. The entire raw/archive/camera path
|
||||
must still be accepted physically; this qualification did not exercise RTSP/MSE.
|
||||
|
||||
The currently qualified initial search radius remains 5 m. A 10 m start envelope,
|
||||
arbitrary-route global relocalization, generalized failure recovery, onboard
|
||||
timing, independent pose accuracy, and autonomous driving are not accepted by
|
||||
these results. No rover profile or footprint decision is needed to validate this
|
||||
registration change.
|
||||
@@ -0,0 +1,127 @@
|
||||
# K1 stationary start: runtime scheduling correction
|
||||
|
||||
Date: 2026-09-19. Mission Core Ops: MISSIONCOR-81; camera baseline: MISSIONCOR-3.
|
||||
Follow-up to `2026-09-19-physical-start-005.md`. The owner confirmed K1 was off
|
||||
before the canonical local service was restarted. No scanner command was issued.
|
||||
|
||||
## Finding and correction
|
||||
|
||||
The canonical8000 LaunchAgent declared `ProcessType=Background`; launchctl also
|
||||
reported `spawn type = background (5)` and the Python service had priority4.
|
||||
The localization child inherits this resource class. Apple's launchd manual
|
||||
describes Background as applying CPU and I/O resource limits. This is unsuitable
|
||||
for the application's operator-requested camera ingestion and bounded initial
|
||||
localization. An HTTP request does not provide the XPC activity needed to promote
|
||||
an Adaptive job. Source: installed `launchd.plist(5)` and
|
||||
[Apple's launchd manual](https://github.com/apple-oss-distributions/launchd/blob/main/man/launchd.plist.5).
|
||||
|
||||
Reproducing the *same frozen005 input* under `/usr/sbin/taskpolicy -b`, with no
|
||||
camera or generated load, again exhausted the unchanged deadline. This identifies
|
||||
an independently reproducible scheduling cause of the slowdown. It does not prove
|
||||
the origin of the corrupt H.264 already present in the physical005 archive.
|
||||
|
||||
The versioned `src/k1link/local_service_launchd.py` now declares Interactive, the
|
||||
ordinary app resource class. It does not set realtime priority, alter system
|
||||
resource limits, or change algorithms. The pre-apply plist comparison found
|
||||
exactly one changed key, `ProcessType`; environment, arguments, repository,
|
||||
watchdog, process-group ownership, data and camera/map configuration are identical.
|
||||
|
||||
`scripts/manage_mission_core_launch_agent.py` applied the hash-gated plan, made a
|
||||
private backup, reloaded the exact label and accepted `/api/health`. Its automatic
|
||||
rollback remained available. Previous plist SHA-256:
|
||||
`6b651aedb54236f76bf062a2319d9223f0b00f7d264451b3bbaccbee0896e065`;
|
||||
new SHA-256:
|
||||
`5d7a58122c016f8021fede27d1b21891ee20238fc38c281e5b33574ff8204e3f`.
|
||||
Post-apply launchctl: `spawn type = interactive (4)`; one Python listener on8000,
|
||||
none on8765; health operational. UI entry asset: `/assets/app-DKxsidxk.js`.
|
||||
|
||||
## Evidence and bounded combined replay
|
||||
|
||||
Private evidence root, outside Git:
|
||||
`NODEDC_MISSION_CORE/.runtime/mission-core/missions/diagnostics/20260919-runtime-scheduling-fix`.
|
||||
It retains the background reproduction, applied plan, runtime acceptance,
|
||||
test/build logs, UI fixture acceptance and `combined-replay-02`.
|
||||
The prior005 sealed capture is unchanged. Input identity:
|
||||
`b72e1f202a04c5455aa10d2907a3912bfb3fbf76b84a079746b23aaa0cc0f9eb`.
|
||||
|
||||
| Execution | Entry seeds | Search wall time | Decision |
|
||||
| --- | ---: | ---: | --- |
|
||||
| Physical005, before correction | 54/108 | 25.4795 s | reject incomplete search |
|
||||
| Exact saved input, Background, no camera | 38/108 | 25.8272 s | reject incomplete search |
|
||||
| Prior ordinary isolated child | 108/108 | 16.0428 s | complete candidate search |
|
||||
| Ordinary combined archive replay | 108/108 | 14.1715 s | complete candidate search |
|
||||
|
||||
The combined run replays the first65seconds of physical005 MQTT at original
|
||||
receipt intervals through `LivePerceptionIngress`, the production K1 planning
|
||||
decoder, `PlanningLiveTests`, bounded entry child, three disjoint fresh validation
|
||||
windows and Rerun scene generation every2seconds. Search process CPU time was
|
||||
14.1691seconds, close to wall time. Full108-seed coverage, five supporting seeds,
|
||||
ambiguity rejection, original25second search/30second child deadlines and fresh
|
||||
validation thresholds remain unchanged. Overlap96.3758% and inlier RMSE0.151095m
|
||||
match the previously observed candidate; these are not measured position accuracy.
|
||||
|
||||
In parallel, the helper `scripts/planning_archive_camera.py` feeds the clean
|
||||
canonical camera baseline A, session `20260822T105904Z_viewer_live`, through the
|
||||
real fMP4 parser, per-segment durable archive, bounded preview queue and a local
|
||||
FFmpeg decoder. The helper has no RTSP or hardware producer. All70 retained media
|
||||
fragments keep their recorded intervals and exact bytes; no generated load or
|
||||
accelerated playback is used. The baseline's legacy index does not contain an
|
||||
init receipt timestamp: init is delivered with the first media receipt, so no
|
||||
initialization-latency claim is made.
|
||||
|
||||
Camera commits span0.182–24.601seconds and overlap the entry search. All70 media
|
||||
fragments and init were archived and delivered byte-for-byte;70 frames decoded;
|
||||
decoder warning/error log empty; no slow-reader retirement. Producer/decoder
|
||||
children, parser/preview threads and planning leases were released. The helper
|
||||
adds a camera acceptance report to the existing replay manifest and source seal.
|
||||
|
||||
Fresh validation reached tracking at37.591seconds after archive playback started.
|
||||
Further fresh results stayed tracking until the65second retained prefix ended;
|
||||
the service then correctly revoked live authority. No prior, stale sample or
|
||||
completed run was promoted to live tracking. All combined acceptance checks passed.
|
||||
|
||||
This is stronger than the earlier cloud-only replay, but is **not** full physical
|
||||
acceptance: the ordinary acquisition authority/publisher, K1 RTSP encoder/network
|
||||
and browser MSE are not exercised by the replay. Clean baseline video cannot
|
||||
retroactively repair or certify physical005's corrupt archive.
|
||||
|
||||
## Operator presentation
|
||||
|
||||
Initial unsuccessful binding now says «Поиск остановлен» and explicitly instructs
|
||||
the operator to stop the device/recording and start a new study for another attempt.
|
||||
It has no continuing busy indicator. The production K1 status component wraps
|
||||
the instruction, uses the canonical11px token and keeps it visible below960px.
|
||||
Device authority, stop precedence, lost-after-established tracking, stale-data
|
||||
rejection and session identity fences remain unchanged.
|
||||
|
||||
Changed owners: `stationary_live.py`, `planningPresentation.ts`,
|
||||
`K1SpatialSession.tsx/.css`, the duplicate responsive rule in plugin `styles.css`.
|
||||
Entry diagnostics now also retain process CPU time in `entry_acquisition.py`;
|
||||
the decision still uses wall time.
|
||||
|
||||
## Validation and limitations
|
||||
|
||||
- 48 focused backend tests passed: LaunchAgent plan/apply, entry acquisition,
|
||||
stationary bootstrap and live planning.
|
||||
- 55 existing camera gateway/archive regression tests passed.
|
||||
- Architecture4/4, full TypeScript check, frontend853/853 and production build
|
||||
passed sequentially. Build retains the existing large-chunk warning.
|
||||
- Ruff passed for changed Python owners and replay helpers.
|
||||
- Actual8000 UI opened a retained aligned project and its inspector; ordinary
|
||||
and expanded window states work. Close-button behavior works. The existing
|
||||
floating inspector did not close on Escape; that unrelated behavior is retained
|
||||
as a follow-up, not represented as a passed check.
|
||||
- The terminal status was rendered from the production React component and built
|
||||
stylesheet in an explicitly labelled offline fixture at1280px and800px. Full
|
||||
instruction visible; no ellipsis or overflow; desktop scroll height equals
|
||||
client height45px. This is presentation QA, not a fabricated live session.
|
||||
The temporary fixture route/tab was removed and viewport override reset.
|
||||
- No Docker VM or stress test was started. Memory pressure remained healthy
|
||||
(48–49% reported free), swap did not grow during qualification. Temporary
|
||||
children exited. The canonical8000 service remains running.
|
||||
|
||||
Next gate: qualify the remaining acquisition/MSE boundaries without changing
|
||||
scanner state, then perform a short physical stationary start with continuous
|
||||
camera and fresh tracking. A long field walk and autonomous vehicle authority
|
||||
are not accepted by this result. Preserve physical005's unresolved STOP evidence;
|
||||
the owner's report that the scanner is off is not a fabricated READY receipt.
|
||||
@@ -0,0 +1,130 @@
|
||||
# Selected route owns the live walk distance
|
||||
|
||||
Owner correction: remove arbitrary upper distance limits, not replace 40 m with
|
||||
100 m. The operator may select 100, 200, 300 m or another available reference
|
||||
interval. No physical acquisition or movement was requested or initiated here.
|
||||
|
||||
## Contract and implementation
|
||||
|
||||
- `live_limits.py`: new live runs seal the selected draft's actual `length_m`
|
||||
as `maximum_distance_m`. No upper route length and no overall run timer.
|
||||
The existing 3 m lower bound belongs to the initial heading basis, not a
|
||||
maximum walk distance. Nonfinite or invalid lengths still fail admission.
|
||||
- `live_tests.py`, `stationary_live.py`: use that sealed distance, not a
|
||||
constant 40 m. Remove both the 300 s active and 1800 s waiting termination.
|
||||
Completing the selected distance ends the calculation only; it never sends
|
||||
scanner STOP or stops the independent raw recording. Identity, freshness,
|
||||
geometric gates and individual native-worker deadlines remain unchanged.
|
||||
- `reference_map.py`, `sources.py`: cover the complete selected interval plus
|
||||
the existing 20 m context on either side where the recording permits it.
|
||||
Prepare as many <=40 m extraction tiles as necessary; 40 m is now a tile
|
||||
size, not a route cap. No tile-count or whole-map point-count truncation.
|
||||
Merge/deduplicate one tile at a time at the existing 0.25 m voxel scale.
|
||||
Cancellation is checked between tiles and after preparation; it releases
|
||||
leases without starting acquisition. One extractor invocation still has its
|
||||
own bounded CPU/data contract.
|
||||
- `reference_window.py`: retain the complete route map for evidence, but for
|
||||
maps above the existing 100,000-point numerical budget select a local target
|
||||
around the initialization/accepted pose. Radius includes all current query
|
||||
points and a 10 m search/correction neighbourhood. Smaller maps retain their
|
||||
exact target. Local targets are never silently thinned to pass the budget;
|
||||
missing/too-dense local support fails explicitly. Each calculation seals
|
||||
its window bounds, counts and target-array SHA-256 with its input artifact.
|
||||
- `live_buffer.py`: after 2000 retained trajectory vertices, decimate historical
|
||||
display geometry while retaining the first and latest pose. Previously the
|
||||
frozen tail could overcount subsequent distance or cause false jump errors.
|
||||
Raw evidence remains untouched. Display decimation is not a pose reset.
|
||||
- `PlanningProjectSettings.tsx`, `planner.ts`: reuse existing start/end metre
|
||||
controls and `Вся траектория`. Live admission has no upper distance cap.
|
||||
End selection chooses the preceding recorded pose rather than overshooting
|
||||
the typed endpoint. The actual selected length remains visible.
|
||||
|
||||
The separate one-shot **two saved recordings** comparison is not the live
|
||||
tracking pipeline. Its existing explicit 3–40 m admission remains unchanged;
|
||||
extending that batch estimator is not claimed by this live-walk change.
|
||||
Developer archive probes retain their own explicit wall bounds; these are not
|
||||
operator run limits.
|
||||
|
||||
## Evidence and tests
|
||||
|
||||
Immutable physical query: `20260919T185447Z_viewer_live`, original live run
|
||||
`27a20878-9697-4735-a86e-763de28e4b67`, distance 35.300 m. Reference:
|
||||
`20260911T085226Z_viewer_live`, full recorded path 487.611 m. Existing reports,
|
||||
raw captures, metadata and code are hashed by `check_planning_live_bootstrap.py`.
|
||||
Its new `--route-length-m` changes only the isolated probe's reference selection,
|
||||
not any operator draft or original result. It reuses real receipt queues,
|
||||
registration workers and in-process production scene GETs, without a listening
|
||||
server, physical commands or a new capture.
|
||||
|
||||
`20260919-selected-route-006-100m-001`: all 11 checks passed. Reference 141,543
|
||||
points / four extraction tiles; numerical targets 37,482–73,356 points.
|
||||
All 1013 pose and 1013 lidar receipts consumed, zero overflow. All 15 fresh
|
||||
fits accepted. Last overlap 0.98911, inlier RMSE 0.14794 m. This seal preceded
|
||||
only incremental map merge/cancellation handling; the full 300 m repeat uses
|
||||
the later sources.
|
||||
|
||||
`20260919-selected-route-006-300m-001`, run
|
||||
`02f063a1-48d8-4f9c-9ddc-ae3ae4819686`: all 11 checks passed. Actual selected
|
||||
distance 299.929 m, map 302,013 points / nine tiles, local numerical targets
|
||||
37,480–73,355. All 15 fresh fits accepted over the existing 35.300 m query;
|
||||
final overlap 0.98945 and inlier RMSE 0.14794 m. Pose overflow zero, lidar
|
||||
overflow **2 of 1013**; do not call this a zero-drop run. No tracking loss before
|
||||
input ended. This is larger-map regression, not a 300 m independent traversal.
|
||||
|
||||
`20260919-selected-route-006-wrong-001`, run
|
||||
`97c40331-e1f5-48dc-ab8d-dce9c85f55e8`: all 11 checks passed using the wrong
|
||||
reference interval starting at 130 m and extending another 100 m. Complete
|
||||
108-seed search rejected the location; no tracking or accepted transform was
|
||||
invented. One lidar receipt overflow, no pose overflow. Original inputs and
|
||||
executed source hashes remained unchanged in both final-source qualifications.
|
||||
|
||||
Focused backend validation: 128 passed. Includes live stop at selected 20,
|
||||
50, 100, 200 and 300 m; no hidden waiting/active timer; admission at 10 km;
|
||||
coverage across multiple tiles; cancellation and lease cleanup; local-target
|
||||
budget failures; path continuation past 2000 vertices; historical display,
|
||||
stale/identity/ambiguity and failure regressions. Synthetic distances prove
|
||||
software termination semantics, not physical localization accuracy.
|
||||
|
||||
Frontend architecture 4/4, TypeScript typecheck, complete frontend suite
|
||||
861/861 and production build passed sequentially. Ruff passed for nine checked
|
||||
changed/new Python modules/tests; whitespace check passed. Existing Vite
|
||||
large-chunk and TestClient/httpx deprecation warnings remain. No Docker VM or
|
||||
second backend; free-memory observation 50–54%, swap stable at 5784.62 MB across
|
||||
checks. Temporary probe/native/test/build workers exited before the next stage.
|
||||
|
||||
## Runtime and interface acceptance
|
||||
|
||||
Canonical service reloaded through `manage_mission_core_launch_agent.py apply`
|
||||
after fresh proof: source/control/camera idle, acquisition null, physical command
|
||||
resolved/inactive, active planning run completed. Current/desired plist hashes
|
||||
both `5d7a58122c016f8021fede27d1b21891ee20238fc38c281e5b33574ff8204e3f`.
|
||||
Health accepted; new PID 11459. No data-root migration or device commands.
|
||||
Production bundle `app-DWoNbjNw.js`.
|
||||
|
||||
In-app browser on the rebuilt app: existing reference JA-SADOVAYA-001; typed
|
||||
100 m selected 99.912 m, typed 300 m selected 299.929 m, and whole trajectory
|
||||
selected 487.611 m. Start stayed enabled in all three cases. These are actual
|
||||
recorded-pose endpoints, not fabricated exact-metre positions. Normal/expanded
|
||||
settings and Escape passed. The temporary client-only draft was neither saved
|
||||
nor started. Returned to the original physical ja-sun-006 historical 3D result;
|
||||
scene expansion and Escape restored correctly. No real-time rate or new
|
||||
physical-distance accuracy was measured by this UI check.
|
||||
|
||||
Ops MISSIONCOR-81 updated through the direct Tasker MCP: 12 titled report/checker
|
||||
blocks appended, previous 108 preserved (120 total). Physical long-walk and
|
||||
navigation acceptance remain unchecked.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Removing a route cap is not proof of unlimited hardware capacity. The complete
|
||||
reference geometry, preparation time and stored evidence still scale with the
|
||||
selected interval. This is not a disk-paged city-scale map implementation.
|
||||
Individual extraction/registration budgets, corruption checks and freshness
|
||||
gates remain real. No distance-based quality claim or automatic threshold
|
||||
relaxation is introduced. Surface overlap/RMSE is not independent positional
|
||||
accuracy. A physical 100/200/300 m repeat remains to be measured.
|
||||
|
||||
The product-UI skill kept the existing inspector, canonical RangeControl/Button
|
||||
and viewer composition; no new setting, visual primitive or product root.
|
||||
Only the canonical durable service on 8000 may be reloaded after idle proof;
|
||||
no duplicate server, Docker workload or rover/K1 command is admitted.
|
||||
@@ -0,0 +1,157 @@
|
||||
# Stationary prior → fresh validation → temporal tracking
|
||||
|
||||
## Objective and frozen protocol
|
||||
|
||||
Connect the previously qualified stationary snapshot initializer to current-data
|
||||
validation. The owner accepts a startup wait. This increment checks the complete
|
||||
causal chain on the existing independent A/B recordings, without another physical
|
||||
scan, vehicle commands, product UI changes or activation in the live profile.
|
||||
|
||||
The immutable pre-execution manifest is in private
|
||||
`data_dir/missions/causal-replays/20260919-stationary-bootstrap-002`.
|
||||
Preparation `-001` was not executed: a formatting-only script correction preceded
|
||||
preparation `-002`. No numerical attempt is omitted.
|
||||
|
||||
Inputs reuse the preceding recovery experiment's A forward 39.926109 m / 52,965
|
||||
points, B's raw transport and actual receipt clock, and the frozen negative A
|
||||
130–155 m geometry. A is `20260911T085226Z_viewer_live`; B is the independent
|
||||
`20260911T134352Z_viewer_live`. Input hashes are verified before and after each
|
||||
run. No previous fitted B matrix, overlap mask or later travel heading seeds the
|
||||
search. Metadata's one pending event supplies only its due time until delivery.
|
||||
|
||||
Policy `stationary-fresh-bootstrap/v1`:
|
||||
|
||||
1. Collect the first 10 s incrementally. Recorded motion must be ≤0.10 m; require
|
||||
continuity and ≥300 retained points. Each event is checked, including poses
|
||||
that the spatial buffer would otherwise thin. Prefix provenance is bounded.
|
||||
2. Freeze only when every due prefix event has arrived. Run the existing
|
||||
`stationary-entry/v1` search: 108 hypotheses, complete-search/support/ambiguity
|
||||
gates, 25 s soft / 30 s process deadline, no change to local GICP thresholds.
|
||||
3. A successful old fit is **only a provisional seed**. It contributes zero
|
||||
temporal confirmations, no live matrix and no green/tracking authority. Its
|
||||
source timestamp remains unchanged. Maximum search wall time is 30 s; maximum
|
||||
prior source age 40 s; there is one initialization attempt and one seed trial.
|
||||
4. Start a new empty buffer after the search actually completes. Exclude every
|
||||
receipt at or before completion, including backlog delivered late. Require at
|
||||
least 2 s of new cloud observations. Trial must complete within the prior's
|
||||
10 s lifetime. Input continues at original 1× pace during calculation.
|
||||
5. Fit current cloud using the provisional seed, then apply unchanged
|
||||
`causal-consistency/v1`: source age ≤8 s, three consistent candidates, ≤0.5 m
|
||||
position change and ≤5° rotation change. Subsequent fits are at least 5 s apart.
|
||||
Observation sequence IDs in successive fit windows are disjoint; the buffer
|
||||
resets at each submitted sample, while new receipts continue accumulating.
|
||||
6. Reject bad/partial/expired initialization, failed fresh fit, session/generation
|
||||
change, source-order regression, fresh-phase receipt gap, silence and input
|
||||
end. Never retry a failed provisional seed or fall back to future travel
|
||||
heading. A late worker result cannot reactivate an ended run.
|
||||
|
||||
The original B receipt gap occurs while initialization is running. One gap before
|
||||
readiness may retain an old same-session transform **as a hypothesis only**,
|
||||
subject to the separate fresh checks above. It cannot bypass `CausalTracking`'s
|
||||
old-segment rejection. Any gap after readiness discards the one-shot prior and
|
||||
tracking. Session/generation identity is enforced; subtle unreported SLAM frame
|
||||
resets are not verified (`slam_reset_verified=false`). This distinction is an
|
||||
experimental assumption, not a deployment claim.
|
||||
|
||||
## Implementation
|
||||
|
||||
- `missions/stationary_entry.py`: shared incremental bounded prefix collector;
|
||||
old snapshot entry point wraps the same collector.
|
||||
- `missions/stationary_bootstrap.py`: provisional state, source/freshness fences,
|
||||
one-shot trial, disjoint fresh windows and existing temporal gate.
|
||||
- `missions/stationary_replay.py`: bounded 1× orchestration, one isolated numerical
|
||||
worker, concurrent input delivery, stage provenance, historical-only late jobs.
|
||||
- `scripts/check_stationary_bootstrap.py`: frozen sources/code/expected outcomes,
|
||||
sequential positive and wrong-region controls, immutable exact source copies.
|
||||
- `tests/test_stationary_bootstrap.py`: authority, identity, timeout, gap,
|
||||
partial-search and observation-fence cases.
|
||||
|
||||
No change to the running travel-entry product profile, frontend, capture
|
||||
ownership, device protocols or old saved experiments.
|
||||
|
||||
## Results
|
||||
|
||||
Both complete 1× probes passed their frozen expectations. Darwin arm64,
|
||||
Python 3.12.13, small_gicp 1.0.1; single isolated CPU worker, sequential runs.
|
||||
|
||||
Positive: 2026-09-19 12:26:30.527–12:27:33.648 UTC;
|
||||
monotonic start 838468170195291. Delivered 974 events in 63.122767 s,
|
||||
40.037509 m; maximum delivery lag 0.154619 s. The original 9.972693 s receipt
|
||||
gap and 10.406525 m recorded displacement remain present. No event is replayed
|
||||
early. The selected fit windows lie inside the reference's 39.926109 m extent;
|
||||
the final pose crosses 40 m and ends the probe without another fit.
|
||||
|
||||
The initial prefix remains 168 events / 8,227 retained points, maximum recorded
|
||||
motion 0.0051505 m. It is frozen at 10.008045 s without a B travel heading.
|
||||
All 108 hypotheses complete in 20.980390 s (worker wall 21.158549 s), producing
|
||||
the same geometric candidate as the preceding snapshot: overlap 89.8145%,
|
||||
RMSE 0.170424 m. This run's timing is a single measurement, not a guaranteed
|
||||
startup deadline. At completion the newest included cloud is 21.482298 s old:
|
||||
its original time is retained and it is not admitted to the live gate.
|
||||
|
||||
| Stage | Seconds from first receipt | Current-data age | Temporal streak |
|
||||
|---|---:|---:|---:|
|
||||
| Provisional seed ready | 31.166594 | old prefix; no live admission | 0 |
|
||||
| First fresh fit | 33.807565 | 0.371100 s | 1 |
|
||||
| Second fresh fit | 38.757844 | 0.372068 s | 2 |
|
||||
| Tracking established | 43.823601 | 0.658728 s | 3 |
|
||||
| Further fresh fits | 48.835069 / 53.928964 / 59.181734 | 0.453103 / 0.475792 / 0.308357 s | 4 / 5 / 6 |
|
||||
| Input ends, state cleared | 63.122606 | — | 0 |
|
||||
|
||||
Six fresh windows use disjoint observation IDs, all strictly after initial search
|
||||
completion; each cloud time is ≤request time≤completion time. Their B distances
|
||||
are 14.624 / 19.049 / 23.296 / 27.295 / 31.905 / 36.019 m. Fit worker wall times
|
||||
are 0.224275–0.328083 s including process overhead; overlaps 98.198–99.260%,
|
||||
inlier surface RMSE 0.148757–0.154952 m. These are geometric consistency metrics,
|
||||
not independent position errors. Disjoint receipt IDs are not independent SLAM
|
||||
maps; overlapping real surfaces are expected.
|
||||
|
||||
Negative A 130–155 m: 12:27:58.108–12:29:01.231 UTC;
|
||||
monotonic start 838555753631833. Same 974 B events / 40.037509 m,
|
||||
maximum delivery lag 0.145273 s. All 108 hypotheses finish in 14.933964 s;
|
||||
`no-admissible-entry`, rejected at 25.165285 s. No prior, fresh candidate or
|
||||
tracking appears; no automatic travel-heading fallback. One wrong area is not
|
||||
an estimate of false-positive frequency across a location catalogue.
|
||||
|
||||
## Validation and retained evidence
|
||||
|
||||
65 focused tests passed in 3.34 s: stationary bootstrap, preceding stationary
|
||||
prefix and receipt-loss tests, entry acquisition, causal replay, live planning,
|
||||
projects, registration and archived viewer replay. Ruff and `git diff --check`
|
||||
passed. Existing Starlette/httpx deprecation warning remains.
|
||||
|
||||
Verified input hashes before/after runs, frozen executed-source hashes, all 36
|
||||
positive and 6 negative artifacts, source-time ordering, disjoint fresh windows,
|
||||
zero authority from the old fit and cleared state at end. The private directory
|
||||
contains the protocol manifest, exact code, per-step inputs/results/provenance,
|
||||
receipt delivery logs, test output, summary and seal. Geometry stays outside Git.
|
||||
|
||||
SHA-256:
|
||||
- Positive report: `d9cbd3059531a1c26d7101a11d0d9a73c7234dfe360eae55d19ae27956a72452`
|
||||
- Negative report: `723d7654d49a326a783fcc81332bb67cfe0dce5323195ca2a286de634e6ba9c9`
|
||||
- Seal: `ffd380e20f46b2c70d195702e86c769b592f2259be01e2aca6806e45cdf293ef`
|
||||
|
||||
Memory checks before runs showed free-memory percentage 54% then 51%; swap
|
||||
4522.31 → 4370.31 MB. Docker VM was not started. Temporary worker processes exited
|
||||
between runs. The canonical server was left running; no duplicate backend or
|
||||
product restart was needed.
|
||||
|
||||
## Decision and next boundary
|
||||
|
||||
On this independent recording pair the complete sequence now works causally:
|
||||
stationary prefix → bounded search → non-authoritative prior → current cloud
|
||||
confirmation → temporally consistent tracking. Full readiness is about **44 s**
|
||||
from first receipt in this probe, not the 21 s search duration or 31 s prior time.
|
||||
The existing B moved while calculation ran; this is not a new physical test of
|
||||
a rover holding still throughout readiness.
|
||||
|
||||
This research protocol remains uninstalled in the product's live planning
|
||||
profile; its default is still travel-entry. Next, connect this same bootstrap
|
||||
state machine to the existing live profile and recording lifecycle, preserve
|
||||
camera/scene controls, expose preparation/confirmation/readiness through existing
|
||||
product states, and test teardown/reconnection without another scan. A separate
|
||||
choice is required for route entry assumptions and trustworthy frame-reset
|
||||
signalling. Then qualify different initial positions/headings and the actual
|
||||
board, and perform a physical validation pass. No unrestricted start, navigation
|
||||
accuracy, obstacle avoidance, permissible speed or autonomous driving is accepted.
|
||||
The 5 s diagnostic cadence and 8 s freshness bound are not a motion-control loop.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Bounded initial-search repair and fresh confirmation
|
||||
|
||||
The owner requested a repair after the stationary physical start failed before
|
||||
tracking. The failed input and report remain immutable; see
|
||||
`2026-09-19-physical-start-diagnosis.md`.
|
||||
|
||||
## Changes
|
||||
|
||||
`PreparedReference` builds the invariant reference voxel cloud, covariances and
|
||||
search tree once per entry-search worker. Each hypothesis retains its original
|
||||
seed-dependent query voxelization and patch-centre correction calculation.
|
||||
Single registration still prepares its own target. No point budget, resolution,
|
||||
correspondence distance, overlap/RMSE threshold or correction bound was relaxed.
|
||||
|
||||
`entry-multistart/v2` and `stationary-entry/v2` visit central translations and
|
||||
nearby headings first, retaining stable seed identities and the complete set of
|
||||
108 stationary hypotheses. All hypotheses, cluster support, multiple translation
|
||||
seeds and ambiguity checks remain mandatory; the deadline remains 25 seconds
|
||||
inside a 30-second isolated worker. An incomplete search still fails closed.
|
||||
|
||||
`stationary-fresh-bootstrap/v2` accounts for an existing receipt gap straddling
|
||||
worker completion. Before the first fresh query cloud and before the single
|
||||
prior trial is consumed, at most one segment boundary relative to the initial
|
||||
prefix can begin the fresh window. The ready timestamp, ten-second prior
|
||||
lifetime and strict post-ready receipt fence do not move. A second boundary,
|
||||
any gap after a fresh query cloud or pending fit, expiry, stale result or loss
|
||||
of identity still invalidates the prior/tracking. Three disjoint, consistent
|
||||
fresh windows remain necessary for tracking.
|
||||
|
||||
This boundary was discovered by retaining a failed intermediate positive
|
||||
control: the faster worker finished just before the recorded ten-second gap
|
||||
ended. Treating that as loss of already established tracking was incorrect.
|
||||
The failed control remains in `20260919-entry-fix-positive-001`.
|
||||
|
||||
The shared presentation now records whether tracking was ever established.
|
||||
An initial failure reads «Привязка не выполнена»; actual loss after tracking
|
||||
reads «Привязка потеряна». Incomplete initial search has specific operator copy.
|
||||
Fresh results and phase copy are committed atomically, removing a transient
|
||||
«Кандидат совмещения» detail during confirmation/tracking. No new control,
|
||||
layout, styling, navigation or device command was added.
|
||||
|
||||
## Qualification
|
||||
|
||||
The extended `check_planning_live_bootstrap.py` and private
|
||||
`ReceiptQueueArchiveSource` run the actual `PlanningLiveTests` service against
|
||||
immutable raw captures at original 1x receipt intervals. Each probe has its own
|
||||
production `LivePerceptionIngress` and `K1PlanningLiveSource`; it never attaches
|
||||
to the live scanner or canonical backend. Original timestamps, rebased receipt
|
||||
mapping, source hashes, exact executed source and decision files are retained.
|
||||
The production RRD scene method runs every two seconds, matching UI polling.
|
||||
One numerical worker and one bounded probe run at a time.
|
||||
|
||||
Three final probes passed all source/code integrity, fresh-window disjointness,
|
||||
complete-search, authority-removal and lease-release checks:
|
||||
|
||||
| Private evidence directory | Full search | First tracking | Fresh fits | Result |
|
||||
| --- | ---: | ---: | ---: | --- |
|
||||
| `20260919-physical-fix-live-002` | 10.683 s | 33.981 s | 9 | Last physical stationary capture, original reference and section |
|
||||
| `20260919-entry-fix-positive-002` | 18.153 s | 44.034 s | 6 | Previous independent pass with its original receipt gap |
|
||||
| `20260919-entry-fix-negative-001` | 13.191 s | none | 0 | Wrong reference region, no admissible entry |
|
||||
|
||||
Directories are under `data_dir/missions/causal-replays`. The physical replay
|
||||
ran 2026-09-19 14:03:47.770–14:04:52.857 UTC. All 108 hypotheses completed in
|
||||
each final probe. Physical-replay fresh fits took 0.252–0.412 seconds; accepted
|
||||
source ages were 0.269–0.648 seconds. Initial surface overlap was 96.770% and
|
||||
inlier RMSE 0.166 m, not independent localization accuracy.
|
||||
|
||||
LiDAR and pose overflow counts were zero in all three private queues. Maximum
|
||||
publisher delays were 0.0246, 0.0694 and 0.0625 seconds respectively. No browser
|
||||
video decoding, camera producer or concurrent physical recording was replayed;
|
||||
these checks do not prove absence of overflow under every live workload.
|
||||
|
||||
Verified seals contain 88, 73 and 42 files respectively. Summary SHA-256:
|
||||
|
||||
- Physical: `277da5a0fd3ed9579fb011503de0b28d2b75b17906bb47b53afd994064699306`.
|
||||
- Positive: `7848e43a2f396d640c8f8e05efa3432e91447552efa5f3743abc2808ceab690b`.
|
||||
- Negative: `24a48c089ea5287ecd47c3f7c17479cfd970e111c49775e2e20f8f984b8226f7`.
|
||||
|
||||
65 focused backend tests passed, including target-reuse equivalence/isolation,
|
||||
known-transform recovery, complete seed coverage, partial/ambiguous rejection,
|
||||
fresh receipts, gaps, expiry, identity changes and cancellation. The strengthened
|
||||
16-test stationary subset passed again after refining the gap test.
|
||||
|
||||
Frontend acceptance passed sequentially: four architecture tests, full
|
||||
typecheck, all 853 frontend tests and the production build. The new bundle is
|
||||
`app-CMB8f3eT.js`. Ruff and `git diff --check` passed. Browser QA on the canonical
|
||||
8000 endpoint reloaded that build and opened the existing saved A/B project;
|
||||
the inspector opened, expanded and closed with Escape, leaving the real cloud
|
||||
visible. New initial-failure versus actual-loss copy is covered by presentation
|
||||
tests; no new live scanner session was started for UI QA.
|
||||
|
||||
## Runtime handoff
|
||||
|
||||
The frontend build is available on 8000, but the existing backend has not yet
|
||||
been restarted to load this numerical repair. A fresh `/api/state` still reports
|
||||
`awaiting_external_stop`, `acquisition.stop.device_stopping` and `reconnecting`
|
||||
for the preceding physical acquisition. Its recovery checkpoint remains active.
|
||||
The owner has been asked whether the scanner is off/stopped or still recording;
|
||||
that answer is pending. No device command, force-finish, checkpoint reset or
|
||||
backend interruption was performed. Keep the existing canonical service
|
||||
running, confirm the physical stop, then replace that same service and verify
|
||||
8000 before inviting another walk. Code/replay qualification is not a claim
|
||||
that the running backend has already changed.
|
||||
|
||||
The owner subsequently confirmed twice that K1 had been switched off after the
|
||||
failed scan. The existing local-only force-finish action was attempted with
|
||||
exact acquisition/revision/runtime/generation fences. It failed before side
|
||||
effects with `acquisition-force-finish-not-recovering`: public recovery state
|
||||
was reconnecting/allowed, but its acquisition lineage was absent. Re-reading
|
||||
the same idempotency key returned that failed operation, not a new attempt.
|
||||
This separate recovery-presentation inconsistency is retained for follow-up.
|
||||
|
||||
The canonical LaunchAgent was then replaced: backend PID 68323 → 75869.
|
||||
Health is operational, port 8765 has no listener, and 8000 serves
|
||||
`app-CMB8f3eT.js`. The 29 implementation hashes in the final physical replay
|
||||
manifest match the current source. The new process is idle with no acquisition;
|
||||
the durable recovery checkpoint remains unchanged at active/revision 242 with
|
||||
no automatic restart authority. No checkpoint, raw input or command ledger was
|
||||
deleted or rewritten to manufacture a confirmed physical stop.
|
||||
|
||||
Browser preparation created «JA-SADOVAYA · повторная проверка старта», using
|
||||
JA-SADOVAYA-002 / 30.050 m and a new live pass. Run
|
||||
`82ff69a7-ac67-4658-8d98-cb5944101d4c` is waiting, with query session absent,
|
||||
tracking false, `stationary-entry/v2` and `stationary-fresh-bootstrap/v2` returned
|
||||
by the actual 8000 API. The standard connection modal is left open, Quick
|
||||
Connect selected, at «Найти по Bluetooth». Selecting that mode completed the
|
||||
existing local connection reset. No discovery, provisioning, physical start or
|
||||
stop command was invoked. Previous physical-command uncertainty stays in its
|
||||
ledger for normal read-only verification when the scanner is reconnected.
|
||||
|
||||
Private before/after states, the local-close request and failed result, health
|
||||
and prepared-run evidence are under
|
||||
`data_dir/missions/diagnostics/20260919-entry-fix-runtime-001`. The only live
|
||||
planning owner is the deliberately prepared operator test, waiting for a new
|
||||
capture. Field acceptance remains pending.
|
||||
|
||||
## Decision
|
||||
|
||||
The saved failure is repaired under the bounded archived scenarios. A new
|
||||
physical trial is still required; no navigation, vehicle control, arbitrary
|
||||
start or independent accuracy qualification is claimed. Use the same reference
|
||||
and section as the failed start, a new query project, and wait without movement
|
||||
through scanner preparation and reference confirmation until «Сопровождение».
|
||||
Then perform the agreed short hand-carried walk. Measured replay time is not a
|
||||
fixed readiness timer.
|
||||
@@ -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 0–1320, length 102.59360280377184 m; context map
|
||||
indices 0–1516, `route-context-map/v2`.
|
||||
- All 133 declared run artifact hashes verified against `report.json`.
|
||||
- Raw capture SHA-256:
|
||||
`1c8161b2d2e22ecb98ccd1bbe04f1dc348b8d79b1cab1a2cf94dcecf58ea929c`.
|
||||
- Metadata SHA-256:
|
||||
`8c8caa3e293fa459e423c815a08c84ced20433cefb55eb81d018e245dd1c5783`.
|
||||
- Raw and metadata hashes verified, all 3,858 message payload hashes verified;
|
||||
full streaming decoding found zero pose/cloud decode errors.
|
||||
- Empty recovery journal and clock origin verified. The summary declares the
|
||||
**sealed session clock**
|
||||
`mqtt.timeline.session-de7e1779fa341e2e81e0943d8523f09fc1003a0528b2f0342fa4ec86334d445b.json`;
|
||||
its digest matches. The mutable transport `mqtt.timeline.json` is a different
|
||||
envelope and must not be compared to the session-clock digest.
|
||||
- Query raw data contains 1,625 poses, 1,624 clouds and 5,573,004 cloud points.
|
||||
Capture summary records no rejected messages, recoveries, or transport error.
|
||||
|
||||
Evidence root is the canonical `NODEDC_MISSION_CORE/.runtime/mission-core`:
|
||||
run artifacts under `missions/live-tests/<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 1–1.66 s input pauses are real limitations to
|
||||
measure, not grounds to call the entire acquisition smooth.
|
||||
|
||||
This does not establish arbitrary-start relocalization, robustness across
|
||||
mounting configurations, independent absolute localization accuracy, or an
|
||||
acceptable navigation freshness/stopping budget. The laboratory still retains
|
||||
`localization_confirmed=false`, `vehicle_control=false`; the 8 s alignment age
|
||||
gate is not a vehicle safety criterion. A longer walk alone does not close those
|
||||
questions. Next useful acceptance is repeated starts with known offsets and a
|
||||
controlled observation of cloud/pose presentation and stale-input behavior.
|
||||
|
||||
Only this audit was added. No implementation, runtime configuration, device,
|
||||
recording, Ops card, or service lifecycle was changed during review.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Planning browser-presentation telemetry — 2026-09-20
|
||||
|
||||
## Purpose
|
||||
|
||||
Instrument the time from a fresh planning cloud package reaching the browser to
|
||||
its admission by the existing native Rerun channel and two subsequent browser
|
||||
animation-frame opportunities. The purpose is to investigate the operator's
|
||||
observation that a new cloud appears substantially slower than an ordinary
|
||||
session cloud, without changing registration, route following, device control,
|
||||
or the visible LAB surface.
|
||||
|
||||
## Exact boundary
|
||||
|
||||
The browser reports one bounded, best-effort sample only after:
|
||||
|
||||
1. `channel.send_rrd` has resolved for a live cloud package;
|
||||
2. one browser animation frame has run (or timed out after 500 ms); and
|
||||
3. a second browser animation frame has run (or timed out after 500 ms).
|
||||
|
||||
The report stores metric summaries and at most 4,096 in-memory samples for the
|
||||
selected run. Browser posts contain at most eight samples, have no retry queue,
|
||||
and use an exact `run_id`, `display_epoch`, cloud revision, and cloud sequence
|
||||
fence. A stale, terminal, or foreign packet is ignored. The final run report
|
||||
receives only the summary.
|
||||
|
||||
`source_to_second_animation_frame_upper_bound_ms` is deliberately conservative:
|
||||
server-reported cloud age + browser request time + Rerun admission duration +
|
||||
post-admission two-frame delay.
|
||||
|
||||
This is not proof of a GPU canvas-paint receipt, physical scanner-to-pixel clock
|
||||
synchronization, registration truth, route-following quality, navigation, or
|
||||
safety authority.
|
||||
|
||||
## Implementation
|
||||
|
||||
- `apps/control-station/src/core/missions/planningSceneStream.ts` forwards the
|
||||
exact live display identity and holds the native cadence until the bounded
|
||||
delivery callback finishes.
|
||||
- `apps/control-station/src/components/missions/PlanningLiveScene.tsx` records
|
||||
the browser-side admission/two-frame proxy without adding controls, panels,
|
||||
labels, or debug UI.
|
||||
- `apps/control-station/src/core/missions/planningPresentationTelemetry.ts`
|
||||
batches bounded best-effort browser reports.
|
||||
- `src/k1link/web/planning_live_api.py`,
|
||||
`src/k1link/missions/live_tests.py`, and
|
||||
`src/k1link/missions/planning_browser_presentation.py` validate, fence, and
|
||||
summarize those reports.
|
||||
|
||||
## Existing physical context
|
||||
|
||||
The operator deliberately starts the scanner about 1–3 m away from the prior
|
||||
start and walks an offset trajectory. In the registered reference frame, the
|
||||
two accepted repeat runs do not collapse into one exact line:
|
||||
|
||||
| Run | Measured path | nearest-route median | nearest-route p95 |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| `ja-sun-006` | 35.30 m | 1.12 m | 3.11 m |
|
||||
| `ja-sun-007-100m` | 102.59 m | 0.95 m | 1.23 m |
|
||||
|
||||
The transformed path origins are about 0.79 m and 0.65 m from the reference
|
||||
route start, and about 0.51 m apart in XY. Those figures support that the
|
||||
recorded passes are not identical traces, but they cannot independently survey
|
||||
the operator's physical 1–3 m offset because registration maps each query into
|
||||
the reference frame.
|
||||
|
||||
## Verification and deployment
|
||||
|
||||
- `pytest -q tests/test_planning_fast_display.py` — 4 passed.
|
||||
- Focused frontend streaming/reporter tests — 8 passed.
|
||||
- `npm run typecheck` — passed.
|
||||
- `npm run test:unit` — 871 passed.
|
||||
- `npm run build` — passed (only the established chunk-size warning).
|
||||
- Planning Python suite rerun — 13 passed. One earlier stationary-start timing
|
||||
assertion was repeated successfully; it does not cover this reporter path.
|
||||
- Canonical LaunchAgent reloaded only after the selected run was terminal.
|
||||
Health is `ok`, exactly one listener is bound to `127.0.0.1:8000`, no listener
|
||||
is bound to 8765, and the new observation endpoint is mounted.
|
||||
|
||||
## Next physical check
|
||||
|
||||
Run a fresh planning pass using the normal deliberate offset; no special
|
||||
placement, UI mode, or debug control is needed. A 30–40 m repeat is enough to
|
||||
validate recording and inspect the new report. Keep the Planning scene open;
|
||||
after the run finishes, review sample count, request/admission/frame percentiles
|
||||
and frame-timeout count alongside the already existing registration evidence.
|
||||
@@ -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 1–2 km reference; qualify its preparation
|
||||
and existing saved-pass localisation offline; then repeat from the known start.
|
||||
Cold restart in the middle of the long route is a separate acceptance gate.
|
||||
Foliage/grass changes and handheld motion are relevant nuisance geometry;
|
||||
these two same-day passes show encouraging tolerance, not seasonal acceptance
|
||||
or an absolute scanner-position error certificate.
|
||||
@@ -0,0 +1,182 @@
|
||||
# Reference preparation and recovery before the long field route
|
||||
|
||||
## Scope and decision
|
||||
|
||||
Owner-authorized follow-up: useful, evidence-based preparation before recording
|
||||
a 1–2 km reference. Preserve dense-start-first v6, all eligible route regions,
|
||||
ambiguity rejection, source freshness and independent recording ownership.
|
||||
No new field capture, K1 command, threshold relaxation or vehicle authority.
|
||||
The current worktree already contained substantial unrelated and earlier work;
|
||||
no commit, reset or unrelated cleanup was performed.
|
||||
|
||||
## Implementation
|
||||
|
||||
- `missions/sources.py`: prepare one verified private source snapshot for the
|
||||
tiles of one atlas, instead of copying and hashing the raw recording again
|
||||
for every tile. Verify original source identity and digests before staging
|
||||
and after assembly, before returning the atlas. Staging is removed on success,
|
||||
corruption, extraction failure and cancellation. Cancellation from the shared
|
||||
recording copier is translated to `InterruptedError`, so it remains a cancelled
|
||||
planning preparation rather than a product error. Single-submap API remains
|
||||
compatible; numerical and presentation maps remain separate derivatives.
|
||||
- `missions/reference_map.py`: assemble inside that verified snapshot context.
|
||||
Retain the original tile order, 40 m tile geometry and first-point-per-voxel
|
||||
deduplication semantics. Lightweight archive/test adapters without a prepared
|
||||
snapshot capability retain the existing submap interface.
|
||||
- `missions/reference_window.py`: an immutable, run-owned spatial index stores
|
||||
source indices grouped in 10 m cells. Exact spherical filtering retains all
|
||||
eligible points, duplicates, boundary points and original source order.
|
||||
No point-count acceptance gate, thinning, expanded acceptance radius or changed
|
||||
transform convention. A reference-identity mismatch fails explicitly.
|
||||
- `missions/stationary_live.py`: build the index once for the run and reuse it
|
||||
for fresh validation windows. The index is local to the live worker and is
|
||||
discarded with it. Lookup mode and examined-point count are retained in each
|
||||
window's evidence alongside the unchanged target-array hash.
|
||||
- `missions/route_relocalization.py`: preprocess the exact local target once
|
||||
per candidate place, sharing its GICP tree across that place's three yaw
|
||||
seeds. Seed list, ranking, complete-search obligation, deadlines, ambiguity
|
||||
and fresh confirmation are unchanged. This is not a new global retrieval
|
||||
algorithm and does not qualify arbitrary kilometre-scale cold starts.
|
||||
- `scripts/planning_archive_source.py`, `check_planning_live_bootstrap.py`:
|
||||
explicit receipt-drop interval for the private archive adapter, preserving
|
||||
surviving source payloads, identity and receipt intervals. Capture ownership,
|
||||
recovery and source integrity are checked independently of recognition success.
|
||||
- `scripts/check_reference_windows.py`, `check_reference_preparation.py`: bounded
|
||||
real-data exactness and preparation checks. They do not use a live singleton,
|
||||
connect to hardware, modify physical reports or generate synthetic kilometres.
|
||||
|
||||
## Exact geometry and measured preparation
|
||||
|
||||
Private physical source: existing reference JA-SADOVAYA-001 and independent
|
||||
physical repeat 018, with source digests from the previous sealed acceptance.
|
||||
|
||||
`qualification-20260920-reference-index-018.json` and its `-r2` successor show
|
||||
the index's development measurements. All 21 saved fresh-fit input windows
|
||||
matched both the full-scan result and original frozen registration target
|
||||
bit-for-bit. Initial default sorting was slower; stable integer sorting retained
|
||||
exact source order with lower overhead. These are retained measurements, not
|
||||
a claim that the short reference became faster.
|
||||
|
||||
Final preparation evidence:
|
||||
`.runtime/qualification-20260920-reference-preparation-full/report.json`.
|
||||
The harness uses the real `PlanningSources` verification/staging/export/map
|
||||
path with a private, hash-bound archive catalog adapter; no production catalog
|
||||
or application state is changed.
|
||||
|
||||
| Measured reference | Selected physical interval | Complete saved trajectory |
|
||||
|---|---:|---:|
|
||||
| Recorded length | 120.073 m | 487.611 m |
|
||||
| Preparation tiles | 4 | 13 |
|
||||
| Numerical points | 172,954 | 390,668 |
|
||||
| Numerical array bytes | 4,150,896 | 9,376,032 |
|
||||
| Preparation wall time | 8.671 s | 28.109 s |
|
||||
| Index construction | 0.081 s | 0.187 s |
|
||||
| Index integer-array bytes, excluding dictionary overhead | 1,383,632 | 3,125,344 |
|
||||
| Median full-scan window | 3.076 ms | 5.682 ms |
|
||||
| Median indexed window | 3.508 ms | 5.109 ms |
|
||||
| Median examined points | 76,985 | 114,760 |
|
||||
|
||||
The selected interval's reconstructed map is bit-identical to the original
|
||||
physical report. For each map, 21 real query windows matched the corresponding
|
||||
full-scan implementation exactly. The complete map contains additional nearby
|
||||
geometry, so its targets are not claimed identical to targets on the smaller
|
||||
map. This is preparation/lookup evidence, not a 488 m independent traversal or
|
||||
recognition acceptance on the expanded map. Timings are one bounded pass per
|
||||
map, not a latency guarantee or a synthetic load test.
|
||||
|
||||
Trajectory export took 0.353 s for 4,959 poses; its private cache is 868,331 bytes.
|
||||
All input/code digest checks and staging-cleanup checks passed.
|
||||
|
||||
## Real archive baseline and injected loss
|
||||
|
||||
`.runtime/qualification-20260920-map-preparation-018-baseline` replays complete
|
||||
018 spatial receipts at 1x through the production ingress/decoder/planner,
|
||||
numerical child and in-process 10 Hz scene-delta endpoint. All 13 acceptance
|
||||
checks passed. Tracking began at 45.84 s; 22/22 disjoint fresh checks passed over
|
||||
85.023 m, with no recovery before admitted STOP. Both spatial queues published
|
||||
1,439 and consumed 1,435 receipts, with zero overflow; four per modality remained
|
||||
queued at STOP, not lost from raw recording. Maximum depths: lidar 10/32, pose
|
||||
9/32. Maximum publisher lag 0.048 s; 1,261 scene requests. Compared with the
|
||||
previous 52.40 s replay this acquired earlier, but the comparison is not a
|
||||
controlled performance guarantee and scheduling changes fresh-window boundaries.
|
||||
|
||||
`.runtime/qualification-20260920-map-preparation-018-gap` uses the same archive
|
||||
with only derived pose/lidar receipts from [70, 80) seconds omitted. Surviving
|
||||
timestamps are not compressed and raw files are unchanged. Exactly 198 receipts
|
||||
were omitted (99 each). All 17 checks passed: tracking established before the
|
||||
fault; authority cleared at about 74.25 s when the last accepted result expired;
|
||||
recovery began with capture still active; ordinary STOP completed the study;
|
||||
no previous transform was resurrected as live authority.
|
||||
|
||||
The recorded operator continues walking after this artificial loss and stops
|
||||
for less than the required 10-second stationary prefix at the end. Recovery
|
||||
therefore remains unconfirmed. The recorded 270 `recovery_attempt` increments
|
||||
are repeated motion-interrupted collection attempts, **not 270 expensive route
|
||||
searches**. No new successful numerical recovery or stationary behaviour was
|
||||
invented. Both queues again had zero overflow (1,340 published / 1,337 consumed
|
||||
per modality); three remained queued at STOP. This proves continued recovery
|
||||
ownership and fail-closed behaviour on moving real data, not successful physical
|
||||
stop-and-reacquire. That success remains a separate field check; focused tests
|
||||
cover the orchestration with a new stationary prefix and three fresh checks.
|
||||
|
||||
Both replays sealed unchanged executed code and original inputs. The subsequent
|
||||
production change was only cancellation-exception translation in `sources.py`;
|
||||
these archive replays supply already-prepared geometry and do not exercise that
|
||||
method. The later real preparation probe and final focused suite exercise the
|
||||
final `sources.py`. No executed replay source was edited during its run.
|
||||
|
||||
## Tests and runtime
|
||||
|
||||
Final focused suite: 152 tests, zero failures/errors/skips, retained in
|
||||
`.runtime/qualification-20260920-reference-preparation-tests.xml`.
|
||||
Coverage includes exact indexed lookup, source mismatch, one snapshot per atlas,
|
||||
mutation after assembly, extraction failure, cancellation during copying and
|
||||
between tiles, unchanged single-submap API, survivor clocks/payloads, complete
|
||||
ranked search, exhausted budget, ambiguity, late jobs, actual loss/recovery,
|
||||
STOP ownership, route limits, recording separation and lease cleanup.
|
||||
Ruff and whitespace checks passed; existing TestClient/httpx deprecation warning
|
||||
remains. All heavy checks ran sequentially; no Mac synthetic load/stress job.
|
||||
|
||||
The canonical 8000 LaunchAgent was reloaded using its reviewed plan/apply tool,
|
||||
after proving no acquisition and resolved/inactive device-command state.
|
||||
Current/desired plist SHA remained
|
||||
`5d7a58122c016f8021fede27d1b21891ee20238fc38c281e5b33574ff8204e3f`;
|
||||
backup and health acceptance succeeded. This is a local code reload, not a NAS
|
||||
artifact deployment, data-root migration or scanner command. The UI and 80 m
|
||||
presentation ceiling were not changed.
|
||||
|
||||
Post-reload acceptance: operational health, idle acquisition/control, resolved
|
||||
inactive physical command, preserved historical completed planning result and
|
||||
80 m ceiling. PID 6431 was the sole backend listener on 8000; none on 8765 and
|
||||
no temporary replay/numerical/test/Docker workload remained. Swap decreased
|
||||
from 5,947.50 to 5,931.50 MiB across the sequential checks. A concise result was
|
||||
appended to the existing MISSIONCOR-81 card via ops-context; private identifiers,
|
||||
paths and raw operational evidence were kept local.
|
||||
|
||||
## Remaining scaling work and next field gate
|
||||
|
||||
The preparation still decodes selected frames after two sequential archive
|
||||
passes per tile and merges voxel keys over the accumulated map. These costs
|
||||
still grow with route size. Shared staging removes repeated copying/hashing,
|
||||
not all route-length-dependent work. The atlas remains in memory; it is not a
|
||||
disk-paged large-area map. A shared seekable frame index / streaming tile builder
|
||||
is the next preparation optimisation if the long capture's measurements require it.
|
||||
|
||||
Protective implementation bounds still exist: export/extraction wall budgets,
|
||||
trajectory cache byte/pose count bounds, per-tile frame/point budgets, queue
|
||||
capacity and numerical freshness deadlines. None is evidence of a 100 m maximum.
|
||||
They were identified, not blindly removed. The original 100,000-point local-map
|
||||
rejection remains absent; the separate 100,000 **trajectory pose** bound remains.
|
||||
The current 4,959-pose reference does not qualify that bound at long dwell times.
|
||||
|
||||
Cold route-wide search still verifies every eligible 5 m anchor with three
|
||||
precise seeds within its existing budget. Around 2 km gives 401 anchors; sharing
|
||||
target preprocessing does not prove this exhaustive stage will finish on time.
|
||||
Do not weaken ambiguity checks or accept an incomplete search to hide this.
|
||||
|
||||
Morning sequence remains: ordinary recording of one approximately 2 km reference
|
||||
(line or convenient loop); verify complete capture, export/map preparation,
|
||||
memory and source integrity; then a separate repeat from a known start. Cold
|
||||
mid-route restart on the long map and physical stop-and-reacquire are separate
|
||||
gates. No 2 km, seasonal, absolute-position or autonomous-driving acceptance is
|
||||
claimed by the present increment.
|
||||
@@ -0,0 +1,182 @@
|
||||
# Route cascade v6 — archive qualification
|
||||
|
||||
## Scope and architectural boundary
|
||||
|
||||
Owner-authorized implementation following the run 016 retrieval diagnosis.
|
||||
This change stays inside Teach & Repeat for the selected, previously recorded
|
||||
location. It neither changes K1 SLAM nor admits autonomous vehicle control.
|
||||
Original captures, physical experiment reports and planner comparisons remain
|
||||
immutable; qualification results live in separate private `.runtime` folders.
|
||||
No scanner, recording, MQTT publisher or second application server is started.
|
||||
|
||||
The defect was not insufficient overlap or scanner range. In run 016 the coarse
|
||||
descriptor ranked the actual location below a permanent top-six shortlist.
|
||||
The precise fitter never received that location. See
|
||||
[the preceding diagnosis](2026-09-20-mid-route-016-retrieval-diagnosis.md).
|
||||
|
||||
## Implemented sequence
|
||||
|
||||
1. Collect a continuous stationary prefix using the existing ten-second and
|
||||
motion gates. Start with the established dense local-start search; after a
|
||||
tracking loss its target is the last confirmed place, not the route origin.
|
||||
2. If dense search honestly rejects the location, rank eligible local regions
|
||||
across the selected route. Ranking controls work order, not eligibility:
|
||||
the six-item permanent shortlist is gone. Six is now only a batch size.
|
||||
3. Precisely fit the ranked regions sequentially, constructing one region at
|
||||
a time. Preserve the existing numerical and freshness budgets. Account for
|
||||
evaluated and remaining regions; an unfinished search is *incomplete*, not
|
||||
a completed negative result or a valid provisional position.
|
||||
4. Group converged fits by their resulting position **and orientation**.
|
||||
Compare competing distinct locations, including fits originating from the
|
||||
same coarse anchor. An ambiguous best result cannot become a prior.
|
||||
5. A qualified best hypothesis remains provisional. Only three consistent,
|
||||
disjoint fresh-data windows can establish tracking. If fresh geometry
|
||||
rejects this hypothesis before tracking, try the next non-ambiguous
|
||||
alternative, reset the streak, and collect new receipts after a new fence.
|
||||
The original prefix's age limit is not extended by a retry.
|
||||
6. If a dense-start provisional hypothesis fails fresh confirmation, collect
|
||||
a new stationary prefix within the same recording and search the route
|
||||
without repeatedly retrying that disproved start. Transport, identity,
|
||||
stale-data and ambiguous-location failures cannot use this fallback to
|
||||
manufacture a successful geometric result.
|
||||
7. Established tracking keeps the existing local update and recovery flow.
|
||||
Loss clears accepted localisation immediately and begins stationary
|
||||
recovery around the last confirmed position. Capture ownership remains
|
||||
separate. End of input clears live authority and releases the compute lease.
|
||||
|
||||
The three fresh checks, overlap/RMSE, convergence, shape/information, identity,
|
||||
continuity and age gates were not weakened. Descriptor similarity and fitted
|
||||
overlap are not probabilities that the scanner is in the correct place.
|
||||
|
||||
## Implementation map
|
||||
|
||||
- `route_relocalization.py`: v6 full ranked queue, lazy local targets,
|
||||
completeness accounting, distinct fitted-pose ambiguity and candidate queue.
|
||||
- `stationary_bootstrap.py`: v3 per-hypothesis fresh confirmation, queue
|
||||
continuation and dense-start rejection signal.
|
||||
- `stationary_live.py` and `route_relocalization_worker.py`: same-capture
|
||||
route-only retry after collecting a new stationary prefix; auditable candidate
|
||||
index and trial number. Existing manual reinitialisation and post-loss recovery
|
||||
retain their separate lifecycle.
|
||||
- `planning_archive_source.py` / `check_planning_live_bootstrap.py`: optional
|
||||
lower receipt boundary for testing the second recorded position independently;
|
||||
preserves original clock intervals, epoch and source sequence. The boundary
|
||||
does not provide the fitter with a reference position.
|
||||
|
||||
## Acceptance evidence
|
||||
|
||||
Qualification runs use the real derived ingress queue, K1 decoder, live service,
|
||||
CPU worker and temporal gate at original 1x receipt timing. Each run seals input
|
||||
digests, executed source copies, decisions, deliveries and terminal cleanup.
|
||||
These are counterfactual software replays, not new physical field passes.
|
||||
|
||||
Initial focused suite: 68 tests passed, including real numerical synthetic
|
||||
registration and live-service fallback tests. Explicit cases cover a valid
|
||||
location beyond the first batch, partial search, ambiguity from the same
|
||||
anchor, distinct fresh receipts, stale priors/results, queue exhaustion,
|
||||
transport discontinuity, source end and resource cleanup. The fake-result
|
||||
service tests prove orchestration, not geometric recognition accuracy.
|
||||
|
||||
Pre-final-metadata full 016 replay in
|
||||
`.runtime/qualification-20260920-v6-016-full`: all 11 checks passed. Complete
|
||||
108-seed start rejection followed by 54 fits across 18 route regions selected
|
||||
the 30 m anchor. Three fresh checks established tracking at about 48.9 s;
|
||||
the recorded approximately 6 m relocation remained tracked until input ended.
|
||||
No operator reinitialisation command was injected in this full replay: unlike
|
||||
the physical failed run, the first hypothesis now succeeded.
|
||||
|
||||
Independent second-position replay in
|
||||
`.runtime/qualification-20260920-v6-016-second`: search completed, selected a
|
||||
20 m coarse anchor and passed two fresh checks. The original retained suffix
|
||||
contains only 46.746 s of receipts after reinitialisation. Input ended before
|
||||
the third check, so tracking was **not** established and the expected-tracking
|
||||
assertion failed. The other ten checks passed. This is explicitly partial
|
||||
evidence, not a passed cold-start qualification, and no frames were duplicated
|
||||
to prolong it.
|
||||
|
||||
Final-code full 015 regression in
|
||||
`.runtime/qualification-20260920-v6-015-full`: all 11 checks passed. The unchanged
|
||||
dense-start path succeeded without route retrieval (108 fits, 14.978 s numerical
|
||||
search). Tracking was established at 39.1 s; all 27 fresh checks were accepted.
|
||||
The recorded 99.282 m traversal completed without tracking loss or recovery;
|
||||
end-of-input correctly cleared live localisation. Maximum ingress delivery lag
|
||||
was 0.055 s. The physical original had 28 checks; replay scheduling changes
|
||||
window boundaries and is not a claim of bit-for-bit fit identity.
|
||||
|
||||
Wrong-region control in `.runtime/qualification-20260920-v6-016-wrong-region`:
|
||||
the same 016 raw input was compared with the actual reference recording's
|
||||
130–160 m interval, not a translated copy of the correct map. All 11 checks
|
||||
passed, with **no** provisional position and **no** tracking. Start search
|
||||
rejected as ambiguous; subsequent route search completed all 24 generated fits
|
||||
and returned no admissible location. Together with 108 start fits, all 132
|
||||
attempts were accounted for. Maximum ingress lag was 0.029 s. This is one
|
||||
negative example, not proof that all perceptually aliased places are rejected.
|
||||
|
||||
Final-code repeat in `.runtime/qualification-20260920-v6-016-final`: all 11
|
||||
checks passed. Complete 108 + 54 fits selected the 30 m anchor (overlap
|
||||
0.96945, inlier RMSE 0.14720 m); dense stage took 15.449 s, route stage 10.088 s.
|
||||
Tracking started at 48.9 s. All 12 fresh checks passed, including the recorded
|
||||
5.976 m movement; no tracking loss preceded the end of receipts at 96.9 s.
|
||||
Maximum ingress lag was 0.043 s. RMSE measures fit residual, **not** absolute
|
||||
localisation error. All original inputs and executed files remained unchanged
|
||||
during each qualification; the final source/script hashes were rechecked.
|
||||
|
||||
Expanded backend regression: **155/155 passed** in 20.29 s; retained JUnit is
|
||||
`.runtime/qualification-20260920-v6-final-tests-r2.xml`. One existing
|
||||
Starlette/httpx deprecation warning remains. Ruff on changed implementation and
|
||||
focused tests, plus `git diff --check`, passed. The first expanded run is
|
||||
retained separately: five distance tests had a v2-only bootstrap fake missing
|
||||
the newly recorded candidate fields, and one existing threaded test asserted
|
||||
publication immediately after worker submission. Updated the fake's contract
|
||||
and waited for the actual published phase; no production acceptance rule was
|
||||
changed to fix these tests. Only test files changed after archive qualification;
|
||||
the executed product and probe script hashes still match the final replay.
|
||||
|
||||
Source base: `be58d589e2fba520b1a3800574073f01a7fc9591`, with the existing dirty
|
||||
worktree preserved. Qualified v6 source SHA-256:
|
||||
|
||||
- `route_relocalization.py`:
|
||||
`b49e802015b062d531cf4f6a0570f8ae45a4db34886ba7a4f13d465983fd9fe7`
|
||||
- `stationary_bootstrap.py`:
|
||||
`40720eab565b7860d7829fefe0e2583d0a6c6859f493be05da1b4eefdbbd265e`
|
||||
- `stationary_live.py`:
|
||||
`95f5bfc7d3a22851745b6f9e52d6e51da38eb9d7251c1c4ce9c1be90d1dda791`
|
||||
|
||||
## Local service acceptance
|
||||
|
||||
With K1 source idle and capture completed, reviewed a fresh plan and applied
|
||||
the repository's `manage_mission_core_launch_agent.py` workflow. Installed and
|
||||
desired plist SHA were identical:
|
||||
`5d7a58122c016f8021fede27d1b21891ee20238fc38c281e5b33574ff8204e3f`.
|
||||
This was a code reload in the same repository, not a configuration or data-root
|
||||
migration. The workflow retained its backup and accepted health.
|
||||
|
||||
Final runtime started at `2026-09-20T17:33:53.567849Z`: PID 95001 listens only
|
||||
on canonical `127.0.0.1:8000`; `/api/health` is operational, device/source state
|
||||
is idle, live ingress inactive. All 16 saved planner comparisons remain.
|
||||
Nothing listens on 8765. Qualification publishers, numerical workers and pytest
|
||||
processes have exited; no Docker workload or duplicate server was introduced.
|
||||
Memory pressure remained normal (48% free by `memory_pressure`); swap stayed
|
||||
at approximately 6339.5 MiB during the final sequential qualification stages.
|
||||
|
||||
## Limits and next field gate
|
||||
|
||||
This repairs exclusion by the shortlist and premature abandonment of a viable
|
||||
queue. It is not a validated kilometre-scale place-recognition solution: visiting
|
||||
all local regions still grows with route length. A runtime deadline remains a
|
||||
freshness/resource guard, not a distance cap; exceeding it must remain visible
|
||||
as incomplete. Descriptor comparability, spatially diverse retrieval and a
|
||||
more scalable atlas remain follow-up work, not claims of this patch.
|
||||
|
||||
The 80 m visual clipping domain and outline correction are unchanged. Visual
|
||||
range is not a promise of usable geometric overlap at an arbitrary position.
|
||||
Archive success does not establish absolute ground-truth accuracy, arbitrary
|
||||
10 m off-route cold starts, lighting robustness, different mounting/terrain,
|
||||
kilometre routes or vehicle safety.
|
||||
|
||||
Only after archive regressions and negative controls pass is a short physical
|
||||
test appropriate: initialise at the previously failed 20–30 m route location,
|
||||
remain stationary through confirmation, then make a short monitored walk.
|
||||
The independent second-position cold start still needs enough fresh real
|
||||
receipts to complete its third check. There is no reason to request another
|
||||
long walk just to debug the same saved input.
|
||||
@@ -0,0 +1,206 @@
|
||||
# Route-wide stationary relocalisation — implementation boundary
|
||||
|
||||
## Intent
|
||||
|
||||
Replace the laboratory assumption that an independent scanner session starts at
|
||||
the first pose of the selected route. A stationary prefix now retrieves
|
||||
candidate places from the full selected route before local GICP refinement.
|
||||
This supports recovery after an interrupted run at a known part of a route; it
|
||||
does not grant any rover, scanner, or navigation authority.
|
||||
|
||||
## Implemented protocol
|
||||
|
||||
1. The existing ten-second stationary prefix and its identity, monotonic-clock,
|
||||
gap and motion fences are unchanged.
|
||||
2. `route_relocalization.py` indexes the full reference map in a 10-m spatial
|
||||
grid and resamples the *entire selected path* at five-metre atlas anchors.
|
||||
Each anchor receives a compact rotation-invariant radial/height descriptor
|
||||
of its local reference submap. The full map is not subject to the 100,000
|
||||
point cap for one GICP target; only each local target is. The query
|
||||
descriptor is ranked against every usable anchor, not against
|
||||
`reference_path[0]`.
|
||||
3. The six best descriptor candidates receive a local target and three
|
||||
yaw hypotheses chosen from a polar descriptor. Every generated hypothesis
|
||||
is qualified by the existing small-gicp implementation, with a separately
|
||||
recorded relocalisation policy. The broader initial correction envelope is
|
||||
only for this isolated initial search; routine tracking retains its local
|
||||
policy.
|
||||
4. Candidate transforms are clustered by their transformed stationary query
|
||||
position. Similar-quality clusters at distinct route progress are rejected
|
||||
as `ambiguous-route-location`; no candidate yields `no-route-location`;
|
||||
a worker or search deadline yields `incomplete-route-search`.
|
||||
5. Only a complete, singular candidate becomes a **provisional** prior. The
|
||||
pre-existing three disjoint fresh registration windows still decide whether
|
||||
tracking is established. Candidate output retains
|
||||
`localization_confirmed=false` and `vehicle_control=false`.
|
||||
|
||||
All route-search policy values, descriptor coverage, generated-attempt count,
|
||||
clusters and chosen route progress are stored in the initialization artifact.
|
||||
The 30-s computational deadline deliberately remains below the bootstrap's
|
||||
40-s source-age fence; a timeout is an observable incomplete calculation, not
|
||||
a geometric rejection or a stale provisional position.
|
||||
|
||||
## Operator-visible states
|
||||
|
||||
- During retrieval: **«Поиск положения на маршруте»**.
|
||||
- No stable place: **«Синхронизация маршрута не выполнена»** with a request to
|
||||
remain near an explored area and repeat stationary calibration.
|
||||
- Repeated/similar geometry: an explicit multiple-similar-segments status; the
|
||||
system does not select one arbitrarily.
|
||||
- Incomplete computation: **«Синхронизация маршрута не завершилась»**, distinct
|
||||
from a geometric no-match.
|
||||
|
||||
## Validation completed in code
|
||||
|
||||
- 90-m synthetic route with the query constructed at the middle: descriptor
|
||||
retrieval chose 40–45 m and real GICP accepted the fit at 100% overlap and
|
||||
0.0165 m RMSE.
|
||||
- Separate synthetic duplicated-place case rejects as ambiguous and clears
|
||||
correspondence colouring.
|
||||
- The bootstrap rejects a route result whose declared generated-attempt count
|
||||
does not match its recorded attempts.
|
||||
- Existing causal stationary bootstrap, route-length and live ownership tests
|
||||
remain green.
|
||||
|
||||
## Not yet demonstrated
|
||||
|
||||
This is an implementation and synthetic qualification, not proof on the K1
|
||||
field data. It does not establish capture radius, recovery at 10 m offset,
|
||||
recovery in the middle of the present 100-m route, kilometre-scale memory/time,
|
||||
mount transferability, absolute localisation accuracy, obstacle handling or
|
||||
autonomous vehicle motion. The next physical acceptance should start a fresh
|
||||
stationary run away from the route start, first around the current known route
|
||||
and then at a deliberately chosen mid-route point. Preserve each result,
|
||||
including explicit no-match, ambiguity or incomplete-search outcomes.
|
||||
|
||||
## 2026-09-20 field finding and corrective decision
|
||||
|
||||
The latest independent field attempt, `ja-sun-010-100m-10left`, started about
|
||||
10 m opposite the normal route entry and reported `no-route-location`. This
|
||||
was not a route-entry-radius rejection: all 18 generated route hypotheses ran.
|
||||
The descriptor ranked the known entry region first (then the 5 m and 10 m
|
||||
anchors). Its best GICP result converged with 61.25% overlap, 0.811 m
|
||||
correction and 5.605° rotation, but was rejected solely because its 0.27594 m
|
||||
inlier RMSE exceeded the common 0.25 m tracking threshold.
|
||||
|
||||
The diagnosis also found a conflicting implementation detail: the stationary
|
||||
query accumulator silently retained only a 20 m radius even though the route
|
||||
search is intended to use the useful K1 scene around the operator. Therefore
|
||||
the correct first change is **not** a global threshold reduction.
|
||||
|
||||
- `route-relocalization/v2` explicitly records a 40 m stationary query radius
|
||||
for whole-route initialisation and its three fresh local confirmations. It
|
||||
is a numerical input footprint only: it is not a cap on route length,
|
||||
recording duration, scanner range, or the normal live presentation budget.
|
||||
- `LiveCloudBuffer`, `StationaryPrefix` and the fresh-confirmation buffer now
|
||||
receive that radius explicitly and persist it into each staged calculation.
|
||||
Ordinary short-range tracking retains its 20 m default profile.
|
||||
- The rejected-run screen now provides **«Переинициализировать»**. It is
|
||||
admitted only for a running, unconfirmed `lost` state. The action clears the
|
||||
old derived preview, prior and result; it does not send a K1 command, stop
|
||||
recording, reuse an old cloud, or start a vehicle. The next pose/cloud pair
|
||||
starts a new ten-second stationary prefix and is stamped as the next attempt
|
||||
in the run evidence.
|
||||
|
||||
The 0.25 m tracking policy remains unchanged. Lowering it everywhere would
|
||||
weaken routine motion tracking and incorrectly treat a one-shot recovery
|
||||
near-miss as ongoing confirmation. If the repeat with the 40 m initial scene
|
||||
again selects the correct local anchor but stays in the 0.27–0.30 m band, the
|
||||
next bounded change is a separate provisional-initialisation policy, locked to
|
||||
that selected local target and still requiring all three disjoint fresh windows
|
||||
to agree before any tracking presentation. It must not alter vehicle
|
||||
authority, which remains false in this laboratory profile.
|
||||
|
||||
Code validation covers the 40 m accumulator request, `lost → operator retry →
|
||||
new prefix → second route search` without closing the source lease, the typed
|
||||
HTTP endpoint, existing stationary/route cases and the Control Station build.
|
||||
The required physical acceptance is a repeat of the same 10 m opposite-entry
|
||||
placement: keep the scanner still for the full prefix, save the resulting run,
|
||||
and compare candidate progress, overlap, RMSE, convergence and all three fresh
|
||||
windows before considering a separate provisional band.
|
||||
|
||||
## 2026-09-20 `ja-sun-011` retry correction and 80-m footprint
|
||||
|
||||
`ja-sun-011-100m-10left` recorded the intended failure separately from K1
|
||||
capture. Its first initialization generated all 18 route hypotheses in 2.94 s
|
||||
and ended at `no-route-location`; the nearest entry hypothesis reached 57.82%
|
||||
overlap but 0.2783 m RMSE, so it correctly did not bypass the 0.25 m gate.
|
||||
|
||||
The following retry began 0.16 s after the operator click, reached a new
|
||||
`collecting` prefix, and then surfaced the generic
|
||||
`Stationary prefix`/geometry failure as a terminal **«Совмещение остановлено»**.
|
||||
The raw recording was still active, but the presentation made an operator
|
||||
repositioning mistake look like a K1 or route-search stop. The old worker also
|
||||
continued to ingest numerical receipts after an unconfirmed route loss, so
|
||||
carrying the scanner could contaminate the next prefix.
|
||||
|
||||
`route-relocalization/v3` changes the boundary as follows:
|
||||
|
||||
- The stationary query, presentation head and candidate GICP target now use
|
||||
K1's declared 80-m sensing footprint. This is not a maximum walk distance,
|
||||
capture duration or route length. A dense target is deterministically
|
||||
voxelised to the explicit per-GICP computational budget without reducing
|
||||
its radial footprint.
|
||||
- The 28-m radial/height descriptor remains only the compact retrieval key for
|
||||
selecting places across the route. Expanding it to 80 m made neighbouring
|
||||
anchors on the short synthetic route less distinctive. After retrieval, the
|
||||
actual GICP comparison receives the complete 80-m query and local target.
|
||||
- In unconfirmed `lost`, the raw K1 source continues but numerical receipts are
|
||||
deliberately ignored. The operator may carry the scanner to another
|
||||
observable point, stop there, then press **«Переинициализировать»**.
|
||||
- If the button is pressed before the scanner has stopped, movement or a
|
||||
receipt gap during the new prefix returns to actionable `lost` with a
|
||||
reinitialization diagnostic; it does not terminate the research run or
|
||||
issue any K1 stop command. The following retry starts a clean buffer and
|
||||
prefix.
|
||||
- The vertical **«Срез»** control remains a Z-height visualization filter. Its
|
||||
7.5 m label is measured height in the currently rendered cloud, not an
|
||||
80-m sensor or relocalization range. The Planning "Движок" panel now shows
|
||||
the independent working radius explicitly.
|
||||
|
||||
Python validation covers 80-m capture, an 80-m dense target under the GICP
|
||||
budget, full-route retrieval, and `lost → carry/retry interruption → retry →
|
||||
second route search` while the exclusive source lease stays active. Control
|
||||
Station typecheck, 872 unit tests and production build also passed. This remains
|
||||
laboratory-only; the next field acceptance must preserve the retry result and
|
||||
its artifacts before any threshold adjustment is considered.
|
||||
|
||||
## 2026-09-20 v4 hybrid restoration after `ja-sun-012` / `ja-sun-013`
|
||||
|
||||
The subsequent physical runs exposed that v3 had made normal startup depend on
|
||||
the global target representation. It did not exhaust the route-search budget:
|
||||
on `ja-sun-013`, it ranked the entry anchor first but the broad, voxel-reduced
|
||||
target stopped at 70.69% overlap and 0.2577 m RMSE. That is not a valid reason
|
||||
to relax the common 0.25 m acceptance limit.
|
||||
|
||||
`route-relocalization/v4` therefore uses a staged strategy,
|
||||
`dense-start-first-then-route-recovery/v1`:
|
||||
|
||||
1. A normal start first runs the established 108-seed stationary acquisition
|
||||
against a precise local reference window at the selected route start. Its
|
||||
query footprint remains 80 m. A successful local result has to satisfy all
|
||||
existing support, convergence and geometry checks; it remains provisional
|
||||
until the three disjoint fresh windows confirm it.
|
||||
2. Only a complete, honest local rejection enters full-route descriptor
|
||||
retrieval. That fallback receives only the remaining search time under the
|
||||
same 35-s / 40-s freshness fences. It cannot replace a valid dense start,
|
||||
become vehicle authority, or silently reuse an old sample.
|
||||
3. A retry whose prefix proves incomplete at `start_search()` now returns to
|
||||
actionable `lost` with the reinitialisation diagnostic. It no longer
|
||||
converts an operator repositioning attempt into terminal
|
||||
**«Совмещение остановлено»**; raw recording remains under its existing owner.
|
||||
|
||||
Offline replay through the actual isolated worker, using unchanged saved
|
||||
inputs, accepts both known-good runs in the dense-start stage:
|
||||
|
||||
- `ja-sun-013-100m`: 94.11% overlap, 0.1504 m RMSE, 108 attempts, 21.54 s
|
||||
accumulated registration time.
|
||||
- `ja-sun-012-100m-10left`, step 3: 90.09% overlap, 0.1808 m RMSE, 108
|
||||
attempts, 17.13 s accumulated registration time.
|
||||
|
||||
The global recovery path is deliberately retained but not claimed as field
|
||||
qualified yet. Its next bounded improvement is a coarse full-route retrieval
|
||||
followed by a high-resolution local refinement *around the retrieved anchor*,
|
||||
with separate field evidence at a deliberately chosen mid-route point. A
|
||||
blind threshold reduction or an unbounded scan of the whole map remains out of
|
||||
scope.
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user