feat: finish Stage 2B continuous sorter realism and local deploy
Unify CAD conveyor on / and /details, add RealSense D435i + contact paddle physics, keep items moving through scan, lock 7×10 drop validation, and ship Stage 2 artifacts with production on :3100 (permanent HTTPS still blocked). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
92
scripts/drop-validation.mts
Normal file
92
scripts/drop-validation.mts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Stage 2B §16 — mandatory drop validation: 7 routes × 10 runs (headless Rapier).
|
||||
*
|
||||
* Uses the SAME simulateDrop as the unit tests — same colliders, profiles,
|
||||
* handoff and pusher as the runtime. Writes docs/stage2_real_sorter/drop-validation.json.
|
||||
*
|
||||
* Note: the sim is deterministic (fixed dt, no RNG), so the 10 runs per route
|
||||
* are identical repetitions validating stability, not a Monte Carlo sample.
|
||||
* Runtime variability is covered by the browser e2e (variable frame rate).
|
||||
*/
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { initRapier, simulateDrop } from '../src/domain/physicsDropSim';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const OUT = join(here, '../docs/stage2_real_sorter/drop-validation.json');
|
||||
|
||||
const RUNS = 10;
|
||||
const ROUTES: { sku: string; zone: 'B' | 'C' | 'D'; label: string }[] = [
|
||||
{ sku: 'SKU-001', zone: 'B', label: 'box → B' },
|
||||
{ sku: 'SKU-009', zone: 'C', label: 'pen → C' },
|
||||
{ sku: 'SKU-005', zone: 'C', label: 'pouf → C' },
|
||||
{ sku: 'SKU-004', zone: 'C', label: 'oversized → C' },
|
||||
{ sku: 'SKU-007', zone: 'D', label: 'bottle → D' },
|
||||
{ sku: 'SKU-006', zone: 'D', label: 'plate → D' },
|
||||
{ sku: 'SKU-008', zone: 'D', label: 'cylinder → D' },
|
||||
];
|
||||
|
||||
// Work-area bounds (m) — anything outside is out-of-bounds.
|
||||
const BOUNDS = { x: [-2.6, 4.2], y: [-0.05, 2.5], z: [-2.6, 2.6] };
|
||||
const SPEED_TELEPORT_CAP = 8; // m/s — above this is a solver blowup/teleport
|
||||
const PENETRATION_DEPTH = 0.006; // m below the belt surface = critical penetration
|
||||
|
||||
await initRapier();
|
||||
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
sim: 'headless Rapier via simulateDrop (deterministic, dt=1/60, settle 6s)',
|
||||
runs: RUNS,
|
||||
routes: [] as unknown[],
|
||||
summary: { totalRoutes: 0, passedRoutes: 0, result: 'PASS' as 'PASS' | 'FAIL' },
|
||||
};
|
||||
|
||||
for (const route of ROUTES) {
|
||||
let correct = 0;
|
||||
let penetrations = 0;
|
||||
let teleports = 0;
|
||||
let outOfBounds = 0;
|
||||
let frozenInAir = 0;
|
||||
let pusherContacts = 0;
|
||||
const finals: string[] = [];
|
||||
for (let run = 0; run < RUNS; run += 1) {
|
||||
const r = simulateDrop(route.sku, route.zone);
|
||||
if (r.insideExpectedReceiver) correct += 1;
|
||||
if (r.minClearanceM < -PENETRATION_DEPTH) penetrations += 1;
|
||||
if (r.maxSpeedMps > SPEED_TELEPORT_CAP) teleports += 1;
|
||||
const [x, y, z] = r.finalPosition;
|
||||
if (x < BOUNDS.x[0] || x > BOUNDS.x[1] || y < BOUNDS.y[0] || y > BOUNDS.y[1] || z < BOUNDS.z[0] || z > BOUNDS.z[1]) {
|
||||
outOfBounds += 1;
|
||||
}
|
||||
// Frozen in air: settled above floor level but NOT inside the receiver.
|
||||
if (!r.insideExpectedReceiver && y > 0.4) frozenInAir += 1;
|
||||
if (r.pusherContactMade) pusherContacts += 1;
|
||||
if (run < 3) finals.push(`(${x.toFixed(2)},${y.toFixed(2)},${z.toFixed(2)})`);
|
||||
}
|
||||
const pass = correct === RUNS && penetrations === 0 && teleports === 0 && outOfBounds === 0 && frozenInAir === 0
|
||||
&& (route.zone === 'B' || pusherContacts === RUNS);
|
||||
report.routes.push({
|
||||
sku: route.sku,
|
||||
route: route.label,
|
||||
expectedZone: route.zone,
|
||||
runs: RUNS,
|
||||
correctLandings: correct,
|
||||
penetrations,
|
||||
teleports,
|
||||
outOfBounds,
|
||||
frozenInAir,
|
||||
pusherContacts: route.zone === 'B' ? 'n/a' : pusherContacts,
|
||||
sampleFinalPositions: finals,
|
||||
result: pass ? 'PASS' : 'FAIL',
|
||||
});
|
||||
report.summary.totalRoutes += 1;
|
||||
if (pass) report.summary.passedRoutes += 1;
|
||||
console.log(`${pass ? 'PASS' : 'FAIL'} ${route.label} (${route.sku}): ${correct}/${RUNS} landings, pen=${penetrations}, tp=${teleports}, oob=${outOfBounds}, air=${frozenInAir}, pusher=${route.zone === 'B' ? 'n/a' : pusherContacts}`);
|
||||
}
|
||||
|
||||
if (report.summary.passedRoutes !== report.summary.totalRoutes) report.summary.result = 'FAIL';
|
||||
mkdirSync(dirname(OUT), { recursive: true });
|
||||
writeFileSync(OUT, JSON.stringify(report, null, 2));
|
||||
console.log(`\n${report.summary.passedRoutes}/${report.summary.totalRoutes} routes PASS -> ${OUT}`);
|
||||
process.exit(report.summary.result === 'PASS' ? 0 : 1);
|
||||
Reference in New Issue
Block a user