import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const projectRoot = resolve(repoRoot, "web-rtcp-5axis-xyzbc-trt-sim-plan"); const nativePath = process.argv[2] || resolve(projectRoot, "working/evidence/native-xyzbc-trt-evidence.json"); const webPath = process.argv[3] || resolve(projectRoot, "working/evidence/web-xyzbc-trt-evidence.json"); const outputPath = process.argv[4] || resolve(projectRoot, "working/evidence/compare-xyzbc-trt-evidence.json"); const nativeEvidence = JSON.parse(await readFile(nativePath, "utf8")); const webEvidence = JSON.parse(await readFile(webPath, "utf8")); const pathComparison = comparePathEvidence(nativeEvidence, webEvidence); const checks = [ check("profile", "native axis mask is XYZBC", nativeEvidence.coverage?.axisProfile === true, { nativeAxisMask: nativeEvidence.after?.axisMask ?? nativeEvidence.before?.axisMask, }), check("profile", "web default profile is xyzbc-trt", webEvidence.coverage?.profileDefaultXyzbc === true, { webProfile: webEvidence.profile?.id, }), check("ini", "native loaded xyzbc switchkins program", nativeEvidence.coverage?.xyzbcProgramOpen === true, { nativeFile: nativeEvidence.after?.file ?? nativeEvidence.before?.file, }), check("ini", "web INI parse is ready", webEvidence.coverage?.iniReady === true, { webIniReady: webEvidence.ini?.ready, }), check("switchkins", "native switchkins pin readable", nativeEvidence.coverage?.switchkinsPinReadable === true, { nativePin: nativeEvidence.hal?.pins?.["motion.switchkins-type"], }), check("switchkins", "web remap files staged", webEvidence.coverage?.remapsStaged === true, { remaps: webEvidence.ini?.remaps, }), check("program", "web default xyzbc_switchkins program staged", webEvidence.coverage?.defaultProgramStaged === true, { selected: webEvidence.opfsStaging?.selectedProgram, }), check("tool-table", "web tool table staged", webEvidence.coverage?.toolTableStaged === true, { toolTable: webEvidence.ini?.toolTable, }), check("pyvcp", "web PyVCP XML staged", webEvidence.coverage?.pyvcpXmlStaged === true, { pyvcpXmlPath: webEvidence.profile?.pyvcpXmlPath, }), check("positions", "native joint feedback readable", nativeEvidence.coverage?.jointFeedbackReadable === true, { joints: nativeEvidence.after?.jointActualPosition, }), check("wasm", "required WASM artifacts available", webEvidence.coverage?.wasmArtifactsReady === true, { missing: webEvidence.wasm?.missing, }), check("parameters", "parameter file staged", webEvidence.coverage?.parameterFileStaged === true, { parameterFile: webEvidence.ini?.parameterFile, }), check("program", "web boat-xyzbc demo program staged", webEvidence.coverage?.boatProgramStaged === true, { demoPrograms: webEvidence.demoPrograms, }), check("ngcgui", "web Ngcgui/remap subroutines staged", webEvidence.coverage?.ngcguiSubroutinesStaged === true, { ngcguiSubroutines: webEvidence.ngcguiSubroutines, }), check("postgui-hal", "web PyVCP to HALUI POSTGUI nets represented", webEvidence.coverage?.postguiHalEquivalent === true, { halNets: webEvidence.halNets, }), check("kinematics", "web kinematics HAL pins represented", webEvidence.coverage?.kinematicsPinsCovered === true, { kinematicsPins: webEvidence.kinematicsPins, }), check("limits", "web TRAJ/AXIS/JOINT limits represented", webEvidence.coverage?.axisJointLimitsCovered === true, { axisJointLimits: webEvidence.axisJointLimits, }), check("path-preview", "native and web preview path sample period is 20ms", pathComparison.previewVsPreview.periodsMatch === true, { nativeSamplePeriodMs: pathComparison.previewVsPreview.nativeSamplePeriodMs, webSamplePeriodMs: pathComparison.previewVsPreview.webSamplePeriodMs, }), check("path-preview", "native and web preview paths have samples", pathComparison.previewVsPreview.comparable === true, { nativeSampleCount: pathComparison.previewVsPreview.nativeSampleCount, webSampleCount: pathComparison.previewVsPreview.webSampleCount, unavailable: pathComparison.previewVsPreview.unavailable, }), check("path-execution", "native and web execution path sample period is 20ms", pathComparison.executionVsExecution.periodsMatch === true, { nativeSamplePeriodMs: pathComparison.executionVsExecution.nativeSamplePeriodMs, webSamplePeriodMs: pathComparison.executionVsExecution.webSamplePeriodMs, }), check("path-execution", "native and web execution paths have samples", pathComparison.executionVsExecution.comparable === true, { nativeSampleCount: pathComparison.executionVsExecution.nativeSampleCount, webSampleCount: pathComparison.executionVsExecution.webSampleCount, unavailable: pathComparison.executionVsExecution.unavailable, }), check("path-preview-execution-consistency", "native preview and execution paths are comparable", pathComparison.previewVsExecutionNative.comparable === true, { nativePreviewSampleCount: pathComparison.previewVsExecutionNative.leftSampleCount, nativeExecutionSampleCount: pathComparison.previewVsExecutionNative.rightSampleCount, unavailable: pathComparison.previewVsExecutionNative.unavailable, }), check("path-preview-execution-consistency", "web preview and execution paths are comparable", pathComparison.previewVsExecutionWeb.comparable === true, { webPreviewSampleCount: pathComparison.previewVsExecutionWeb.leftSampleCount, webExecutionSampleCount: pathComparison.previewVsExecutionWeb.rightSampleCount, unavailable: pathComparison.previewVsExecutionWeb.unavailable, }), ]; const failed = checks.filter((item) => item.status !== "pass"); const blockers = [ ...(nativeEvidence.status === "blocked" ? nativeEvidence.blocker ? [nativeEvidence.blocker] : ["native-blocked"] : []), ...(webEvidence.blockers || []).map((blocker) => blocker.id), ]; const report = { apiName: "xyzbc-trt-native-web-evidence-comparison", status: failed.length === 0 ? "pass" : "fail", comparedAt: new Date().toISOString(), nativePath, webPath, summary: { checkCount: checks.length, passCount: checks.length - failed.length, failCount: failed.length, blockers, nativeStatus: nativeEvidence.status, webStatus: webEvidence.status, }, checks, pathComparison, requiredImprovements: failed.map((item) => ({ category: item.category, requirement: item.requirement, evidence: item.evidence, })), semanticBoundary: "native_linuxcnc_vs_web_opfs_wasm_xyzbc_trt_evidence_comparison", }; await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, JSON.stringify(report, null, 2) + "\n", "utf8"); console.log(`compare_xyzbc_trt_evidence=${outputPath}`); if (failed.length > 0) { console.log(`compare_xyzbc_trt_status=fail fail_count=${failed.length}`); } else { console.log("compare_xyzbc_trt_status=pass"); } function check(category, requirement, passed, evidence = {}) { return { category, requirement, status: passed ? "pass" : "fail", evidence, }; } function comparePathEvidence(nativeEvidence, webEvidence) { const samplePeriodMs = 20; return { samplePeriodMs, previewVsPreview: compareNamedPaths({ left: nativeEvidence.previewPath, right: webEvidence.previewPath, leftName: "native", rightName: "web", expectedSamplePeriodMs: samplePeriodMs, }), executionVsExecution: compareNamedPaths({ left: nativeEvidence.executionPath, right: webEvidence.executionPath, leftName: "native", rightName: "web", expectedSamplePeriodMs: samplePeriodMs, }), previewVsExecutionNative: compareNamedPaths({ left: nativeEvidence.previewPath, right: nativeEvidence.executionPath, leftName: "nativePreview", rightName: "nativeExecution", expectedSamplePeriodMs: samplePeriodMs, }), previewVsExecutionWeb: compareNamedPaths({ left: webEvidence.previewPath, right: webEvidence.executionPath, leftName: "webPreview", rightName: "webExecution", expectedSamplePeriodMs: samplePeriodMs, }), }; } function compareNamedPaths({ left, right, leftName, rightName, expectedSamplePeriodMs }) { const leftSamplePeriodMs = left?.samplePeriodMs ?? null; const rightSamplePeriodMs = right?.samplePeriodMs ?? null; const periodsMatch = leftSamplePeriodMs === expectedSamplePeriodMs && rightSamplePeriodMs === expectedSamplePeriodMs; const leftSamples = Array.isArray(left?.samples) ? left.samples : []; const rightSamples = Array.isArray(right?.samples) ? right.samples : []; const comparable = periodsMatch && leftSamples.length > 0 && rightSamples.length > 0; const unavailable = [ ...(leftSamples.length > 0 ? [] : [`${leftName}: ${left?.unavailableReason || "missing samples"}`]), ...(rightSamples.length > 0 ? [] : [`${rightName}: ${right?.unavailableReason || "missing samples"}`]), ...(periodsMatch ? [] : [`sample period mismatch ${leftSamplePeriodMs}/${rightSamplePeriodMs}`]), ]; const stats = comparable ? pathStats(leftSamples, rightSamples) : emptyStats(leftSamples, rightSamples); return { status: comparable ? "pass" : "fail", comparable, periodsMatch, [`${leftName}SamplePeriodMs`]: leftSamplePeriodMs, [`${rightName}SamplePeriodMs`]: rightSamplePeriodMs, nativeSamplePeriodMs: leftName === "native" ? leftSamplePeriodMs : undefined, webSamplePeriodMs: rightName === "web" ? rightSamplePeriodMs : undefined, leftSampleCount: leftSamples.length, rightSampleCount: rightSamples.length, nativeSampleCount: leftName === "native" ? leftSamples.length : undefined, webSampleCount: rightName === "web" ? rightSamples.length : undefined, unavailable, ...stats, }; } function pathStats(leftSamples, rightSamples) { const count = Math.min(leftSamples.length, rightSamples.length); const missingSamples = []; let maxTcpErrorMm = 0; let sumTcpErrorSquared = 0; let maxJointError = 0; let sumJointErrorSquared = 0; let maxToolAxisAngleDeg = 0; for (let index = 0; index < count; index += 1) { const left = leftSamples[index]; const right = rightSamples[index]; if (left.sampleIndex !== right.sampleIndex || left.timeMs !== right.timeMs) { missingSamples.push({ index, leftSampleIndex: left.sampleIndex, rightSampleIndex: right.sampleIndex }); } const tcpError = vectorError(left.tcp, right.tcp, ["x", "y", "z"]); const jointError = vectorError(left.joint, right.joint, ["x", "y", "z", "b", "c"]); const angleError = toolAxisAngleDeg(left.toolAxis, right.toolAxis); maxTcpErrorMm = Math.max(maxTcpErrorMm, tcpError); sumTcpErrorSquared += tcpError ** 2; maxJointError = Math.max(maxJointError, jointError); sumJointErrorSquared += jointError ** 2; maxToolAxisAngleDeg = Math.max(maxToolAxisAngleDeg, angleError); } return { maxTcpErrorMm, rmsTcpErrorMm: count > 0 ? Math.sqrt(sumTcpErrorSquared / count) : 0, maxJointError, rmsJointError: count > 0 ? Math.sqrt(sumJointErrorSquared / count) : 0, maxToolAxisAngleDeg, sampleCountDelta: Math.abs(leftSamples.length - rightSamples.length), missingSamples, }; } function emptyStats(leftSamples, rightSamples) { return { maxTcpErrorMm: null, rmsTcpErrorMm: null, maxJointError: null, rmsJointError: null, maxToolAxisAngleDeg: null, sampleCountDelta: Math.abs(leftSamples.length - rightSamples.length), missingSamples: [], }; } function vectorError(left = {}, right = {}, keys = []) { return Math.sqrt(keys.reduce((sum, key) => ( sum + (numberOrZero(left[key]) - numberOrZero(right[key])) ** 2 ), 0)); } function toolAxisAngleDeg(left = {}, right = {}) { const dot = numberOrZero(left.i) * numberOrZero(right.i) + numberOrZero(left.j) * numberOrZero(right.j) + numberOrZero(left.k) * numberOrZero(right.k); const leftLen = vectorError(left, { i: 0, j: 0, k: 0 }, ["i", "j", "k"]); const rightLen = vectorError(right, { i: 0, j: 0, k: 0 }, ["i", "j", "k"]); if (leftLen <= 0 || rightLen <= 0) return 0; const cosine = Math.max(-1, Math.min(1, dot / (leftLen * rightLen))); return Math.acos(cosine) * 180 / Math.PI; } function numberOrZero(value) { const number = Number(value); return Number.isFinite(number) ? number : 0; }