292 lines
13 KiB
JavaScript
292 lines
13 KiB
JavaScript
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("tool-table", "web tool table active offset drives kinematics, path, and Vismach", webEvidence.coverage?.toolTableToToolOffsetClosed === true, {
|
|
toolRuntime: {
|
|
activeToolNumber: webEvidence.toolRuntime?.activeToolNumber,
|
|
activePocket: webEvidence.toolRuntime?.activePocket,
|
|
kinematicsToolOffsetZ: webEvidence.toolRuntime?.kinematics?.toolOffsetZ,
|
|
pathTool: webEvidence.toolRuntime?.pathTool,
|
|
vismachToolOffset: webEvidence.toolRuntime?.vismach?.toolOffset,
|
|
defaultActivationUsed: webEvidence.toolRuntime?.defaultActivationUsed,
|
|
},
|
|
toolOffsetClosure: webEvidence.toolOffsetClosure,
|
|
}),
|
|
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("ui", "web AXIS first screen exposes program, coordinates, status, MDI/switchkins, override, tool, preview, and execution", webEvidence.coverage?.axisMainUiEquivalent === true, {
|
|
axisMainUi: webEvidence.axisMainUi,
|
|
}),
|
|
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;
|
|
}
|