merge: integrate final rover control with operator simulation workspace

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 16:48:42 +03:00
88 changed files with 6963 additions and 97 deletions
@@ -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});
});