Files
cnc_wams/web-rtcp-5axis-sim-plan/tools/collect-linuxcnc-web-parity.mjs

631 lines
20 KiB
JavaScript

#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { basename, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createMemorySessionStorage } from "../app/src/runtime/five-axis-session.js";
import {
createLinuxCncInterpreterRuntime,
} from "../app/src/runtime/linuxcnc-interpreter-runtime.js";
import {
selectMachineFileProgram,
stageProfileMachineFiles,
} from "../app/src/runtime/linuxcnc-machine-file-staging.js";
import { getFiveAxisProfile } from "../app/src/profiles/index.js";
const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const WORKSPACE_ROOT = resolve(PROJECT_ROOT, "..");
const LINUXCNC_ROOT = resolve(WORKSPACE_ROOT, "linuxcnc");
const GMOCAPY_MACHINE_REL = "configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting";
const GMOCAPY_MACHINE_DIR = resolve(LINUXCNC_ROOT, GMOCAPY_MACHINE_REL);
const GMOCAPY_SOURCE_PREFIX = `${GMOCAPY_MACHINE_REL}/examples/`;
const DEFAULT_CASES = [
"impeller-7bl-xyzac.ngc",
"boat-xyzac.ngc",
];
const AXES = ["x", "y", "z", "a", "b", "c", "u", "v", "w"];
const PLANE_AXIS_MAP = {
170: ["x", "y", "z"],
180: ["z", "x", "y"],
190: ["y", "z", "x"],
};
const options = parseArgs(process.argv.slice(2));
const outputDir = resolve(PROJECT_ROOT, options.outputDir || "working_run/linuxcnc-web-parity");
const cases = options.cases.length > 0 ? options.cases : DEFAULT_CASES;
mkdirSync(outputDir, { recursive: true });
const runtime = await createLinuxCncInterpreterRuntime();
const profile = getFiveAxisProfile("gmoccapy-xyzac-trt");
const staged = await stageProfileMachineFiles(profile, {
storage: createMemorySessionStorage(),
});
const reports = [];
for (const filename of cases) {
const sourceRel = `${GMOCAPY_SOURCE_PREFIX}${filename}`;
const programPath = resolve(GMOCAPY_MACHINE_DIR, "examples", filename);
const programText = readFileSync(programPath, "utf8");
const native = collectNativeLinuxCncResult({ filename, programPath, programText });
const web = collectWebResult({ filename, sourceRel, programText, runtime, staged });
const comparison = compareResults(native, web);
const slug = filename.replace(/\.ngc$/i, "");
const nativePath = resolve(outputDir, `linuxcnc-native-${slug}.json`);
const webPath = resolve(outputDir, `web-runtime-${slug}.json`);
const comparisonPath = resolve(outputDir, `comparison-${slug}.json`);
writeJson(nativePath, native);
writeJson(webPath, web);
writeJson(comparisonPath, comparison);
reports.push({
filename,
nativePath,
webPath,
comparisonPath,
passed: comparison.summary.passed,
mismatchCount: comparison.summary.mismatchCount,
maxAxisDelta: comparison.summary.maxAxisDelta,
nativeMotionCount: native.summary.motionEventCount,
webMotionCount: web.summary.motionEventCount,
});
}
const indexPath = resolve(outputDir, "index.json");
writeJson(indexPath, {
apiName: "web-rtcp-5axis-linuxcnc-web-parity-index",
generatedAt: new Date().toISOString(),
profileId: profile.id,
outputDir,
linuxCncRoot: LINUXCNC_ROOT,
projectRoot: PROJECT_ROOT,
cases: reports,
});
console.log(`parity_output_dir=${outputDir}`);
for (const report of reports) {
console.log(`${report.filename}: passed=${report.passed} mismatches=${report.mismatchCount} native_motion=${report.nativeMotionCount} web_motion=${report.webMotionCount}`);
}
console.log(`parity_index=${indexPath}`);
function collectNativeLinuxCncResult({ filename, programPath, programText }) {
const tempDir = resolve("/tmp", `web-rtcp-native-rs274-${process.pid}-${Date.now()}-${filename.replace(/[^a-z0-9]/gi, "_")}`);
mkdirSync(tempDir, { recursive: true });
const tempVarPath = resolve(tempDir, "xyzac.var");
copyFileSync(resolve(GMOCAPY_MACHINE_DIR, "xyzac.var"), tempVarPath);
try {
const result = spawnSync(resolve(LINUXCNC_ROOT, "scripts/rip-environment"), [
"rs274",
"-g",
"-i",
"xyzac-trt.ini",
"-t",
"xyzac-trt.tbl",
"-v",
tempVarPath,
`examples/${basename(programPath)}`,
], {
cwd: GMOCAPY_MACHINE_DIR,
encoding: "utf8",
maxBuffer: 256 * 1024 * 1024,
});
const resultText = `${result.stdout || ""}${result.stderr || ""}`;
const canonical = parseRs274CanonicalOutput(resultText, programText);
const executionPath = buildExecutionPathFromMotion(canonical.motion);
return createResultArtifact({
source: "linuxcnc-native-rs274",
semanticBoundary: "linuxcnc_source_tree_rs274_gmoccapy_ini_canonical_output",
filename,
sourceRel: `${GMOCAPY_SOURCE_PREFIX}${filename}`,
programText,
resultText,
motion: canonical.motion,
canonicalEvents: canonical.canonicalEvents,
lineExecution: canonical.lineExecution,
executionPath,
runtime: {
command: "scripts/rip-environment rs274 -g -i xyzac-trt.ini -t xyzac-trt.tbl -v <temp-var> examples/<program>",
exitCode: result.status,
signal: result.signal,
cwd: GMOCAPY_MACHINE_DIR,
tempParameterFile: tempVarPath,
},
});
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
}
function collectWebResult({ filename, sourceRel, programText, runtime, staged }) {
const plan = selectMachineFileProgram(staged.plan, staged.save, sourceRel);
const machineFileExecution = runtime.runMachineFileProgram({
plan,
files: staged.save.files,
executionMode: "fiveAxisRemap",
});
const directExecution = runtime.runProgram(programText);
const selectedExecution = machineFileExecution.summary.motionEventCount > 0
? machineFileExecution
: directExecution;
return createResultArtifact({
source: "web-runtime-wasm",
semanticBoundary: selectedExecution.semanticBoundary,
filename,
sourceRel,
programText,
resultText: selectedExecution.resultText,
motion: selectedExecution.motion,
canonicalEvents: extractWebCanonicalEvents(selectedExecution.resultText),
lineExecution: buildLineExecution(selectedExecution.motion, programText),
executionPath: selectedExecution.plannerTiming?.samples || [],
plannerTiming: selectedExecution.plannerTiming,
runtime: {
selectedMode: selectedExecution === machineFileExecution
? "linuxcnc-machine-file-remap-wasm"
: "linuxcnc-interpreter-wasm",
directSummary: directExecution.summary,
machineFileSummary: machineFileExecution.summary,
machineFilePlan: machineFileExecution.machineFilePlan,
machineFileDiagnostics: extractDiagnostics(machineFileExecution.resultText),
},
});
}
function createResultArtifact({
source,
semanticBoundary,
filename,
sourceRel,
programText,
resultText,
motion,
canonicalEvents,
lineExecution,
executionPath,
plannerTiming = null,
runtime,
}) {
return {
apiName: "web-rtcp-5axis-gcode-run-result",
generatedAt: new Date().toISOString(),
source,
semanticBoundary,
profileId: "gmoccapy-xyzac-trt",
filename,
sourceRel,
program: {
lineCount: programText.split(/\r?\n/).length,
nonBlankLineCount: programText.split(/\r?\n/).filter((line) => line.trim()).length,
sha256: sha256(programText),
},
summary: {
ready: motion.length > 0,
canonicalEventCount: canonicalEvents.length,
motionEventCount: motion.length,
executedPathPointCount: executionPath.length,
lineExecutionCount: lineExecution.length,
motionTypes: [...new Set(motion.map((event) => event.type))],
finalAxes: motion.at(-1)?.axes || Object.fromEntries(AXES.map((axis) => [axis, 0])),
firstMotionLine: firstFiniteLine(motion),
lastMotionLine: lastFiniteLine(motion),
plannerRuntimeReady: plannerTiming?.plannerRuntimeReady === true || executionPath.length > 0,
},
toolPreviewPath: motion,
toolExecutionPath: executionPath,
lineExecution,
canonicalEvents,
runtime,
plannerTiming,
rawResultText: resultText,
};
}
function parseRs274CanonicalOutput(resultText, programText) {
const canonicalEvents = [];
const motion = [];
const axes = Object.fromEntries(AXES.map((axis) => [axis, 0]));
const sourceLines = programLineMap(programText);
const candidateLines = candidateMotionSourceLines(programText);
let candidateIndex = 0;
let activePlane = 170;
let activeFeedRate = null;
let activeFeedMode = "units-per-minute";
let activeLinearUnits = "mm";
for (const textLine of String(resultText).split(/\r?\n/)) {
const parsed = parseRs274Line(textLine);
if (!parsed) continue;
canonicalEvents.push(parsed);
if (parsed.name === "USE_LENGTH_UNITS") {
activeLinearUnits = parsed.args[0] === "CANON_UNITS_INCHES" ? "inch" : "mm";
}
if (parsed.name === "SET_FEED_MODE") {
activeFeedMode = "units-per-minute";
}
if (parsed.name === "COMMENT" && /inverse time/i.test(parsed.args.join(" "))) {
activeFeedMode = "inverse-time";
}
if (parsed.name === "SELECT_PLANE") {
activePlane = Number(parsed.args[0]) || activePlane;
}
if (parsed.name === "SET_FEED_RATE") {
const feed = Number(parsed.args[0]);
activeFeedRate = Number.isFinite(feed) && feed > 0 ? feed : activeFeedRate;
}
if (!["STRAIGHT_TRAVERSE", "STRAIGHT_FEED", "ARC_FEED"].includes(parsed.name)) continue;
const mappedLine = candidateLines[candidateIndex++] || null;
if (parsed.name === "ARC_FEED") {
const [firstAxis, secondAxis, thirdAxis] = PLANE_AXIS_MAP[activePlane] ?? PLANE_AXIS_MAP[170];
const [firstEnd, secondEnd, centerFirst, centerSecond, rotation, axisEndPoint] = parsed.args.map(Number);
if (Number.isFinite(firstEnd)) axes[firstAxis] = firstEnd;
if (Number.isFinite(secondEnd)) axes[secondAxis] = secondEnd;
if (Number.isFinite(axisEndPoint)) axes[thirdAxis] = axisEndPoint;
axes.arc = {
plane: activePlane,
firstAxis,
secondAxis,
thirdAxis,
firstEnd,
secondEnd,
centerFirst,
centerSecond,
rotation,
axisEndPoint,
};
} else {
for (const [index, axis] of AXES.slice(0, 6).entries()) {
const value = Number(parsed.args[index]);
if (Number.isFinite(value)) axes[axis] = value;
}
}
motion.push({
index: motion.length,
type: parsed.name,
canonicalSequence: parsed.sequence,
line: mappedLine?.line ?? null,
statement: mappedLine?.statement ?? "-",
axes: { ...axes },
feedRate: activeFeedRate,
feedMode: activeFeedMode,
linearUnits: activeLinearUnits,
raw: parsed.raw,
lineMappingSource: mappedLine ? "source-motion-line-order" : "unmapped-native-canonical-order",
});
}
return {
canonicalEvents,
motion,
lineExecution: buildLineExecution(motion, programText),
};
}
function parseRs274Line(line) {
const match = String(line).match(/^\s*(\d+)\s+N\.+\s+([A-Z_]+)\((.*)\)\s*$/);
if (!match) return null;
return {
sequence: Number(match[1]),
name: match[2],
args: splitCanonicalArgs(match[3]),
raw: line,
};
}
function splitCanonicalArgs(text) {
const args = [];
let current = "";
let quoted = false;
for (const char of String(text)) {
if (char === "\"") {
quoted = !quoted;
current += char;
continue;
}
if (char === "," && !quoted) {
args.push(cleanArg(current));
current = "";
continue;
}
current += char;
}
if (current.length > 0 || text.length > 0) args.push(cleanArg(current));
return args;
}
function cleanArg(value) {
return String(value).trim().replace(/^"(.*)"$/s, "$1");
}
function extractWebCanonicalEvents(resultText) {
return String(resultText).split(/\r?\n/)
.filter((line) => line.startsWith("canon_event="))
.map((line, index) => ({
sequence: index + 1,
name: line.slice("canon_event=".length).split(/\s+/)[0],
raw: line,
}));
}
function candidateMotionSourceLines(programText) {
const candidates = [];
let activeMotion = null;
String(programText).split(/\r?\n/).forEach((line, index) => {
const codeOnly = stripComments(line);
const lineNumber = index + 1;
let lineMotion = null;
for (const match of codeOnly.matchAll(/\bG\s*([0-9]+(?:\.[0-9]+)?)\b/gi)) {
const value = Number(match[1]);
if (value === 0 || value === 1 || value === 2 || value === 3) {
lineMotion = value;
activeMotion = value;
}
}
const hasAxisWord = /\b[XYZABCUVW]\s*[-+#0-9.]/i.test(codeOnly);
if (lineMotion !== null || (hasAxisWord && activeMotion !== null)) {
candidates.push({
line: lineNumber,
statement: line.trim() || "(blank)",
});
}
});
return candidates;
}
function buildExecutionPathFromMotion(motion) {
let elapsedSeconds = 0;
return motion.map((event, index) => {
const previous = motion[index - 1]?.axes || Object.fromEntries(AXES.map((axis) => [axis, 0]));
const distance = vectorLength(["x", "y", "z"].map((axis) => (
Number(event.axes?.[axis] || 0) - Number(previous?.[axis] || 0)
)));
const velocity = event.type === "STRAIGHT_TRAVERSE"
? 2100
: Math.max(Number(event.feedRate) || 100, 1);
const durationSeconds = distance > 0 ? distance / (velocity / 60) : 0;
elapsedSeconds += durationSeconds;
return {
sampleIndex: index,
timeSeconds: elapsedSeconds,
motionIndex: index,
line: event.line,
type: event.type,
axes: event.axes,
currentVelocityMmPerMin: velocity,
source: "native-rs274-canonical-endpoint-timing-estimate",
};
});
}
function buildLineExecution(motion, programText) {
const lines = programLineMap(programText);
const byLine = new Map();
for (const [line, statement] of lines.entries()) {
const executable = stripComments(statement).trim().length > 0;
byLine.set(line, {
line,
statement,
executable,
executionState: executable ? "no-motion" : "blank-or-comment",
canonicalEventCount: 0,
motionEventCount: 0,
motionTypes: [],
firstMotionIndex: null,
lastMotionIndex: null,
finalAxes: null,
events: [],
});
}
for (const [motionOrder, event] of motion.entries()) {
const motionIndex = event.index ?? motionOrder;
const line = Number(event.line);
if (!Number.isFinite(line)) continue;
const entry = byLine.get(line) || {
line,
statement: event.statement || "-",
executable: true,
executionState: "no-motion",
canonicalEventCount: 0,
motionEventCount: 0,
motionTypes: [],
firstMotionIndex: null,
lastMotionIndex: null,
finalAxes: null,
events: [],
};
byLine.set(line, entry);
entry.executionState = "motion";
entry.canonicalEventCount += 1;
entry.motionEventCount += 1;
if (entry.firstMotionIndex === null) entry.firstMotionIndex = motionIndex;
entry.lastMotionIndex = motionIndex;
entry.finalAxes = event.axes;
if (!entry.motionTypes.includes(event.type)) entry.motionTypes.push(event.type);
entry.events.push({
index: motionIndex,
type: event.type,
axes: event.axes,
feedRate: event.feedRate,
raw: event.raw,
});
}
return [...byLine.values()].sort((left, right) => left.line - right.line);
}
function compareResults(native, web) {
const mismatches = [];
compareNumber("motionEventCount", native.summary.motionEventCount, web.summary.motionEventCount, 0, mismatches);
const maxCount = Math.min(native.toolPreviewPath.length, web.toolPreviewPath.length);
let maxAxisDelta = 0;
for (let index = 0; index < maxCount; index += 1) {
const nativeEvent = native.toolPreviewPath[index];
const webEvent = web.toolPreviewPath[index];
if (nativeEvent.type !== webEvent.type) {
mismatches.push({
kind: "motion-type",
index,
native: nativeEvent.type,
web: webEvent.type,
});
}
for (const axis of AXES) {
const delta = Math.abs(Number(nativeEvent.axes?.[axis] || 0) - Number(webEvent.axes?.[axis] || 0));
maxAxisDelta = Math.max(maxAxisDelta, delta);
if (delta > 0.001) {
mismatches.push({
kind: "axis",
index,
axis,
native: nativeEvent.axes?.[axis] ?? null,
web: webEvent.axes?.[axis] ?? null,
delta,
});
break;
}
}
const nativeFeed = Number(nativeEvent.feedRate);
const webFeed = Number(webEvent.feedRate);
if (Number.isFinite(nativeFeed) && Number.isFinite(webFeed) && Math.abs(nativeFeed - webFeed) > 0.01) {
mismatches.push({
kind: "feed-rate",
index,
native: nativeFeed,
web: webFeed,
delta: Math.abs(nativeFeed - webFeed),
});
}
if (mismatches.length > 200) break;
}
const diagnostics = {
nativeExecutionPathBasis: native.toolExecutionPath[0]?.source || "linuxcnc-native-rs274-canonical",
webExecutionPathBasis: web.plannerTiming?.semanticBoundary || web.toolExecutionPath[0]?.source || "unknown",
nativeLineMapping: native.toolPreviewPath[0]?.lineMappingSource || null,
webSelectedMode: web.runtime.selectedMode,
webMachineFileDiagnostics: web.runtime.machineFileDiagnostics,
};
return {
apiName: "web-rtcp-5axis-linuxcnc-web-parity-comparison",
generatedAt: new Date().toISOString(),
profileId: "gmoccapy-xyzac-trt",
filename: native.filename,
summary: {
passed: mismatches.length === 0,
mismatchCount: mismatches.length,
comparedMotionEvents: maxCount,
maxAxisDelta,
nativeMotionCount: native.summary.motionEventCount,
webMotionCount: web.summary.motionEventCount,
nativeExecutedPathPointCount: native.summary.executedPathPointCount,
webExecutedPathPointCount: web.summary.executedPathPointCount,
webPlannerRuntimeReady: web.summary.plannerRuntimeReady,
},
tolerances: {
axis: 0.001,
feedRate: 0.01,
},
diagnostics,
mismatches,
};
}
function compareNumber(field, nativeValue, webValue, tolerance, mismatches) {
const delta = Math.abs(Number(nativeValue) - Number(webValue));
if (delta > tolerance) {
mismatches.push({ kind: "summary", field, native: nativeValue, web: webValue, delta });
}
}
function extractDiagnostics(resultText) {
const diagnostics = {};
for (const line of String(resultText).split(/\r?\n/)) {
if (line.startsWith("fiveaxis_hal_switchkins:")) {
const rc = line.match(/\brc=([-+0-9.eE]+)/);
const found = line.match(/\bfound=([-+0-9.eE]+)/);
const value = line.match(/\bvalue=([-+0-9.eE]+)/);
diagnostics.fiveaxis_hal_switchkins = {
rc: rc ? Number(rc[1]) : null,
found: found ? Number(found[1]) : null,
value: value ? Number(value[1]) : null,
};
continue;
}
if (!line.startsWith("fiveaxis_")) continue;
const equals = line.indexOf("=");
if (equals < 0) continue;
diagnostics[line.slice(0, equals)] = line.slice(equals + 1);
}
return diagnostics;
}
function programLineMap(programText) {
const map = new Map();
String(programText).split(/\r?\n/).forEach((line, index) => {
map.set(index + 1, line.trim() || "(blank)");
});
return map;
}
function stripComments(line) {
return String(line)
.replace(/\([^)]*\)/g, " ")
.replace(/;.*$/g, " ");
}
function vectorLength(values) {
return Math.sqrt(values.reduce((total, value) => total + value * value, 0));
}
function firstFiniteLine(motion) {
return motion.find((event) => Number.isFinite(Number(event.line)))?.line ?? null;
}
function lastFiniteLine(motion) {
return [...motion].reverse().find((event) => Number.isFinite(Number(event.line)))?.line ?? null;
}
function writeJson(path, value) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
}
function sha256(text) {
return createHash("sha256").update(text).digest("hex");
}
function parseArgs(args) {
const parsed = {
cases: [],
outputDir: null,
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--out" || arg === "--output-dir") {
parsed.outputDir = args[++index];
continue;
}
if (arg === "--case") {
parsed.cases.push(args[++index]);
continue;
}
if (arg === "--cases") {
parsed.cases.push(...String(args[++index] || "").split(",").filter(Boolean));
continue;
}
if (arg === "--help" || arg === "-h") {
console.log("Usage: node tools/collect-linuxcnc-web-parity.mjs [--out DIR] [--case file.ngc] [--cases a.ngc,b.ngc]");
process.exit(0);
}
parsed.cases.push(arg);
}
return parsed;
}