Files
cnc_wams/web-rtcp-5axis-xyzbc-trt-sim-plan/tests/node/verify_run_feedback_loop.mjs
2026-07-09 18:12:01 -04:00

286 lines
12 KiB
JavaScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createLinuxCncTaskHalSdk } from "../../../wasm-port/runtime/sdk/src/linuxcnc-task-hal.js";
import { getFiveAxisProfile } from "../../app/src/profiles/index.js";
import { createLinuxCncInterpreterRuntime } from "../../app/src/runtime/linuxcnc-interpreter-runtime.js";
import { createLinuxCncKinematicsRuntime } from "../../app/src/runtime/linuxcnc-kinematics-runtime.js";
import { parseLinuxCncIni } from "../../app/src/runtime/linuxcnc-ini-runtime.js";
import { wrapTaskHalSdk } from "../../app/src/runtime/linuxcnc-task-hal-runtime.js";
import { createSimulationStore } from "../../app/src/state/store.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const rootDir = resolve(__dirname, "../../..");
const wasmPath = resolve(rootDir, "wasm-port/build/wasm/task-hal/linuxcnc_task_hal.wasm");
await verifyRunFeedbackLoop({
profileId: "xyzac-trt",
sourceRel: "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_1.ngc",
stopAction: "STOP",
});
await verifyRunReadySequence({
profileId: "xyzac-trt",
sourceRel: "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_1.ngc",
});
await verifyRunFeedbackLoop({
profileId: "xyzac-trt",
sourceRel: "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_1.ngc",
stopAction: "ABORT",
});
await verifyRunFeedbackLoop({
profileId: "xyzbc-trt",
sourceRel: "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/boat-xyzbc.ngc",
stopAction: "STOP",
});
console.log("run_feedback_status_loop_smoke=ok");
console.log("run_ready_sequence_smoke=ok");
console.log("pause_uses_motion_paused_gate=ok");
console.log("pause_freezes_task_hal_status_loop=ok");
async function verifyRunFeedbackLoop({ profileId, sourceRel, stopAction }) {
const sdk = await createLinuxCncTaskHalSdk({
wasmBinary: readFileSync(wasmPath),
print() {},
printErr(message) {
console.error(message);
},
});
const profile = getFiveAxisProfile(profileId);
const iniText = readFileSync(resolve(rootDir, "wasm-port/vendor/linuxcnc", profile.iniPath), "utf8");
const iniConfig = parseLinuxCncIni(iniText, {
path: profile.iniPath,
profileId: profile.id,
});
const kinematicsModuleId = iniConfig.kinematicsModuleId;
const store = createSimulationStore();
store.dispatch({ type: "ATTACH_INI_CONFIG", profileId: profile.id, iniConfig });
store.dispatch({
type: "ATTACH_KINEMATICS_RUNTIME",
runtime: await createLinuxCncKinematicsRuntime({ moduleId: kinematicsModuleId }),
});
store.dispatch({
type: "ATTACH_INTERPRETER_RUNTIME",
runtime: await createLinuxCncInterpreterRuntime(),
});
store.dispatch({ type: "ATTACH_TASK_HAL_RUNTIME", runtime: wrapTaskHalSdk(sdk) });
await store.stageMachineFiles();
store.dispatch({ type: "LOAD_LINUXCNC_GCODE_SOURCE", sourceRel });
await waitForState(store, (state) => (
state.taskHalSession?.programPath?.endsWith(sourceRel.split("/").at(-1)) &&
state.rtcpFrame?.sourceMode === "source-derived-kinematics-wasm"
));
store.dispatch({ type: "TOGGLE_POWER" });
await waitForTaskHalCommand(store);
store.dispatch({ type: "HOME" });
store.dispatch({ type: "SET_MODE", mode: "auto" });
await waitForTaskHalCommand(store);
store.dispatch({ type: "RUN" });
await waitForTaskHalCommand(store);
await waitForState(store, (state) => distinctActiveLines(state.programRuntimeFeedbackHistory).length >= 3);
let state = store.getState();
const history = state.programRuntimeFeedbackHistory;
if (state.taskHalStatusLoop.profileId) {
assert.equal(state.taskHalStatusLoop.profileId, profileId);
}
if (state.taskHalStatusLoop.kinematicsModuleId) {
assert.equal(state.taskHalStatusLoop.kinematicsModuleId, kinematicsModuleId);
}
assert.equal(state.taskHalStatusLoop.taskPeriodNs, 10000000);
assert.equal(state.taskHalStatusLoop.servoPeriodNs, 1000000);
assert.equal(history.length >= 3, true);
assert.equal(history.every((entry) => entry.sourceMode === "linuxcnc-task-motion-hal-wasm"), true);
assert.equal(history.every((entry) => entry.semanticBoundary === "linuxcnc_task_motion_hal_wasm_simulation_runtime"), true);
assert.equal(history.some((entry) => entry.sourceMode === "fixture-line-playback"), false);
assert.equal(history.every((entry) => entry.activeLineSource === "emc-status"), true);
assert.equal(history.every((entry) => entry.activeLineHalSynced === true), true);
assert.equal(history.every((entry) => entry.line === entry.motionProgramLine), true);
assert.equal(history.every((entry) => entry.line === entry.halProgramLine), true);
assert.equal(history.some((entry) => entry.currentVelocityMmPerMin > 0), true);
assert.equal(history.some((entry) => entry.currentVelocityMmPerMin !== 3600), true);
assert.equal(isMonotonic(history.map((entry) => entry.taskCycle).reverse()), true);
assert.equal(isMonotonic(history.map((entry) => entry.cycle).reverse()), true);
const activeLines = distinctActiveLines(history);
assert.equal(activeLines.length >= 3, true, `${sourceRel} activeLines=${activeLines.join(",")}`);
assert.equal(isMonotonic(activeLines), true, `${sourceRel} activeLines=${activeLines.join(",")}`);
assert.equal(
sourceRel.endsWith("boat-xyzbc.ngc") ? activeLines.join(",") !== "1,10" : true,
true,
`${sourceRel} activeLines=${activeLines.join(",")}`,
);
assert.equal(
sourceRel.endsWith("boat-xyzbc.ngc") ? activeLines.some((line) => line > activeLines.indexOf(line) + 1) : true,
true,
`${sourceRel} activeLines=${activeLines.join(",")}`,
);
assert.equal(state.programExecutionSourceMode, "linuxcnc-task-motion-hal-wasm");
assert.equal(state.taskHalStatus.summary.taskRuntimeReady, true);
assert.equal(state.taskHalStatus.summary.halSyncReady, true);
const activeLineBeforePause = state.activeLine;
const motionLineBeforePause = state.taskHalStatus.emcStatus.motion.programLine;
const motionAxisBeforePause = { ...state.taskHalStatus.ui.axisPose };
store.dispatch({ type: "PAUSE_RESUME" });
await waitForTaskHalCommand(store);
state = store.getState();
assert.equal(state.runState, "paused");
assert.equal(state.machine.motionPaused, true);
assert.equal(state.machine.taskPaused, true);
assert.equal(state.taskHalStatus.ui.motionPaused, true);
assert.equal(state.taskHalStatus.emcStatus.motion.paused, true);
assert.equal(state.programRuntimeFeedback.paused, true);
assert.equal(state.activeLine, activeLineBeforePause);
assert.equal(state.taskHalStatus.emcStatus.motion.programLine, motionLineBeforePause);
assert.deepEqual(state.taskHalStatus.ui.axisPose, motionAxisBeforePause);
await new Promise((resolve) => setTimeout(resolve, 120));
state = store.getState();
assert.equal(state.runState, "paused");
assert.equal(state.activeLine, activeLineBeforePause);
assert.equal(state.taskHalStatus.emcStatus.motion.programLine, motionLineBeforePause);
assert.deepEqual(state.taskHalStatus.ui.axisPose, motionAxisBeforePause);
store.dispatch({ type: "PAUSE_RESUME" });
await waitForTaskHalCommand(store);
await waitForState(store, (nextState) => nextState.machine.motionPaused === false && nextState.runState !== "paused");
state = store.getState();
assert.equal(state.machine.motionPaused, false);
assert.equal(state.taskHalStatus.emcStatus.motion.paused, false);
const cycleAfterRun = state.taskHalStatus.ui.taskCycle;
store.dispatch({ type: "STEP" });
await waitForTaskHalCommand(store);
state = store.getState();
assert.equal(state.programRuntimeFeedback.sourceMode, "linuxcnc-task-motion-hal-wasm");
assert.equal(state.programExecutionSourceMode, "linuxcnc-task-motion-hal-wasm");
store.dispatch({ type: stopAction });
await waitForTaskHalCommand(store);
state = store.getState();
assert.equal(state.taskHalStatusLoop.active, false);
assert.equal(["stopped", "complete", "idle"].includes(state.runState), true);
assert.equal(
stopAction !== "ABORT" || ["aborted", "stopped", "complete", "idle"].includes(state.taskHalStatusLoop.stopReason),
true,
);
assert.equal(state.taskHalStatus.ui.taskCycle >= cycleAfterRun, true);
}
async function verifyRunReadySequence({ profileId, sourceRel }) {
const sdk = await createLinuxCncTaskHalSdk({
wasmBinary: readFileSync(wasmPath),
print() {},
printErr(message) {
console.error(message);
},
});
const profile = getFiveAxisProfile(profileId);
const iniText = readFileSync(resolve(rootDir, "wasm-port/vendor/linuxcnc", profile.iniPath), "utf8");
const iniConfig = parseLinuxCncIni(iniText, {
path: profile.iniPath,
profileId: profile.id,
});
const store = createSimulationStore();
store.dispatch({ type: "ATTACH_INI_CONFIG", profileId: profile.id, iniConfig });
store.dispatch({
type: "ATTACH_KINEMATICS_RUNTIME",
runtime: await createLinuxCncKinematicsRuntime({ moduleId: iniConfig.kinematicsModuleId }),
});
store.dispatch({
type: "ATTACH_INTERPRETER_RUNTIME",
runtime: await createLinuxCncInterpreterRuntime(),
});
store.dispatch({ type: "ATTACH_TASK_HAL_RUNTIME", runtime: wrapTaskHalSdk(sdk) });
await store.stageMachineFiles();
store.dispatch({ type: "LOAD_LINUXCNC_GCODE_SOURCE", sourceRel });
await waitForState(store, (state) => (
state.taskHalSession?.programPath?.endsWith(sourceRel.split("/").at(-1)) &&
state.rtcpFrame?.sourceMode === "source-derived-kinematics-wasm"
));
store.dispatch({ type: "RUN_READY" });
await waitForTaskHalCommand(store);
await waitForState(store, (state) => state.taskHalSession?.programPath?.endsWith(sourceRel.split("/").at(-1)));
let state = store.getState();
assert.equal(state.machine.powerOn, false);
assert.equal(state.machine.taskState, "estop-reset");
assert.equal(state.machine.mode, "manual");
assert.equal(state.machine.allHomed, false);
assert.equal(state.taskHalSession?.programPath?.endsWith(sourceRel.split("/").at(-1)), true);
store.dispatch({ type: "TOGGLE_POWER" });
await waitForTaskHalCommand(store);
store.dispatch({ type: "HOME" });
store.dispatch({ type: "SET_MODE", mode: "auto" });
await waitForTaskHalCommand(store);
state = store.getState();
assert.equal(state.machine.powerOn, true);
assert.equal(state.machine.taskState, "on");
assert.equal(state.machine.mode, "auto");
assert.equal(state.machine.allHomed, true);
store.dispatch({ type: "RUN" });
await waitForTaskHalCommand(store);
await waitForState(store, (nextState) => distinctActiveLines(nextState.programRuntimeFeedbackHistory).length >= 2);
state = store.getState();
assert.equal(state.programExecutionSourceMode, "linuxcnc-task-motion-hal-wasm");
assert.equal(state.programRuntimeFeedback.sourceMode, "linuxcnc-task-motion-hal-wasm");
assert.notEqual(state.operatorMessage, "run blocked: home machine first");
assert.equal(state.machine.allHomed, true);
const activeLineBeforeStop = state.activeLine;
store.dispatch({ type: "STOP" });
await waitForTaskHalCommand(store);
await new Promise((resolve) => setTimeout(resolve, 120));
state = store.getState();
assert.equal(state.taskHalStatusLoop.active, false);
assert.equal(state.runState, "stopped");
assert.equal(state.machine.interpState, "idle");
assert.equal(state.feed.currentVelocity, 0);
assert.equal(state.activeLine, activeLineBeforeStop);
}
async function waitForTaskHalCommand(store) {
for (let attempt = 0; attempt < 80; attempt += 1) {
if (!store.getState().taskHalExecutionPending) {
await new Promise((resolve) => setTimeout(resolve, 0));
if (!store.getState().taskHalExecutionPending) return store.getState();
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
throw new Error("task/HAL command did not settle");
}
async function waitForState(store, predicate) {
for (let attempt = 0; attempt < 160; attempt += 1) {
const state = store.getState();
if (predicate(state)) return state;
await new Promise((resolve) => setTimeout(resolve, 5));
}
throw new Error("store state condition did not settle");
}
function isMonotonic(values) {
for (let index = 1; index < values.length; index += 1) {
if (Number(values[index]) < Number(values[index - 1])) return false;
}
return true;
}
function distinctActiveLines(history = []) {
return [...new Set(history.map((entry) => Number(entry.line)).reverse())]
.filter((line) => Number.isFinite(line));
}