merge: integrate final rover control with operator simulation workspace
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {build} from 'esbuild';
|
||||
|
||||
async function moduleAt(path) {
|
||||
const result = await build({entryPoints:[new URL(path,import.meta.url).pathname],bundle:true,write:false,format:'esm',platform:'node'});
|
||||
return import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
|
||||
}
|
||||
const {defaultProfile:base,mix,motorDemands,parseProfile}=await moduleAt('../../../packages/rover-control/src/profile.ts');
|
||||
const {AuthorityModel}=await moduleAt('../../../packages/rover-control/src/authority.ts');
|
||||
const axes=(leftY=0,rightY=0,leftX=0,rightX=0)=>({leftY,rightY,leftX,rightX});
|
||||
const profile={...base,deadband:0};
|
||||
const arcade={...profile,mode:'arcade'};
|
||||
const policy={maxAgeMs:100,neutralMs:200,maxCommandMs:100,monitoredAxes:['leftY','rightY','leftX','rightX']};
|
||||
const motors=['a','b','c','d'];
|
||||
function frame(now,values=axes(),extra={}) {
|
||||
return {now,link:{state:'live',at:now},
|
||||
axes:Object.fromEntries(Object.entries(values).map(([key,value])=>[key,{value,at:now,sequence:now}])),
|
||||
drives:Object.fromEntries(motors.map(id=>[id,{at:now,healthy:true,stopped:true}])),...extra};
|
||||
}
|
||||
function ready(p=base) {
|
||||
const model=new AuthorityModel(p,motors,policy,'boot-A');
|
||||
for(let t=0;t<=200;t+=50)model.step(frame(t));
|
||||
return model;
|
||||
}
|
||||
function core() {
|
||||
const model=ready();
|
||||
const {token}=model.step(frame(250,axes(),{requestCore:{owner:'autonomy',sequence:1,at:250}}));
|
||||
assert.ok(token);return {model,token};
|
||||
}
|
||||
const command=(token,at,sequence=0)=>({token,at,expires:at+75,sequence,demand:{left:.5,right:.5}});
|
||||
|
||||
test('tank keeps the two motor sides independent, including reversal',()=>{
|
||||
assert.deepEqual(mix(profile,axes(.8,-.4)),{left:.8,right:-.4});
|
||||
assert.deepEqual(mix(profile,axes(0,.4)),{left:0,right:.4});
|
||||
});
|
||||
test('arcade cardinal, diagonal and reverse vectors preserve yaw sign',()=>{
|
||||
for(const [y,x,left,right] of [[0,0,0,0],[1,0,1,1],[-1,0,-1,-1],[0,1,1,-1],[0,-1,-1,1],[1,1,1,0],[1,-1,0,1],[-1,1,0,-1],[-1,-1,-1,0]])
|
||||
assert.deepEqual(mix(arcade,axes(0,y,0,x)),{left,right});
|
||||
assert.deepEqual(mix(arcade,axes(0,.5,0,.5)),{left:.5,right:0});
|
||||
});
|
||||
test('selected stick defines the axes, not the number or position of motors',()=>{
|
||||
assert.deepEqual(mix({...arcade,stick:'left'},axes(.5,-1,.5,-1)),{left:.5,right:0});
|
||||
});
|
||||
test('deadband is continuous and rescaled; response and output scale are separate',()=>{
|
||||
assert.deepEqual(mix(base,axes(-.064,.074)),{left:0,right:0});
|
||||
assert.deepEqual(mix({...profile,deadband:.2,response:'squared',outputScale:.5},axes(.6,-1)),{left:.12499999999999997,right:-.5});
|
||||
assert.ok(Math.abs(mix(base,axes(.150001)).left)<.000002);
|
||||
});
|
||||
test('every point in the input square stays bounded and changes sign symmetrically',()=>{
|
||||
for(let i=-20;i<=20;i++)for(let j=-20;j<=20;j++){
|
||||
const a=mix({...arcade,outputScale:.8},axes(0,i/20,0,j/20));
|
||||
const b=mix({...arcade,outputScale:.8},axes(0,-i/20,0,-j/20));
|
||||
assert.ok(Math.abs(a.left)<=.8+1e-12 && Math.abs(a.right)<=.8+1e-12);
|
||||
assert.ok(Math.abs(a.left+b.left)<1e-12 && Math.abs(a.right+b.right)<1e-12);
|
||||
}
|
||||
});
|
||||
test('profile JSON rejects unknown fields, malformed numbers and unknown schema',()=>{
|
||||
assert.deepEqual(parseProfile(JSON.parse(JSON.stringify(base))),base);
|
||||
for(const bad of [null,[],{...base,schema:'future'},{...base,revision:1.5},{...base,revision:-1},{...base,deadband:NaN},{...base,outputScale:1.1},{...base,writeVesc:true}])assert.throws(()=>parseProfile(bad));
|
||||
for(const value of [NaN,Infinity,1.01,undefined])assert.throws(()=>mix(profile,{...axes(),leftY:value}));
|
||||
});
|
||||
test('2, 4 and 10 motors route by identity and explicit direction, never USB position',()=>{
|
||||
for(const n of [2,4,10]){
|
||||
const bindings=Array.from({length:n},(_,i)=>({uuid:i.toString(16).padStart(24,'0'),side:i<n/2?'left':'right',forwardSign:i%2?1:-1}));
|
||||
const result=motorDemands({left:.3,right:-.6},bindings);
|
||||
assert.equal(Object.keys(result).length,n);
|
||||
assert.deepEqual(result,motorDemands({left:.3,right:-.6},bindings.toReversed()));
|
||||
}
|
||||
const b={uuid:'0'.repeat(24),side:'left',forwardSign:1};
|
||||
assert.throws(()=>motorDemands({left:1,right:1},[b]));
|
||||
assert.throws(()=>motorDemands({left:1,right:1},[b,{...b,side:'right'}]));
|
||||
});
|
||||
test('boot with held stick never permits motion; stable stopped neutral required',()=>{
|
||||
const model=new AuthorityModel(base,motors,policy,'boot');
|
||||
for(let t=0;t<1000;t+=50){const d=model.step(frame(t,axes(1)));assert.equal(d.state,'hold');assert.deepEqual(d.demand,{left:0,right:0});}
|
||||
for(let t=1000;t<1200;t+=50)assert.equal(model.step(frame(t)).state,'hold');
|
||||
assert.equal(model.step(frame(1200)).state,'rc-ready');
|
||||
assert.equal(model.step(frame(1250,axes(1))).state,'rc-manual');
|
||||
});
|
||||
test('first RC gesture stops Core immediately, held packets never count as second gesture',()=>{
|
||||
const {model,token}=core();
|
||||
assert.equal(model.step(frame(300,axes(),{command:command(token,300)})).state,'core');
|
||||
const first=model.step(frame(350,axes(.5),{command:command(token,350,1)}));
|
||||
assert.equal(first.reason,'rc-takeover');assert.ok(first.stopAll&&first.flushMotionQueue&&first.cancelMotionTasks);
|
||||
for(let t=400;t<1500;t+=50)assert.deepEqual(model.step(frame(t,axes(.5))).demand,{left:0,right:0});
|
||||
for(let t=1500;t<=1700;t+=50)model.step(frame(t));
|
||||
const second=model.step(frame(1750,axes(.5)));
|
||||
assert.equal(second.state,'rc-manual');assert.ok(second.demand.left>0);
|
||||
assert.equal(model.step(frame(1800)).state,'rc-manual');
|
||||
assert.equal(model.step(frame(1850,axes(0,.5),{command:command(token,1850,2)})).state,'rc-manual');
|
||||
});
|
||||
test('neutral of one channel or a single moving member never completes rearming',()=>{
|
||||
for(const kind of ['axis','drive']){
|
||||
const {model}=core();model.step(frame(300,axes(1)));
|
||||
for(let t=350;t<=1500;t+=50){const f=frame(t,kind==='axis'?axes(0,.2):axes());if(kind==='drive')f.drives.d.stopped=false;assert.equal(model.step(f).state,'hold');}
|
||||
}
|
||||
});
|
||||
test('loss of any of four controllers stops all sides and invalidates Core',()=>{
|
||||
for(const id of motors){const {model,token}=core();const f=frame(300,axes(),{command:command(token,300)});delete f.drives[id];const d=model.step(f);assert.equal(d.reason,'drive-unverified');assert.ok(d.cancelMotionTasks);assert.deepEqual(d.demand,{left:0,right:0});}
|
||||
});
|
||||
test('unknown/lost radio cannot be treated as neutral even with fresh zero PWM reads',()=>{
|
||||
for(const state of ['lost','unknown']){const {model,token}=core();const d=model.step(frame(300,axes(),{link:{state,at:300},command:command(token,300)}));assert.equal(d.reason,'receiver-unverified');assert.ok(d.stopAll);}
|
||||
});
|
||||
test('old decoded PPM, future samples, modified repeats and sequence rollback are rejected',()=>{
|
||||
for(const patch of [{at:0},{at:351},{sequence:249},{value:.4,at:250,sequence:250},{value:NaN}]){
|
||||
const {model}=core();const f=frame(350);Object.assign(f.axes.leftY,patch);assert.equal(model.step(f).reason,'axis-invalid');
|
||||
}
|
||||
});
|
||||
test('one repeated neutral sample cannot qualify stable neutral',()=>{
|
||||
const model=new AuthorityModel(base,motors,{...policy,neutralMs:50},'boot');
|
||||
model.step(frame(0));const f=frame(50);f.axes.leftY={at:0,sequence:0,value:0};
|
||||
assert.equal(model.step(f).state,'hold');
|
||||
});
|
||||
test('receiver reconnect while held stays stopped until neutral and a new gesture',()=>{
|
||||
const model=ready();model.step(frame(250,axes(.5)));model.step(frame(300,axes(),{link:{state:'lost',at:300}}));
|
||||
for(let t=350;t<1000;t+=50)assert.equal(model.step(frame(t,axes(.5))).state,'hold');
|
||||
for(let t=1000;t<=1200;t+=50)model.step(frame(t));
|
||||
assert.equal(model.step(frame(1250,axes(.5))).state,'rc-manual');
|
||||
});
|
||||
test('Core expiry/replay/wrong boot token/missing commands all require rearming',()=>{
|
||||
for(const change of [c=>({...c,expires:300}),c=>({...c,expires:500}),c=>({...c,token:'old-boot:1'}),()=>undefined,c=>({...c,demand:{left:Infinity,right:0}})]){
|
||||
const {model,token}=core();assert.equal(model.step(frame(300,axes(),{command:change(command(token,300))})).reason,'core-command-invalid');
|
||||
}
|
||||
const {model,token}=core();model.step(frame(300,axes(),{command:command(token,300,1)}));
|
||||
assert.equal(model.step(frame(350,axes(),{command:command(token,350,1)})).reason,'core-command-invalid');
|
||||
});
|
||||
test('clock rewind and missing observation interval cannot bypass the stop gate',()=>{
|
||||
for(const time of [249,500,NaN]){const {model,token}=core();const d=model.step(frame(time,axes(),{command:command(token,time)}));assert.equal(d.state,'hold');assert.ok(d.cancelMotionTasks);}
|
||||
});
|
||||
test('premature or replayed Core request does not acquire authority after neutral',()=>{
|
||||
const model=new AuthorityModel(base,motors,policy,'boot');
|
||||
for(let t=0;t<=300;t+=50){const d=model.step(frame(t,axes(),{requestCore:{owner:'remote',sequence:1,at:t}}));assert.notEqual(d.state,'core');}
|
||||
assert.equal(model.step(frame(350,axes(),{requestCore:{owner:'remote',sequence:2,at:350}})).state,'core');
|
||||
});
|
||||
test('reboot cannot restore authority from serialized profile or old grant',()=>{
|
||||
const {token}=core(), model=ready();
|
||||
assert.equal(model.step(frame(250,axes(),{command:command(token,250)})).state,'rc-ready');
|
||||
const next=new AuthorityModel(base,motors,policy,'boot-B');
|
||||
for(let t=0;t<=200;t+=50)next.step(frame(t));
|
||||
const grant=next.step(frame(250,axes(),{requestCore:{owner:'remote',sequence:1,at:250}}));
|
||||
assert.notEqual(grant.token,token);
|
||||
assert.equal(next.step(frame(300,axes(),{command:command(token,300)})).state,'hold');
|
||||
});
|
||||
test('profile limits do not mask RC takeover intent at zero output scale',()=>{
|
||||
const model=ready({...base,outputScale:0});const grant=model.step(frame(250,axes(),{requestCore:{owner:'remote',sequence:1,at:250}}));
|
||||
assert.ok(grant.token);assert.equal(model.step(frame(300,axes(.5))).reason,'rc-takeover');
|
||||
});
|
||||
|
||||
test('non-driving stick still takes over in Arcade and every monitored axis must return',()=>{
|
||||
const model=ready({...base,mode:'arcade',stick:'right'});
|
||||
model.step(frame(250,axes(),{requestCore:{owner:'autonomy',sequence:1,at:250}}));
|
||||
assert.equal(model.step(frame(300,axes(.5))).reason,'rc-takeover');
|
||||
for(let t=350;t<800;t+=50)assert.equal(model.step(frame(t,axes(0,0,.5))).state,'hold');
|
||||
const f=frame(800);delete f.axes.leftX;assert.equal(model.step(f).reason,'axis-invalid');
|
||||
assert.throws(()=>new AuthorityModel(base,motors,{...policy,monitoredAxes:['leftY']},'boot'));
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {build} from 'esbuild';
|
||||
const result=await build({entryPoints:[new URL('../src/core/fleet/roverInput.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm'});
|
||||
const {keyDemand,roverSettings}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
|
||||
const keys=(...k)=>new Set(k.map(v=>'Key'+v));
|
||||
test('arcade mixes directions and opposite keys cancel',()=>{
|
||||
assert.deepEqual(keyDemand('arcade',keys('W')),{left:1,right:1});
|
||||
assert.deepEqual(keyDemand('arcade',keys('A')),{left:-1,right:1});
|
||||
assert.deepEqual(keyDemand('arcade',keys('W','A')),{left:0,right:1});
|
||||
assert.deepEqual(keyDemand('arcade',keys('W','S','A','D')),{left:0,right:0});
|
||||
});
|
||||
test('tank sides and backwards remain independent',()=>{
|
||||
assert.deepEqual(keyDemand('tank',keys('Q','D')),{left:1,right:-1});
|
||||
assert.deepEqual(keyDemand('tank',keys('A','D')),{left:-1,right:-1});
|
||||
assert.deepEqual(keyDemand('tank',keys('Q','A')),{left:0,right:0});
|
||||
});
|
||||
test('invalid stored settings never grant motion or invalid limits',()=>{
|
||||
assert.equal(roverSettings({version:1,currentA:Infinity}).currentA,30);
|
||||
assert.equal(roverSettings({version:1,maxErpm:999999}).maxErpm,3000);
|
||||
assert.equal(roverSettings({version:1,model:'https://other/model.glb'}).model,'');
|
||||
assert.deepEqual(keyDemand('arcade',new Set()),{left:0,right:0});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import {build} from 'esbuild';
|
||||
import {mkdir,writeFile} from 'node:fs/promises';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {resolve,join} from 'node:path';
|
||||
|
||||
const root=fileURLToPath(new URL('.',import.meta.url));
|
||||
const destination=process.argv[2];
|
||||
if(!destination)throw Error('Pass an artifact output directory; no server is started.');
|
||||
const result=await build({entryPoints:[join(root,'main.tsx')],bundle:true,write:false,minify:true,
|
||||
format:'iife',platform:'browser',jsx:'automatic',outfile:'preview.js',define:{'process.env.NODE_ENV':'"production"'},
|
||||
alias:{'@nodedc/ui-react':resolve(root,'../../node_modules/@nodedc/ui-react/dist/index.js')},
|
||||
nodePaths:[resolve(root,'../../node_modules')],logLevel:'warning'});
|
||||
const js=result.outputFiles.find(f=>f.path.endsWith('.js')).text.replaceAll('</script','<\\/script');
|
||||
const css=result.outputFiles.find(f=>f.path.endsWith('.css')).text.replaceAll('</style','<\\/style');
|
||||
await mkdir(destination,{recursive:true});
|
||||
const html=`<!doctype html><html lang="ru" data-nodedc-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:"><title>Mission Core · Профили управления · Прототип</title><style>${css}</style></head><body data-nodedc-ui><div id="root"></div><script>${js}</script></body></html>`;
|
||||
await writeFile(join(destination,'rover-control-preview.html'),html);
|
||||
console.log(JSON.stringify({artifact:join(destination,'rover-control-preview.html'),bytes:Buffer.byteLength(html),network:'disabled by CSP',hardware:'no adapter'}));
|
||||
@@ -0,0 +1,69 @@
|
||||
import {useState} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {Button,Inspector,InspectorSelectField,RangeControl,SettingsCard,StatusBadge} from '@nodedc/ui-react';
|
||||
import '@nodedc/ui-core/styles.css';
|
||||
import {defaultProfile,mix,parseProfile,type Axes,type ControlProfile} from '../../../../packages/rover-control/src/profile';
|
||||
import {trace} from './trace';
|
||||
import './preview.css';
|
||||
|
||||
const emptyAxes:Axes={leftY:0,rightY:0,leftX:0,rightX:0};
|
||||
const percent=(n:number)=>`${Math.round(n*100)}%`;
|
||||
const states={'hold':'Остановка / ожидание нейтрали','rc-ready':'Пульт готов','rc-manual':'Ручное управление','core':'Mission Core'};
|
||||
const storageKey='missioncore.rover-control-preview.v1';
|
||||
function load():ControlProfile {
|
||||
try{const raw=localStorage.getItem(storageKey);return raw?parseProfile(JSON.parse(raw)):{...defaultProfile};}
|
||||
catch{return {...defaultProfile};}
|
||||
}
|
||||
function App(){
|
||||
const [profile,setProfile]=useState(load);
|
||||
const [axes,setAxes]=useState<Axes>(emptyAxes);
|
||||
const [open,setOpen]=useState(['settings','preview']);
|
||||
const [saved,setSaved]=useState('');
|
||||
const [light,setLight]=useState(false);
|
||||
const change=(patch:Partial<ControlProfile>)=>{
|
||||
setProfile(parseProfile({...profile,...patch,revision:profile.revision+1}));setAxes(emptyAxes);setSaved('');
|
||||
};
|
||||
const result=mix(profile,axes);
|
||||
const y=profile.mode==='tank'?'leftY':profile.stick==='right'?'rightY':'leftY';
|
||||
const x=profile.mode==='tank'?'rightY':profile.stick==='right'?'rightX':'leftX';
|
||||
const field=(key:keyof Axes,label:string)=><RangeControl label={label} value={axes[key]} min={-1} max={1} step={.01} exactValueBounds={{min:-1,max:1}} formatValue={percent} onChange={value=>setAxes({...axes,[key]:value})}/>;
|
||||
function save(){
|
||||
try{localStorage.setItem(storageKey,JSON.stringify(profile));setSaved('Черновик сохранён в этом браузере.');}
|
||||
catch{setSaved('Браузер не сохранил черновик. Используйте скачивание JSON.');}
|
||||
}
|
||||
function download(){
|
||||
const url=URL.createObjectURL(new Blob([JSON.stringify(profile,null,2)+'\n'],{type:'application/json'}));
|
||||
const a=document.createElement('a');a.href=url;a.download='rover-control-draft.json';a.click();
|
||||
setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||||
}
|
||||
return <main data-nodedc-ui>
|
||||
<header><div><p>MISSION CORE · ПРОТОТИП</p><h1>Настройки управления ровером</h1></div><Button onClick={()=>{setLight(!light);document.documentElement.dataset.nodedcTheme=light?'dark':'light';}}>Тема</Button></header>
|
||||
<p className="notice">Интерактивный макет без подключения к роверу. Все значения ниже — расчёт на экране; кнопки не обращаются к борту и VESC.</p>
|
||||
<Inspector variant="panel" openSections={open} onOpenSectionsChange={setOpen} sections={[
|
||||
{id:'settings',label:'Профиль управления',content:<SettingsCard title="Рычаги и отклик" description="Схема 1×1 / 2×2 и назначения моторов остаются отдельными настройками привода.">
|
||||
<InspectorSelectField label="Управление" value={profile.mode} options={[{value:'tank',label:'Два рычага · Tank'},{value:'arcade',label:'Один рычаг · Arcade'}]} onChange={mode=>change({mode})}/>
|
||||
{profile.mode==='arcade'&&<InspectorSelectField label="Управляющий рычаг" value={profile.stick} options={[{value:'right',label:'Правый'},{value:'left',label:'Левый'}]} onChange={stick=>change({stick})}/>}
|
||||
<p>{profile.mode==='tank'?'Левый рычаг управляет левой стороной, правый — правой.':'Вперёд/назад задаёт движение; влево/вправо — поворот, включая разворот на месте. При движении назад знак поворота корпуса сохраняется.'}</p>
|
||||
<InspectorSelectField label="Отклик рычага" value={profile.response} options={[{value:'linear',label:'Линейный'},{value:'squared',label:'Плавный у центра'}]} onChange={response=>change({response})}/>
|
||||
<RangeControl label="Зона нейтрали" value={profile.deadband} min={0} max={.5} step={.01} exactValueBounds={{min:0,max:.5}} formatValue={percent} onChange={deadband=>change({deadband})}/>
|
||||
<RangeControl label="Масштаб команды" value={profile.outputScale} min={0} max={1} step={.01} exactValueBounds={{min:0,max:1}} formatValue={percent} onChange={outputScale=>change({outputScale})}/>
|
||||
<p>Масштаб команды — относительный отклик рычага. Он не задаёт амперы, ватты или паспортный предел двигателя.</p>
|
||||
<div className="actions"><Button onClick={save}>Сохранить черновик</Button><Button onClick={download}>Скачать JSON</Button></div>
|
||||
{saved&&<p role="status">{saved}</p>}
|
||||
</SettingsCard>},
|
||||
{id:'preview',label:'Проверка профиля на экране',content:<SettingsCard title="Команды сторонам" description="Нормализованная команда от −100% до +100%. Плюс — вперёд по корпусу, минус — назад.">
|
||||
{field(y,profile.mode==='tank'?'Левый рычаг · вперёд / назад':'Движение · вперёд / назад')}
|
||||
{field(x,profile.mode==='tank'?'Правый рычаг · вперёд / назад':'Поворот · влево / вправо')}
|
||||
<div className="actions"><Button onClick={()=>setAxes(emptyAxes)}>Нейтраль</Button><Button onClick={()=>setAxes({...emptyAxes,[y]:1,[x]:profile.mode==='tank'?1:0})}>Вперёд</Button><Button onClick={()=>setAxes({...emptyAxes,[y]:-1,[x]:profile.mode==='tank'?-1:0})}>Назад</Button><Button onClick={()=>setAxes({...emptyAxes,[y]:profile.mode==='tank'?-1:0,[x]:profile.mode==='tank'?1:-1})}>Разворот влево</Button></div>
|
||||
<div className="demands" aria-live="polite">{(['left','right'] as const).map(side=><div key={side}><span>{side==='left'?'Левая сторона':'Правая сторона'}</span><strong>{percent(result[side])}</strong><span>{result[side]===0?'Нейтраль':result[side]>0?'Вперёд':'Назад'}</span></div>)}</div>
|
||||
<p>Команда стороны распространяется на все назначенные ей моторы: два, четыре, шесть и более. Связь — по UUID, а направление проверяется отдельно для каждого мотора.</p>
|
||||
</SettingsCard>},
|
||||
{id:'takeover',label:'Перехват пультом · проверка сценария',content:<SettingsCard title="Стоп → нейтраль → ручное управление" description="Расчётная модель. Существующая прошивка и проводка пока не обеспечивают этот сценарий при отказе Mini.">
|
||||
<div className="trace">{trace(profile).map(({label,decision},i)=><div className="trace-row" key={label}><span>{i+1}. {label}</span><StatusBadge tone={decision.stopAll?'warning':'neutral'}>{states[decision.state]}</StatusBadge><span>{percent(decision.demand.left)} / {percent(decision.demand.right)}</span></div>)}</div>
|
||||
<p>Первый жест отменяет допуск и очередь Mission Core. Новый допуск требует явного действия: нейтраль и восстановление связи не возобновляют автономную задачу.</p>
|
||||
</SettingsCard>},
|
||||
]}/>
|
||||
</main>;
|
||||
}
|
||||
document.documentElement.dataset.nodedcTheme='dark';
|
||||
createRoot(document.getElementById('root')!).render(<App/>);
|
||||
@@ -0,0 +1,16 @@
|
||||
/* Domain composition only; all controls come from the Design Guideline. */
|
||||
body { margin:0; background:var(--nodedc-canvas); color:var(--nodedc-text-primary); font-family:var(--nodedc-font-family); font-size:var(--nodedc-font-size-md); }
|
||||
main { max-width:960px; margin:0 auto; padding:32px 24px 64px; }
|
||||
main>header { display:flex; align-items:center; justify-content:space-between; gap:24px; margin-bottom:24px; }
|
||||
h1 { font-size:var(--nodedc-font-size-title); font-weight:600; margin:8px 0; }
|
||||
p { font-size:var(--nodedc-font-size-sm); line-height:1.6; color:var(--nodedc-text-secondary); }
|
||||
main>header p { font-size:var(--nodedc-font-size-xs); }
|
||||
.notice { margin-bottom:24px; }
|
||||
.actions { display:flex; flex-wrap:wrap; gap:12px; margin:16px 0; }
|
||||
.demands { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; margin:24px 0; }
|
||||
.demands>div { display:flex; flex-direction:column; gap:8px; }
|
||||
.demands strong { font-size:var(--nodedc-font-size-lg); font-variant-numeric:tabular-nums; }
|
||||
.demands span { font-size:var(--nodedc-font-size-sm); }
|
||||
.trace { display:grid; gap:20px; }
|
||||
.trace-row { display:grid; grid-template-columns:minmax(0,1fr) 250px 100px; align-items:center; gap:16px; font-size:var(--nodedc-font-size-sm); }
|
||||
@media (max-width:720px) { main {padding:20px 12px 40px;} .trace-row {grid-template-columns:1fr;} }
|
||||
@@ -0,0 +1,38 @@
|
||||
import {AuthorityModel, type Decision} from '../../../../packages/rover-control/src/authority';
|
||||
import {type ControlProfile} from '../../../../packages/rover-control/src/profile';
|
||||
|
||||
/** Synthetic event replay, with no wall clock, I/O, timers or hardware adapter. */
|
||||
export function trace(profile: ControlProfile): {label: string; decision: Decision}[] {
|
||||
const model = new AuthorityModel(profile, ['left', 'right'], {
|
||||
maxAgeMs:100, neutralMs:200, maxCommandMs:100, monitoredAxes:['leftY','rightY','leftX','rightX'],
|
||||
}, 'preview');
|
||||
const rows: {label:string;decision:Decision}[] = [];
|
||||
let now = -50, token = '';
|
||||
function step(value=0, link: 'live'|'lost'='live', core=false, request=false) {
|
||||
now+=50;
|
||||
const axes = {leftY:0,rightY:0,leftX:0,rightX:0};
|
||||
axes[profile.mode==='tank'?'leftY':profile.stick==='left'?'leftY':'rightY']=value;
|
||||
return model.step({now, link:{state:link,at:now},
|
||||
axes:Object.fromEntries(Object.entries(axes).map(([key,v])=>[key,{value:v,at:now,sequence:now}])),
|
||||
drives:{left:{healthy:true,stopped:value===0,at:now},right:{healthy:true,stopped:value===0,at:now}},
|
||||
requestCore:request?{owner:'autonomy',at:now,sequence:1}:undefined,
|
||||
command:core?{token,at:now,expires:now+75,sequence:now,demand:{left:.4,right:.4}}:undefined,
|
||||
});
|
||||
}
|
||||
const add=(label:string,decision:Decision)=>rows.push({label,decision});
|
||||
for(let i=0;i<5;i++)step();
|
||||
const grant=step(0,'live',false,true);token=grant.token??'';
|
||||
add('Mission Core получил явный допуск',grant);
|
||||
add('Автономная команда движения',step(0,'live',true));
|
||||
add('Первое отклонение рычага',step(.8,'live',true));
|
||||
for(let i=0;i<20;i++)step(.8);
|
||||
add('Тот же рычаг удерживается',step(.8));
|
||||
add('Остановка подтверждена, оба рычага отпущены',step());
|
||||
for(let i=0;i<3;i++)step();
|
||||
add('Непрерывная нейтраль подтверждена',step());
|
||||
add('Второе отклонение — ручное движение',step(.8));
|
||||
add('Запоздавшая команда прежней задачи',step(.8,'live',true));
|
||||
add('Радиосвязь потеряна',step(0,'lost'));
|
||||
add('Связь вернулась с отклонённым рычагом',step(.8));
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends":"../../tsconfig.app.json",
|
||||
"compilerOptions":{"incremental":false,"tsBuildInfoFile":null},
|
||||
"include":["*.tsx","*.ts","../../../../packages/rover-control/src/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user