补齐 xyzbc-trt Web 路径证据
This commit is contained in:
@@ -9,8 +9,10 @@ import time
|
||||
|
||||
|
||||
AXES = ["X", "Y", "Z", "A", "B", "C", "U", "V", "W"]
|
||||
DEFAULT_INI = "/home/mes123456/linuxcnc-master/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini"
|
||||
DEFAULT_PROGRAM = "/home/mes123456/linuxcnc-master/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzbc_switchkins.ngc"
|
||||
SAMPLE_PERIOD_MS = 20
|
||||
DEFAULT_SOURCE_ROOT = "/home/mes123456/cnc_wams/linuxcnc"
|
||||
DEFAULT_INI = f"{DEFAULT_SOURCE_ROOT}/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini"
|
||||
DEFAULT_PROGRAM = f"{DEFAULT_SOURCE_ROOT}/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzbc_switchkins.ngc"
|
||||
DEFAULT_OUTPUT = "/home/mes123456/cnc_wams/web-rtcp-5axis-xyzbc-trt-sim-plan/working/evidence/native-xyzbc-trt-evidence.json"
|
||||
|
||||
|
||||
@@ -31,7 +33,11 @@ def main():
|
||||
"status": "blocked",
|
||||
"blocker": "python_linuxcnc_import_failed",
|
||||
"error": f"{type(exc).__name__}: {exc}",
|
||||
"hint": "Run through /home/mes123456/linuxcnc-master/scripts/rip-environment python3",
|
||||
"hint": f"Run through {DEFAULT_SOURCE_ROOT}/scripts/rip-environment python3",
|
||||
"sourceRoot": DEFAULT_SOURCE_ROOT,
|
||||
"pathSampling": create_path_sampling(),
|
||||
"previewPath": empty_path("linuxcnc-preview", "python linuxcnc import failed"),
|
||||
"executionPath": empty_path("linuxcnc-stat", "python linuxcnc import failed"),
|
||||
})
|
||||
return 2
|
||||
|
||||
@@ -53,6 +59,7 @@ def main():
|
||||
"status": "ok",
|
||||
"collectedAt": iso_now(),
|
||||
"executionMode": "auto-run" if args.run else "snapshot-only",
|
||||
"sourceRoot": source_root_for_ini(args.ini),
|
||||
"iniPath": args.ini,
|
||||
"programPath": args.program,
|
||||
"programExists": pathlib.Path(args.program).exists(),
|
||||
@@ -66,6 +73,26 @@ def main():
|
||||
"commandResult": command_result,
|
||||
"hal": hal,
|
||||
"errors": errors,
|
||||
"pathSampling": create_path_sampling(),
|
||||
"previewPath": empty_path("linuxcnc-preview", "native AXIS preview/canon path capture is not implemented in this collector"),
|
||||
"executionPath": execution_path_from_command(command_result),
|
||||
"startupSequence": [
|
||||
".desktop",
|
||||
"rip-environment",
|
||||
"linuxcncsvr",
|
||||
"rtapi_app",
|
||||
"milltask",
|
||||
"halui",
|
||||
"LIB:basic_sim.tcl",
|
||||
"xyzbc-trt-kins",
|
||||
"xyzbc-trt-gui",
|
||||
"axis.py",
|
||||
"xyzbc-trt.xml",
|
||||
"switchkins_postgui.hal",
|
||||
"OPEN_FILE ./demos/xyzbc_switchkins.ngc",
|
||||
],
|
||||
"halNets": native_hal_nets(hal),
|
||||
"kinematicsPins": native_kinematics_pins(hal),
|
||||
"coverage": {
|
||||
"axisProfile": before.get("axisMask") == 55 or after.get("axisMask") == 55,
|
||||
"xyzbcProgramOpen": str(after.get("file") or before.get("file") or "").endswith("xyzbc_switchkins.ngc"),
|
||||
@@ -73,6 +100,8 @@ def main():
|
||||
"jointFeedbackReadable": all(f"joint.{i}.pos-fb" in hal.get("pins", {}) for i in range(5)),
|
||||
"taskStateReadable": after.get("taskState") is not None,
|
||||
"positionReadable": bool(after.get("position")),
|
||||
"previewPathAvailable": False,
|
||||
"executionPathAvailable": command_result is not None and len(command_result.get("events", [])) > 0,
|
||||
},
|
||||
"semanticBoundary": "native_linuxcnc_axis_vismach_xyzbc_trt_runtime",
|
||||
}
|
||||
@@ -109,6 +138,9 @@ def run_program(linuxcnc, stat, command, program_path, timeout):
|
||||
"readLine": snapshot.get("readLine"),
|
||||
"position": snapshot.get("position"),
|
||||
"jointActualPosition": snapshot.get("jointActualPosition"),
|
||||
"velocity": snapshot.get("velocity"),
|
||||
"feedrate": snapshot.get("feedrate"),
|
||||
"spindle": snapshot.get("spindle"),
|
||||
})
|
||||
if snapshot.get("interpState") == linuxcnc.INTERP_IDLE and len(result["events"]) > 2:
|
||||
result["status"] = "completed"
|
||||
@@ -160,6 +192,10 @@ def collect_hal_snapshot():
|
||||
["halcmd", "show", "pin", "xyzbc-trt-kins.tool-offset"],
|
||||
["halcmd", "show", "pin", "xyzbc-trt-kins.x-offset"],
|
||||
["halcmd", "show", "pin", "xyzbc-trt-kins.z-offset"],
|
||||
["halcmd", "show", "pin", "xyzbc-trt-kins.x-rot-point"],
|
||||
["halcmd", "show", "pin", "xyzbc-trt-kins.y-rot-point"],
|
||||
["halcmd", "show", "pin", "xyzbc-trt-kins.z-rot-point"],
|
||||
["halcmd", "show", "pin", "xyzbc-trt-kins.conventional-directions"],
|
||||
]
|
||||
raw = []
|
||||
for command in commands:
|
||||
@@ -225,6 +261,158 @@ def count_program_lines(path):
|
||||
return 0
|
||||
|
||||
|
||||
def create_path_sampling():
|
||||
return {
|
||||
"samplePeriodMs": SAMPLE_PERIOD_MS,
|
||||
"timeBase": "program-relative-ms",
|
||||
"resampling": "linear-position-slerp-or-axis-linear",
|
||||
"coordinateSystem": "machine-xyzbc-and-tcp",
|
||||
}
|
||||
|
||||
|
||||
def empty_path(source, reason):
|
||||
return {
|
||||
"source": source,
|
||||
"samplePeriodMs": SAMPLE_PERIOD_MS,
|
||||
"status": "blocked",
|
||||
"unavailableReason": reason,
|
||||
"sampleCount": 0,
|
||||
"samples": [],
|
||||
}
|
||||
|
||||
|
||||
def execution_path_from_command(command_result):
|
||||
events = (command_result or {}).get("events", [])
|
||||
if not events:
|
||||
return empty_path("linuxcnc-stat", "collector was run without --run or no execution events were captured")
|
||||
samples = []
|
||||
event_index = 0
|
||||
max_ms = int(round(float(events[-1].get("elapsedSeconds") or 0) * 1000))
|
||||
for sample_index, time_ms in enumerate(range(0, max_ms + SAMPLE_PERIOD_MS, SAMPLE_PERIOD_MS)):
|
||||
while (
|
||||
event_index + 1 < len(events)
|
||||
and float(events[event_index + 1].get("elapsedSeconds") or 0) * 1000 <= time_ms
|
||||
):
|
||||
event_index += 1
|
||||
event = events[event_index]
|
||||
samples.append(path_sample_from_event(sample_index, time_ms, event))
|
||||
return {
|
||||
"source": "linuxcnc-stat",
|
||||
"samplePeriodMs": SAMPLE_PERIOD_MS,
|
||||
"status": "ok" if samples else "blocked",
|
||||
"unavailableReason": None if samples else "no execution samples after resampling",
|
||||
"sampleCount": len(samples),
|
||||
"samples": samples,
|
||||
}
|
||||
|
||||
|
||||
def path_sample_from_event(sample_index, time_ms, event):
|
||||
position = event.get("position") or {}
|
||||
joints = event.get("jointActualPosition") or {}
|
||||
b = number_or_zero(joints.get("3", position.get("b")))
|
||||
c = number_or_zero(joints.get("4", position.get("c")))
|
||||
joint = {
|
||||
"x": number_or_zero(joints.get("0", position.get("x"))),
|
||||
"y": number_or_zero(joints.get("1", position.get("y"))),
|
||||
"z": number_or_zero(joints.get("2", position.get("z"))),
|
||||
"b": b,
|
||||
"c": c,
|
||||
}
|
||||
return {
|
||||
"sampleIndex": sample_index,
|
||||
"timeMs": time_ms,
|
||||
"line": int(number_or_zero(event.get("currentLine"))),
|
||||
"motionType": "unknown",
|
||||
"activeKinematics": "unknown",
|
||||
"tool": {
|
||||
"id": 0,
|
||||
"length": 0,
|
||||
"diameter": 0,
|
||||
},
|
||||
"joint": joint,
|
||||
"tcp": {
|
||||
"x": joint["x"],
|
||||
"y": joint["y"],
|
||||
"z": joint["z"],
|
||||
},
|
||||
"toolAxis": tool_axis_from_bc(b, c),
|
||||
"feed": number_or_zero(event.get("feedrate")),
|
||||
"spindle": spindle_speed(event.get("spindle")),
|
||||
}
|
||||
|
||||
|
||||
def tool_axis_from_bc(b_deg, c_deg):
|
||||
b = math.radians(number_or_zero(b_deg))
|
||||
c = math.radians(number_or_zero(c_deg))
|
||||
return {
|
||||
"i": math.sin(b) * math.cos(c),
|
||||
"j": math.sin(b) * math.sin(c),
|
||||
"k": math.cos(b),
|
||||
}
|
||||
|
||||
|
||||
def spindle_speed(spindle):
|
||||
if isinstance(spindle, list) and spindle:
|
||||
first = spindle[0]
|
||||
if isinstance(first, dict):
|
||||
return number_or_zero(first.get("speed"))
|
||||
if isinstance(spindle, dict):
|
||||
return number_or_zero(spindle.get("speed"))
|
||||
return 0
|
||||
|
||||
|
||||
def native_hal_nets(hal):
|
||||
pins = hal.get("pins", {})
|
||||
return [
|
||||
{
|
||||
"signal": "kinstype-select",
|
||||
"source": "motion.analog-out-03",
|
||||
"target": "motion.switchkins-type",
|
||||
"present": "motion.switchkins-type" in pins,
|
||||
},
|
||||
*[
|
||||
{
|
||||
"signal": f"joint-{index}-feedback",
|
||||
"source": f"joint.{index}.pos-fb",
|
||||
"target": ["table-x", "saddle-y", "spindle-z", "tilt-b", "rotate-c"][index],
|
||||
"present": f"joint.{index}.pos-fb" in pins,
|
||||
}
|
||||
for index in range(5)
|
||||
],
|
||||
{
|
||||
"signal": "tool-offset",
|
||||
"source": "motion.tooloffset.z",
|
||||
"target": "xyzbc-trt-kins.tool-offset",
|
||||
"present": "motion.tooloffset.z" in pins or "xyzbc-trt-kins.tool-offset" in pins,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def native_kinematics_pins(hal):
|
||||
pins = hal.get("pins", {})
|
||||
return {
|
||||
"xOffset": hal_pin_value(pins, "xyzbc-trt-kins.x-offset"),
|
||||
"zOffset": hal_pin_value(pins, "xyzbc-trt-kins.z-offset"),
|
||||
"xRotPoint": hal_pin_value(pins, "xyzbc-trt-kins.x-rot-point"),
|
||||
"yRotPoint": hal_pin_value(pins, "xyzbc-trt-kins.y-rot-point"),
|
||||
"zRotPoint": hal_pin_value(pins, "xyzbc-trt-kins.z-rot-point"),
|
||||
"conventionalDirections": hal_pin_value(pins, "xyzbc-trt-kins.conventional-directions"),
|
||||
"toolOffset": hal_pin_value(pins, "xyzbc-trt-kins.tool-offset"),
|
||||
}
|
||||
|
||||
|
||||
def hal_pin_value(pins, name):
|
||||
return (pins.get(name) or {}).get("value")
|
||||
|
||||
|
||||
def source_root_for_ini(ini_path):
|
||||
marker = "/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini"
|
||||
value = str(ini_path)
|
||||
if value.endswith(marker):
|
||||
return value[: -len(marker)]
|
||||
return DEFAULT_SOURCE_ROOT
|
||||
|
||||
|
||||
def value(obj, attr):
|
||||
return normalize_json(getattr(obj, attr, None))
|
||||
|
||||
@@ -268,6 +456,16 @@ def parse_number(value):
|
||||
return value
|
||||
|
||||
|
||||
def number_or_zero(value):
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
if math.isnan(number) or math.isinf(number):
|
||||
return 0
|
||||
return number
|
||||
|
||||
|
||||
def write_json(path, payload):
|
||||
output = pathlib.Path(path)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { createSimulationStore } from "../app/src/state/store.js";
|
||||
import { getFiveAxisProfile } from "../app/src/profiles/index.js";
|
||||
|
||||
const SAMPLE_PERIOD_MS = 20;
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const projectRoot = resolve(repoRoot, "web-rtcp-5axis-xyzbc-trt-sim-plan");
|
||||
const outputPath = process.argv[2]
|
||||
@@ -35,6 +36,13 @@ const store = createSimulationStore();
|
||||
const storeStage = await store.stageMachineFiles({ storage: createMemorySessionStorage() });
|
||||
store.dispatch({ type: "LOAD_LINUXCNC_GCODE_SOURCE", sourceRel });
|
||||
const state = store.getState();
|
||||
const paths = await collectPathEvidence({
|
||||
profile,
|
||||
staged,
|
||||
selectedPlan,
|
||||
wasmArtifacts,
|
||||
});
|
||||
const semanticFields = buildSemanticFields({ profile, ini, staged, state });
|
||||
|
||||
const evidence = {
|
||||
apiName: "xyzbc-trt-web-opfs-wasm-evidence",
|
||||
@@ -50,6 +58,7 @@ const evidence = {
|
||||
kinematics: profile.kinematics,
|
||||
kinematicsModuleId: profile.kinematicsModuleId,
|
||||
defaultProgramFilename: profile.machineFileStaging?.defaultProgramFilename,
|
||||
samplePrograms: profile.samplePrograms,
|
||||
switchkinsTypes: profile.kinematicsParameters?.switchkinsTypes,
|
||||
halPins: profile.halPins,
|
||||
},
|
||||
@@ -64,6 +73,10 @@ const evidence = {
|
||||
toolTable: ini.emcio.toolTable,
|
||||
parameterFile: ini.rs274ngc.parameterFile,
|
||||
jointConfig: ini.jointConfig,
|
||||
display: ini.display,
|
||||
traj: ini.traj,
|
||||
axisLimits: ini.axisLimits,
|
||||
hal: ini.hal,
|
||||
},
|
||||
opfsStaging: {
|
||||
storageMode: staged.save.storageMode,
|
||||
@@ -94,6 +107,10 @@ const evidence = {
|
||||
programLineCount: state.programLines.length,
|
||||
selectedGcodeSourceRel: state.machineFileStaging.selectedGcodeSourceRel,
|
||||
},
|
||||
pathSampling: createPathSampling(),
|
||||
previewPath: paths.previewPath,
|
||||
executionPath: paths.executionPath,
|
||||
...semanticFields,
|
||||
wasm: wasmArtifacts,
|
||||
coverage: {
|
||||
profileDefaultXyzbc: profile.id === "xyzbc-trt",
|
||||
@@ -106,6 +123,20 @@ const evidence = {
|
||||
toolTableStaged: staged.save.files.some((file) => file.sourceRel.endsWith("xyzbc-trt.tbl")),
|
||||
parameterFileStaged: staged.save.files.some((file) => file.sourceRel.endsWith("xyzbc.var")),
|
||||
defaultProgramStaged: staged.save.gcodeSources.some((source) => source.filename === "xyzbc_switchkins.ngc"),
|
||||
boatProgramStaged: staged.save.gcodeSources.some((source) => source.filename === "boat-xyzbc.ngc"),
|
||||
ngcguiSubroutinesStaged: ["xyzbc_switchkins_sub.ngc", "centering.ngc", "helix_bc.ngc"].every((name) => (
|
||||
staged.save.files.some((file) => file.sourceRel.endsWith(`/remap_subs/${name}`))
|
||||
)),
|
||||
postguiHalEquivalent: semanticFields.halNets.some((net) => net.source === "pyvcp.type1-button" && net.target === "halui.mdi-command-01"),
|
||||
kinematicsPinsCovered: semanticFields.kinematicsPins.xOffset === -20
|
||||
&& semanticFields.kinematicsPins.zOffset === -15
|
||||
&& semanticFields.kinematicsPins.conventionalDirections === 0,
|
||||
axisJointLimitsCovered: semanticFields.axisJointLimits.coordinates === "XYZBC"
|
||||
&& semanticFields.axisJointLimits.jointCount === 5
|
||||
&& Boolean(semanticFields.axisJointLimits.axisLimits.B)
|
||||
&& Boolean(semanticFields.axisJointLimits.axisLimits.C),
|
||||
previewPathAvailable: paths.previewPath.sampleCount > 0,
|
||||
executionPathAvailable: paths.executionPath.sampleCount > 0,
|
||||
wasmArtifactsReady: wasmArtifacts.ready,
|
||||
},
|
||||
blockers: [
|
||||
@@ -119,6 +150,14 @@ const evidence = {
|
||||
id: "missing-parameter-file-staging",
|
||||
detail: "xyzbc.var is referenced by INI but absent from wasm-port/vendor manifest in this workspace.",
|
||||
}]),
|
||||
...(paths.previewPath.sampleCount > 0 ? [] : [{
|
||||
id: "web-preview-path-unavailable",
|
||||
detail: paths.previewPath.unavailableReason,
|
||||
}]),
|
||||
...(paths.executionPath.sampleCount > 0 ? [] : [{
|
||||
id: "web-execution-path-unavailable",
|
||||
detail: paths.executionPath.unavailableReason,
|
||||
}]),
|
||||
],
|
||||
semanticBoundary: "web_opfs_wasm_runtime_readiness_for_linuxcnc_xyzbc_trt",
|
||||
};
|
||||
@@ -165,3 +204,491 @@ async function commandExists(command) {
|
||||
child.on("exit", (code) => resolveCommand(code === 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function collectPathEvidence({ profile, staged, selectedPlan, wasmArtifacts }) {
|
||||
if (!wasmArtifacts.files.includes("wasm-port/build/wasm/core/linuxcnc_interp.js")
|
||||
|| !wasmArtifacts.files.includes("wasm-port/build/wasm/core/linuxcnc_interp.wasm")) {
|
||||
return {
|
||||
previewPath: emptyPath("web-preview", "missing linuxcnc_interp WASM artifacts"),
|
||||
executionPath: emptyPath("web-task-hal", "missing task/HAL WASM runtime artifacts"),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const { createLinuxCncInterpreterRuntime } = await import("../app/src/runtime/linuxcnc-interpreter-runtime.js");
|
||||
const runtime = await createLinuxCncInterpreterRuntime();
|
||||
const execution = runtime.runMachineFileProgram({
|
||||
plan: selectedPlan,
|
||||
files: staged.save.files,
|
||||
executionMode: "fiveAxisRemap",
|
||||
});
|
||||
const previewPath = pathFromWebMotion(execution, profile);
|
||||
return {
|
||||
previewPath,
|
||||
executionPath: wasmArtifacts.ready
|
||||
? await pathFromTaskHalExecution({ profile, staged, selectedPlan, execution })
|
||||
: emptyPath("web-task-hal", "missing task/HAL WASM runtime artifacts"),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
previewPath: emptyPath("web-preview", error instanceof Error ? error.message : String(error)),
|
||||
executionPath: emptyPath("web-task-hal", "preview runtime failed before task/HAL execution capture"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function pathFromWebMotion(execution, profile) {
|
||||
const plannerSamples = execution.plannerTiming?.samples || [];
|
||||
const motion = execution.motion || [];
|
||||
const motionByIndex = new Map(motion.map((event, index) => [index, event]));
|
||||
const resampled = resamplePlannerSamples(plannerSamples, SAMPLE_PERIOD_MS);
|
||||
const samples = resampled.map((sample, index) => {
|
||||
const event = motionByIndex.get(sample.motionIndex) || {};
|
||||
const axes = sample.axes || event.axes || {};
|
||||
return normalizePathSample({
|
||||
sampleIndex: index,
|
||||
timeMs: sample.timeMs,
|
||||
line: sample.line ?? event.line ?? 0,
|
||||
motionType: motionTypeFromCanonical(sample.type || event.type),
|
||||
activeKinematics: activeKinematics(event),
|
||||
axes,
|
||||
tool: firstTool(profile),
|
||||
feed: sample.currentVelocityMmPerMin ?? event.feedRate ?? 0,
|
||||
spindle: 0,
|
||||
});
|
||||
});
|
||||
return {
|
||||
source: "web-linuxcnc-interpreter-preview",
|
||||
samplePeriodMs: SAMPLE_PERIOD_MS,
|
||||
status: samples.length > 0 ? "ok" : "blocked",
|
||||
unavailableReason: samples.length > 0 ? null : "interpreter produced no planner samples",
|
||||
sampleCount: samples.length,
|
||||
samples,
|
||||
};
|
||||
}
|
||||
|
||||
function resamplePlannerSamples(plannerSamples = [], samplePeriodMs = SAMPLE_PERIOD_MS) {
|
||||
if (!Array.isArray(plannerSamples) || plannerSamples.length === 0) return [];
|
||||
const normalized = plannerSamples
|
||||
.map((sample) => ({
|
||||
...sample,
|
||||
timeMs: Math.round(Number(sample.timeSeconds || 0) * 1000),
|
||||
}))
|
||||
.filter((sample) => Number.isFinite(sample.timeMs))
|
||||
.sort((left, right) => left.timeMs - right.timeMs);
|
||||
if (normalized.length === 0) return [];
|
||||
const firstMs = 0;
|
||||
const lastMs = normalized.at(-1).timeMs;
|
||||
const output = [];
|
||||
let rightIndex = 0;
|
||||
for (let timeMs = firstMs; timeMs <= lastMs; timeMs += samplePeriodMs) {
|
||||
while (rightIndex < normalized.length - 1 && normalized[rightIndex].timeMs < timeMs) {
|
||||
rightIndex += 1;
|
||||
}
|
||||
const right = normalized[rightIndex];
|
||||
const left = normalized[Math.max(0, rightIndex - 1)] || right;
|
||||
output.push(interpolatePlannerSample(left, right, timeMs));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function interpolatePlannerSample(left, right, timeMs) {
|
||||
if (!left || !right || left.timeMs === right.timeMs) {
|
||||
return { ...(right || left), timeMs };
|
||||
}
|
||||
const ratio = Math.max(0, Math.min(1, (timeMs - left.timeMs) / (right.timeMs - left.timeMs)));
|
||||
const axes = {};
|
||||
for (const axis of ["x", "y", "z", "a", "b", "c", "u", "v", "w"]) {
|
||||
axes[axis] = lerpNumber(left.axes?.[axis], right.axes?.[axis], ratio);
|
||||
}
|
||||
return {
|
||||
...right,
|
||||
timeMs,
|
||||
axes,
|
||||
currentVelocityMmPerMin: lerpNumber(left.currentVelocityMmPerMin, right.currentVelocityMmPerMin, ratio),
|
||||
currentVelocity: lerpNumber(left.currentVelocity, right.currentVelocity, ratio),
|
||||
distanceToGo: lerpNumber(left.distanceToGo, right.distanceToGo, ratio),
|
||||
};
|
||||
}
|
||||
|
||||
async function pathFromTaskHalExecution({ profile, staged, selectedPlan, execution }) {
|
||||
try {
|
||||
const { createLinuxCncTaskHalSdk } = await import("../../wasm-port/runtime/sdk/src/linuxcnc-task-hal.js");
|
||||
const {
|
||||
buildTaskHalProgramMotionPlan,
|
||||
buildTaskHalSessionFromMachineFiles,
|
||||
wrapTaskHalSdk,
|
||||
} = await import("../app/src/runtime/linuxcnc-task-hal-runtime.js");
|
||||
const wasmBinary = await readFile(resolve(repoRoot, "wasm-port/build/wasm/task-hal/linuxcnc_task_hal.wasm"));
|
||||
const taskHal = wrapTaskHalSdk(await createLinuxCncTaskHalSdk({
|
||||
wasmBinary,
|
||||
print() {},
|
||||
printErr() {},
|
||||
}));
|
||||
const session = buildTaskHalSessionFromMachineFiles({
|
||||
profile,
|
||||
plan: selectedPlan,
|
||||
save: staged.save,
|
||||
selectedProgramRel: selectedPlan.selectedProgramSourceRel,
|
||||
});
|
||||
const programFile = staged.save.files.find((file) => (
|
||||
(file.wasmPath || file.path) === session.programPath
|
||||
));
|
||||
const programLines = String(programFile?.text || "").split(/\r?\n/);
|
||||
taskHal.initSession(session);
|
||||
taskHal.stageFiles(session.files);
|
||||
taskHal.openProgram(session.programPath);
|
||||
taskHal.loadProgramMotionPlan(buildTaskHalProgramMotionPlan({
|
||||
programPath: session.programPath,
|
||||
motion: execution.motion,
|
||||
timing: execution.plannerTiming,
|
||||
linearUnits: execution.plannerTiming?.linearUnits || "mm",
|
||||
programLines,
|
||||
}));
|
||||
taskHal.sendCommand({ type: "EMC_TASK_SET_STATE", state: "ON" });
|
||||
taskHal.sendCommand({ type: "EMC_TASK_SET_MODE", mode: "AUTO" });
|
||||
taskHal.sendCommand({ type: "EMC_TASK_PLAN_RUN", line: 0 });
|
||||
|
||||
const totalSeconds = Number(execution.plannerTiming?.totalSeconds || 0);
|
||||
const cycleCount = Math.max(
|
||||
Math.ceil((totalSeconds * 1000) / SAMPLE_PERIOD_MS) + 5,
|
||||
Number(execution.plannerTiming?.samples?.length || 0),
|
||||
1,
|
||||
);
|
||||
const samples = [];
|
||||
let previousKey = null;
|
||||
let completed = false;
|
||||
for (let index = 0; index < cycleCount; index += 1) {
|
||||
taskHal.runCycles({
|
||||
taskPeriodNs: SAMPLE_PERIOD_MS * 1000000,
|
||||
servoPeriodNs: 1000000,
|
||||
taskCycles: 1,
|
||||
});
|
||||
const status = taskHal.readStatus();
|
||||
const motion = status.motionStatus?.motion || {};
|
||||
const task = status.task || {};
|
||||
const activeLine = status.ui?.activeLine ?? motion.programLine ?? 0;
|
||||
const sample = normalizePathSample({
|
||||
sampleIndex: samples.length,
|
||||
timeMs: samples.length * SAMPLE_PERIOD_MS,
|
||||
line: activeLine,
|
||||
motionType: motionTypeFromCanonical(currentMotionTypeForLine(execution.motion, activeLine)),
|
||||
activeKinematics: activeKinematics({
|
||||
switchkinsType: status.ui?.switchkinsType ?? motion.switchkinsType,
|
||||
}),
|
||||
axes: xyzbcAxesFromTaskHalStatus(status),
|
||||
tool: firstTool(profile),
|
||||
feed: status.ui?.currentVelocity ?? Number(motion.currentVel || motion.currentVelocity || 0) * 60,
|
||||
spindle: spindleFromTaskHalStatus(status),
|
||||
});
|
||||
const sampleKey = JSON.stringify({ line: sample.line, joint: sample.joint, feed: sample.feed });
|
||||
samples.push(sample);
|
||||
completed = String(task.execState || "").toUpperCase() === "DONE"
|
||||
&& String(task.interpState || "").toUpperCase() === "IDLE"
|
||||
&& samples.length > 2
|
||||
&& sampleKey === previousKey;
|
||||
previousKey = sampleKey;
|
||||
if (completed) break;
|
||||
}
|
||||
return {
|
||||
source: "web-linuxcnc-task-hal-execution",
|
||||
samplePeriodMs: SAMPLE_PERIOD_MS,
|
||||
status: samples.length > 0 ? "ok" : "blocked",
|
||||
unavailableReason: samples.length > 0 ? null : "task/HAL execution produced no status samples",
|
||||
sampleCount: samples.length,
|
||||
samples,
|
||||
taskHal: {
|
||||
semanticBoundary: "linuxcnc_task_motion_hal_wasm_simulation_runtime",
|
||||
sessionProgramPath: session.programPath,
|
||||
completed,
|
||||
eventCount: taskHal.readEvents()?.events?.length || 0,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return emptyPath("web-task-hal", error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function buildSemanticFields({ profile, ini, staged, state }) {
|
||||
const postguiNets = [
|
||||
{ signal: "kinstype.is-0", source: "kinstype.is-0", target: "pyvcp.multilabel.0.legend0", boundary: "postgui-hal" },
|
||||
{ signal: "kinstype.is-1", source: "kinstype.is-1", target: "pyvcp.multilabel.0.legend1", boundary: "postgui-hal" },
|
||||
{ signal: "kinstype.is-2", source: "kinstype.is-2", target: "pyvcp.multilabel.0.legend2", boundary: "postgui-hal" },
|
||||
{ signal: "vismach-clear", source: "pyvcp.vismach-clear", target: "vismach.plotclear", boundary: "postgui-hal" },
|
||||
{ signal: "type0-button", source: "pyvcp.type0-button", target: "halui.mdi-command-00", command: "M429", boundary: "postgui-hal" },
|
||||
{ signal: "type1-button", source: "pyvcp.type1-button", target: "halui.mdi-command-01", command: "M428", boundary: "postgui-hal" },
|
||||
{ signal: "type2-button", source: "pyvcp.type2-button", target: "halui.mdi-command-02", command: "M430", boundary: "postgui-hal" },
|
||||
];
|
||||
const profileNets = [
|
||||
profile.hal?.halcmd?.switchkinsSelectNet,
|
||||
...(profile.hal?.halcmd?.feedbackNets || []),
|
||||
...(profile.hal?.halcmd?.offsetNets || []),
|
||||
].filter(Boolean).map((net) => ({ ...net, boundary: "ini-halcmd" }));
|
||||
const gcodeFiles = staged.save.gcodeFiles || [];
|
||||
|
||||
return {
|
||||
startupSequence: [
|
||||
".desktop",
|
||||
"rip-environment",
|
||||
"linuxcncsvr",
|
||||
"rtapi_app",
|
||||
"milltask",
|
||||
"halui",
|
||||
"LIB:basic_sim.tcl",
|
||||
"xyzbc-trt-kins",
|
||||
"xyzbc-trt-gui",
|
||||
"axis.py",
|
||||
"xyzbc-trt.xml",
|
||||
"switchkins_postgui.hal",
|
||||
"OPEN_FILE ./demos/xyzbc_switchkins.ngc",
|
||||
],
|
||||
iniDisplay: {
|
||||
...ini.display,
|
||||
coordinates: ini.traj.coordinates,
|
||||
positionFeedback: ini.display.positionFeedback,
|
||||
positionOffset: ini.display.positionOffset,
|
||||
},
|
||||
halNets: [
|
||||
...profileNets,
|
||||
...postguiNets,
|
||||
],
|
||||
kinematicsPins: {
|
||||
xOffset: profile.offsets?.x,
|
||||
zOffset: profile.offsets?.z,
|
||||
xRotPoint: profile.offsets?.xRotPoint,
|
||||
yRotPoint: profile.offsets?.yRotPoint,
|
||||
zRotPoint: profile.offsets?.zRotPoint,
|
||||
conventionalDirections: profile.offsets?.conventionalDirections,
|
||||
toolOffsetSource: "motion.tooloffset.z",
|
||||
pins: profile.halPins,
|
||||
},
|
||||
axisJointLimits: {
|
||||
coordinates: ini.traj.coordinates,
|
||||
linearUnits: ini.traj.linearUnits,
|
||||
angularUnits: ini.traj.angularUnits,
|
||||
jogAxes: ini.display.jogAxes,
|
||||
geometry: ini.display.geometry,
|
||||
traj: ini.traj,
|
||||
axisLimits: ini.axisLimits,
|
||||
jointCount: ini.jointConfig.length,
|
||||
jointConfig: ini.jointConfig,
|
||||
},
|
||||
switchkinsTransitions: (profile.kinematicsParameters?.switchkinsTypes || []).map((type) => ({
|
||||
...type,
|
||||
remap: profile.remaps?.find((remap) => remap.code === type.mdiCommand) || null,
|
||||
haluiCommand: type.mdiCommand,
|
||||
halPin: "motion.switchkins-type",
|
||||
})),
|
||||
uiEquivalence: {
|
||||
firstViewport: "axis-equivalent-cnc-console",
|
||||
regions: [
|
||||
"program",
|
||||
"dro",
|
||||
"status",
|
||||
"mdi-switchkins",
|
||||
"override",
|
||||
"tool",
|
||||
"preview-execution",
|
||||
],
|
||||
activeProgram: state.activeProgram,
|
||||
selectedGcodeSourceRel: state.machineFileStaging.selectedGcodeSourceRel,
|
||||
pyvcpPanelSchema: profile.panelSchema?.id,
|
||||
switchkinsButtons: ["IDENTITY", "TCP:XYZBC", "USERK"],
|
||||
},
|
||||
vismachEquivalent: {
|
||||
sourceGui: "src/hal/user_comps/vismach/xyzbc-trt-gui.py",
|
||||
webModel: "app/src/visualization/five-axis-scene.js",
|
||||
pins: [
|
||||
"table-x",
|
||||
"saddle-y",
|
||||
"spindle-z",
|
||||
"tilt-b",
|
||||
"rotate-c",
|
||||
"tool-offset",
|
||||
"x-offset",
|
||||
"z-offset",
|
||||
],
|
||||
clearTraceSignal: "pyvcp.vismach-clear => vismach.plotclear",
|
||||
},
|
||||
ngcguiSubroutines: ["xyzbc_switchkins_sub.ngc", "centering.ngc", "helix_bc.ngc"].map((filename) => ({
|
||||
filename,
|
||||
staged: gcodeFiles.some((file) => file.filename === filename),
|
||||
sourceRel: gcodeFiles.find((file) => file.filename === filename)?.sourceRel || null,
|
||||
})),
|
||||
demoPrograms: ["xyzbc_switchkins.ngc", "boat-xyzbc.ngc"].map((filename) => ({
|
||||
filename,
|
||||
default: filename === profile.machineFileStaging?.defaultProgramFilename,
|
||||
staged: staged.save.gcodeSources.some((source) => source.filename === filename),
|
||||
sourceRel: staged.save.gcodeSources.find((source) => source.filename === filename)?.sourceRel || null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function createPathSampling() {
|
||||
return {
|
||||
samplePeriodMs: SAMPLE_PERIOD_MS,
|
||||
timeBase: "program-relative-ms",
|
||||
resampling: "linear-position-slerp-or-axis-linear",
|
||||
coordinateSystem: "machine-xyzbc-and-tcp",
|
||||
};
|
||||
}
|
||||
|
||||
function emptyPath(source, reason) {
|
||||
return {
|
||||
source,
|
||||
samplePeriodMs: SAMPLE_PERIOD_MS,
|
||||
status: "blocked",
|
||||
unavailableReason: reason,
|
||||
sampleCount: 0,
|
||||
samples: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePathSample({
|
||||
sampleIndex,
|
||||
timeMs,
|
||||
line,
|
||||
motionType,
|
||||
activeKinematics,
|
||||
axes,
|
||||
tool,
|
||||
feed,
|
||||
spindle,
|
||||
}) {
|
||||
const joint = {
|
||||
x: numberOrZero(axes.x),
|
||||
y: numberOrZero(axes.y),
|
||||
z: numberOrZero(axes.z),
|
||||
b: numberOrZero(axes.b),
|
||||
c: numberOrZero(axes.c),
|
||||
};
|
||||
return {
|
||||
sampleIndex,
|
||||
timeMs,
|
||||
line: Number(line) || 0,
|
||||
motionType,
|
||||
activeKinematics,
|
||||
tool,
|
||||
joint,
|
||||
tcp: {
|
||||
x: joint.x,
|
||||
y: joint.y,
|
||||
z: joint.z,
|
||||
},
|
||||
toolAxis: toolAxisFromBc(joint.b, joint.c),
|
||||
feed: numberOrZero(feed),
|
||||
spindle: numberOrZero(spindle),
|
||||
};
|
||||
}
|
||||
|
||||
function firstTool(profile) {
|
||||
const tool = profile.toolTable?.tools?.[0] || {};
|
||||
return {
|
||||
id: Number(tool.tool) || 0,
|
||||
length: Number(tool.zOffset) || 0,
|
||||
diameter: Number(tool.diameter) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function xyzbcAxesFromTaskHalStatus(status = {}) {
|
||||
const axis = status.motionStatus?.axis || {};
|
||||
const pins = status.halSnapshot?.pins || {};
|
||||
return {
|
||||
x: firstFiniteNumber(
|
||||
axis.x,
|
||||
pins["joint.0.motor-pos-fb"]?.value,
|
||||
pins["joint.0.pos-fb"]?.value,
|
||||
pins["axis.0.pos-fb"]?.value,
|
||||
pins["joint.0.motor-pos-cmd"]?.value,
|
||||
),
|
||||
y: firstFiniteNumber(
|
||||
axis.y,
|
||||
pins["joint.1.motor-pos-fb"]?.value,
|
||||
pins["joint.1.pos-fb"]?.value,
|
||||
pins["axis.1.pos-fb"]?.value,
|
||||
pins["joint.1.motor-pos-cmd"]?.value,
|
||||
),
|
||||
z: firstFiniteNumber(
|
||||
axis.z,
|
||||
pins["joint.2.motor-pos-fb"]?.value,
|
||||
pins["joint.2.pos-fb"]?.value,
|
||||
pins["axis.2.pos-fb"]?.value,
|
||||
pins["joint.2.motor-pos-cmd"]?.value,
|
||||
),
|
||||
b: firstFiniteNumber(
|
||||
axis.b,
|
||||
pins["joint.3.motor-pos-fb"]?.value,
|
||||
pins["joint.3.pos-fb"]?.value,
|
||||
pins["axis.3.pos-fb"]?.value,
|
||||
pins["joint.3.motor-pos-cmd"]?.value,
|
||||
),
|
||||
c: firstFiniteNumber(
|
||||
axis.c,
|
||||
pins["joint.4.motor-pos-fb"]?.value,
|
||||
pins["joint.4.pos-fb"]?.value,
|
||||
pins["axis.4.pos-fb"]?.value,
|
||||
pins["joint.4.motor-pos-cmd"]?.value,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function spindleFromTaskHalStatus(status = {}) {
|
||||
const pins = status.halSnapshot?.pins || {};
|
||||
return firstFiniteNumber(
|
||||
status.motionStatus?.motion?.spindleSpeed,
|
||||
pins["spindle.0.speed-out"]?.value,
|
||||
pins["motion.spindle-speed-out"]?.value,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function currentMotionTypeForLine(motion = [], line = 0) {
|
||||
const number = Number(line);
|
||||
if (!Number.isFinite(number) || number <= 0) return null;
|
||||
return motion.find((event) => Number(event.line) === number)?.type || null;
|
||||
}
|
||||
|
||||
function activeKinematics(event) {
|
||||
if (event.kinsType === "identity" || event.switchkinsType === 0) return "identity";
|
||||
if (event.kinsType === "tcp" || event.switchkinsType === 1) return "xyzbc-tcp";
|
||||
if (event.kinsType === "userk" || event.switchkinsType === 2) return "userk";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function motionTypeFromCanonical(type) {
|
||||
if (type === "STRAIGHT_TRAVERSE") return "G0";
|
||||
if (type === "STRAIGHT_FEED") return "G1";
|
||||
if (type === "ARC_FEED") return "G2/G3";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function toolAxisFromBc(bDeg, cDeg) {
|
||||
const b = bDeg * Math.PI / 180;
|
||||
const c = cDeg * Math.PI / 180;
|
||||
return {
|
||||
i: Math.sin(b) * Math.cos(c),
|
||||
j: Math.sin(b) * Math.sin(c),
|
||||
k: Math.cos(b),
|
||||
};
|
||||
}
|
||||
|
||||
function numberOrZero(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
}
|
||||
|
||||
function lerpNumber(left, right, ratio) {
|
||||
const leftNumber = Number(left);
|
||||
const rightNumber = Number(right);
|
||||
if (!Number.isFinite(leftNumber)) return Number.isFinite(rightNumber) ? rightNumber : 0;
|
||||
if (!Number.isFinite(rightNumber)) return leftNumber;
|
||||
return leftNumber + (rightNumber - leftNumber) * ratio;
|
||||
}
|
||||
|
||||
function firstFiniteNumber(...values) {
|
||||
for (const value of values) {
|
||||
const number = Number(value);
|
||||
if (Number.isFinite(number)) return number;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const outputPath = process.argv[4] || resolve(projectRoot, "working/evidence/com
|
||||
|
||||
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, {
|
||||
@@ -48,6 +49,49 @@ const checks = [
|
||||
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");
|
||||
@@ -71,6 +115,7 @@ const report = {
|
||||
webStatus: webEvidence.status,
|
||||
},
|
||||
checks,
|
||||
pathComparison,
|
||||
requiredImprovements: failed.map((item) => ({
|
||||
category: item.category,
|
||||
requirement: item.requirement,
|
||||
@@ -96,3 +141,137 @@ function check(category, requirement, passed, evidence = {}) {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user