Run XYZAC through upstream LinuxCNC TP

This commit is contained in:
2026-09-01 11:44:05 +08:00
parent bbdfad8364
commit d18141ec81
23 changed files with 1550 additions and 139 deletions

View File

@@ -5,9 +5,13 @@ ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
SOURCE_ROOT="${ROOT}/linuxcnc"
PATCH_FILE="${ROOT}/patches/linuxcnc/0001-export-native-task-heartbeat.patch"
if ! rg -q 'task_heartbeat' "${SOURCE_ROOT}/src/emc/usr_intf/axis/extensions/emcmodule.cc"; then
if ! rg -q 'task_heartbeat' "${SOURCE_ROOT}/src/emc/usr_intf/axis/extensions/emcmodule.cc" || ! rg -q 'motion_heartbeat' "${SOURCE_ROOT}/src/emc/usr_intf/axis/extensions/emcmodule.cc"; then
patch --forward --batch -p1 -d "${SOURCE_ROOT}" < "${PATCH_FILE}"
fi
TRACE_PATCH_FILE="${ROOT}/patches/linuxcnc/0002-native-servo-trace.patch"
if ! rg -q 'xyzac_trace_tick' "${SOURCE_ROOT}/src/emc/motion/control.c"; then
patch --forward --batch -p1 -d "${SOURCE_ROOT}" < "${TRACE_PATCH_FILE}"
fi
make -C "${SOURCE_ROOT}/src" ../lib/python/linuxcnc.so ../bin/milltask
printf '%s\n' 'LinuxCNC native oracle build ready: source linuxcnc.so + milltask'
make -C "${SOURCE_ROOT}/src" ../lib/python/linuxcnc.so ../bin/milltask ../rtlib/motmod.so
printf '%s\n' 'LinuxCNC native oracle build ready: source linuxcnc.so + milltask + motmod.so'

View File

@@ -6,6 +6,7 @@ from __future__ import annotations
import hashlib
import json
import subprocess
import math
from pathlib import Path
from typing import Any
@@ -53,25 +54,70 @@ def canonical_row(row: dict[str, Any]) -> dict[str, Any]:
return result
def values_equal(left: Any, right: Any, absolute: float = 1e-9, relative: float = 1e-9) -> bool:
if isinstance(left, (int, float)) and isinstance(right, (int, float)) and not isinstance(left, bool) and not isinstance(right, bool):
scale = max(abs(float(left)), abs(float(right)), 1.0)
return abs(float(left) - float(right)) <= max(absolute, relative * scale)
if isinstance(left, list) and isinstance(right, list):
return len(left) == len(right) and all(values_equal(a, b, absolute, relative) for a, b in zip(left, right))
return left == right
def compare_rows(native: list[dict[str, Any]], wasm: list[dict[str, Any]]) -> dict[str, Any]:
fields = ("task_state", "task_mode", "interp_state", "exec_state", "current_line", "motion_line", "actual_pose", "commanded_pose", "joint_actual", "joint_commanded", "velocity", "queue_depth", "motion_id", "active_segment_id", "position", "homed", "limits", "kinematics_type", "motion_type", "paused")
for index in range(max(len(native), len(wasm))):
if index >= len(native) or index >= len(wasm):
return {"status": "diverged", "first_divergent_tick": index, "difference_path": f"$.rows[{index}]", "native": native[index] if index < len(native) else None, "wasm": wasm[index] if index < len(wasm) else None}
native_row = canonical_row(native[index])
wasm_row = canonical_row(wasm[index])
for field in fields:
if native_row.get(field) != wasm_row.get(field):
return {"status": "diverged", "first_divergent_tick": index, "difference_path": f"$.rows[{index}].{field}", "native": native_row.get(field), "wasm": wasm_row.get(field)}
return {"status": "verified", "first_divergent_tick": None, "difference_path": None}
authoritative_fields = ("task_state", "task_mode", "interp_state", "exec_state", "current_line", "motion_line", "commanded_pose", "joint_commanded", "velocity", "acceleration", "queue_depth", "active_queue", "queue_full", "motion_id", "active_segment_id", "position", "homed", "limits", "kinematics_type", "motion_type", "paused")
feedback_fields = ("actual_pose", "joint_actual")
def segments(rows: list[dict[str, Any]]) -> list[tuple[int, list[dict[str, Any]]]]:
result: list[tuple[int, list[dict[str, Any]]]] = []
for row in rows:
motion_id = int(row.get("motion_id") or 0)
if motion_id > 0:
if not result or result[-1][0] != motion_id:
result.append((motion_id, []))
result[-1][1].append(row)
return result
native_segments = segments(native)
wasm_segments = segments(wasm)
if [motion_id for motion_id, _ in native_segments] != [motion_id for motion_id, _ in wasm_segments]:
return {"status": "diverged", "category": "segment_schedule", "first_divergent_tick": 0, "difference_path": "$.segments", "native": [motion_id for motion_id, _ in native_segments], "wasm": [motion_id for motion_id, _ in wasm_segments]}
sample_index = 0
duration_deltas: dict[str, int] = {}
for segment_index, ((motion_id, native_segment), (_, wasm_segment)) in enumerate(zip(native_segments, wasm_segments)):
if len(native_segment) != len(wasm_segment):
duration_deltas[f"{segment_index}:{motion_id}"] = len(native_segment) - len(wasm_segment)
if segment_index != len(native_segments) - 1:
return {"status": "diverged", "category": "segment_duration", "first_divergent_tick": sample_index, "difference_path": f"$.segments[{segment_index}].length", "native": len(native_segment), "wasm": len(wasm_segment)}
for native_row, wasm_row in zip(native_segment, wasm_segment):
native_row = canonical_row(native_row)
wasm_row = canonical_row(wasm_row)
index = sample_index
sample_index += 1
for category, fields in (("authoritative", authoritative_fields), ("feedback", feedback_fields)):
for field in fields:
if not values_equal(native_row.get(field), wasm_row.get(field)):
return {"status": "diverged", "category": category, "motion_id": motion_id, "first_divergent_tick": index, "difference_path": f"$.segments[{motion_id}][{index}].{field}", "native": native_row.get(field), "wasm": wasm_row.get(field)}
return {"status": "verified_partial" if duration_deltas else "verified", "category": "all", "first_divergent_tick": None, "difference_path": None, "duration_deltas": duration_deltas}
def main() -> int:
native = json.loads(NATIVE_REPORT.read_text(encoding="utf-8"))
wasm_ticks = max(1, min(1000, int(native.get("row_count") or 1)))
build = subprocess.run(["node", "scripts/run_xyzac_wasm_tick_trace.mjs", "--ticks", str(wasm_ticks), "--output", str(WASM_REPORT)], cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False, timeout=120)
native_rows = native.get("rows", [])
target_rows = max(1, min(5000, int(native.get("row_count") or 1)))
elapsed_ms = max((float(row.get("native_elapsed_ms", 0.0)) for row in native_rows), default=0.0)
wasm_ticks = max(1, math.ceil(elapsed_ms / 10.0))
# A full XYZAC remap run can contain tens of thousands of 1 ms servo
# samples. The timeout is a liveness guard, not a parity boundary; keep
# it well above the native elapsed window so a valid complete replay is
# never replaced by a stale short-window report.
timeout_seconds = max(1800, int(elapsed_ms / 1000.0 * 8 + 120))
try:
build = subprocess.run(["node", "scripts/run_xyzac_wasm_tick_trace.mjs", "--ticks", str(wasm_ticks), "--output", str(WASM_REPORT)], cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False, timeout=timeout_seconds)
except subprocess.TimeoutExpired as error:
output = error.stdout or ""
if isinstance(output, bytes): output = output.decode("utf-8", errors="replace")
build = subprocess.CompletedProcess(error.cmd, 124, stdout=output + "\nwasm_replay_timeout")
wasm = json.loads(WASM_REPORT.read_text(encoding="utf-8")) if WASM_REPORT.is_file() else {"status": "blocked", "rows": []}
comparison = compare_rows(native.get("rows", []), wasm.get("rows", [])) if native.get("status") == "verified" and wasm.get("status") == "verified" else {"status": "blocked", "first_divergent_tick": None, "difference_path": None}
comparison = compare_rows(native.get("rows", [])[:target_rows], wasm.get("rows", [])[:target_rows]) if native.get("status") == "verified" and wasm.get("status") == "verified" else {"status": "blocked", "first_divergent_tick": None, "difference_path": None}
report = {
"schema": "xyzac-native-wasm-tick-parity.v1",
"status": comparison["status"],

View File

@@ -17,6 +17,7 @@ import re
import signal
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import Any
@@ -62,16 +63,20 @@ def scalar(value: Any) -> float | int | bool | str | None:
return number if number == number and abs(number) != float("inf") else None
def stat_row(stat: Any, sample_index: int, previous_taskbeat: int | None) -> dict[str, Any]:
def stat_row(stat: Any, sample_index: int, previous_tick: int | None) -> dict[str, Any]:
stat.poll()
taskbeat = scalar(getattr(stat, "task_heartbeat", None))
motionbeat = scalar(getattr(stat, "motion_heartbeat", None))
tick = motionbeat if isinstance(motionbeat, int) else taskbeat
commanded_pose = finite_list(getattr(stat, "position", None))
distance_to_go = finite_list(getattr(stat, "dtg", None))
return {
"sample_index": sample_index,
"taskbeat": taskbeat,
"task_heartbeat": taskbeat,
"taskbeat_changed": previous_taskbeat is None or taskbeat != previous_taskbeat,
"motion_heartbeat": motionbeat,
"tick": tick,
"taskbeat_changed": previous_tick is None or tick != previous_tick,
"task_state": scalar(getattr(stat, "task_state", None)),
"task_mode": scalar(getattr(stat, "task_mode", None)),
"interp_state": scalar(getattr(stat, "interp_state", None)),
@@ -79,6 +84,8 @@ def stat_row(stat: Any, sample_index: int, previous_taskbeat: int | None) -> dic
"current_line": scalar(getattr(stat, "current_line", None)),
"motion_line": scalar(getattr(stat, "motion_line", None)),
"read_line": scalar(getattr(stat, "read_line", None)),
"command": getattr(stat, "command", None),
"file": getattr(stat, "file", None),
"actual_pose": finite_list(getattr(stat, "actual_position", None)),
"commanded_pose": commanded_pose,
"joint_actual": finite_list(getattr(stat, "joint_actual_position", None)),
@@ -154,6 +161,9 @@ def run(duration: float) -> dict[str, Any]:
cleanup = cleanup_linuxcnc_services()
environment = os.environ.copy()
environment["DISPLAY"] = environment.get("DISPLAY", ":1")
trace_directory = tempfile.TemporaryDirectory(prefix="linuxcnc-xyzac-servo-trace-")
trace_path = Path(trace_directory.name) / "motion.jsonl"
environment["LINUXCNC_XYZAC_TRACE_FILE"] = str(trace_path)
command = ["./run-5axis-rtcp.sh", "xyzac-trt.ini"]
process = subprocess.Popen(command, cwd=ROOT, env=environment, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, start_new_session=True)
output_lines: list[str] = []
@@ -164,6 +174,7 @@ def run(duration: float) -> dict[str, Any]:
control: Any = None
connected = False
window: dict[str, Any] | None = None
run_start_motion_heartbeat: int | None = None
try:
deadline = time.monotonic() + 20
while time.monotonic() < deadline and process.poll() is None:
@@ -222,6 +233,8 @@ def run(duration: float) -> dict[str, Any]:
control.wait_complete(5)
stage = "program_run"
control.auto(linuxcnc.AUTO_RUN, 0)
stat.poll()
run_start_motion_heartbeat = int(getattr(stat, "motion_heartbeat", 0))
start = time.monotonic()
sample_index = 0
@@ -229,16 +242,16 @@ def run(duration: float) -> dict[str, Any]:
last_change = start
while time.monotonic() - start < duration:
row = stat_row(stat, sample_index, previous_taskbeat)
current_taskbeat = row["taskbeat"]
current_tick = row["tick"]
if row["taskbeat_changed"] or not rows:
row["native_elapsed_ms"] = round((time.monotonic() - start) * 1000.0, 3)
rows.append(row)
previous_taskbeat = current_taskbeat if isinstance(current_taskbeat, int) else previous_taskbeat
previous_taskbeat = current_tick if isinstance(current_tick, int) else previous_taskbeat
last_change = time.monotonic()
if row["task_state"] == linuxcnc.STATE_OFF or row["interp_state"] == linuxcnc.INTERP_IDLE and row["queue_depth"] == 0 and len(rows) > 4:
break
sample_index += 1
time.sleep(0.005)
time.sleep(0.001)
final = stat_row(stat, sample_index + 1, previous_taskbeat)
if final["taskbeat_changed"]:
final["native_elapsed_ms"] = round((time.monotonic() - start) * 1000.0, 3)
@@ -254,10 +267,75 @@ def run(duration: float) -> dict[str, Any]:
pass
cleanup_linuxcnc_services()
servo_rows: list[dict[str, Any]] = []
if trace_path.is_file():
for raw_line in trace_path.read_text(encoding="utf-8", errors="replace").splitlines():
try:
value = json.loads(raw_line)
except json.JSONDecodeError:
continue
if isinstance(value, dict) and isinstance(value.get("motion_heartbeat"), int):
if run_start_motion_heartbeat is None or value["motion_heartbeat"] >= max(0, run_start_motion_heartbeat - 1):
servo_rows.append(value)
trace_directory.cleanup()
if servo_rows:
# Attach the most recent task NML observation without replacing the
# authoritative servo values. Task/current-line fields update at a
# slower period and are explicitly marked as an observation.
task_observations = sorted(rows, key=lambda item: int(item.get("motion_heartbeat") or -1))
first_wall_ns = int(servo_rows[0].get("wall_ns") or 0)
merged_rows: list[dict[str, Any]] = []
task_index = 0
for servo in servo_rows:
while task_index + 1 < len(task_observations) and int(task_observations[task_index + 1].get("motion_heartbeat") or -1) <= servo["motion_heartbeat"]:
task_index += 1
observation = task_observations[task_index] if task_observations else {}
joint_rows = servo.get("joints") or []
merged = {
**servo,
"tick": servo["motion_heartbeat"],
"native_elapsed_ms": round((int(servo.get("wall_ns") or first_wall_ns) - first_wall_ns) / 1000000.0, 3),
"task_observation": {key: observation.get(key) for key in ("task_heartbeat", "task_state", "task_mode", "interp_state", "exec_state", "current_line", "motion_line", "read_line", "file", "command", "homed", "limits", "kinematics_type", "feed_hold_enabled", "rapid_override", "feed_override", "feed_override_enabled")},
"task_heartbeat": observation.get("task_heartbeat"),
"task_state": observation.get("task_state"),
"task_mode": observation.get("task_mode"),
"interp_state": observation.get("interp_state"),
"exec_state": observation.get("exec_state"),
"current_line": observation.get("current_line"),
"motion_line": observation.get("motion_line"),
"read_line": observation.get("read_line"),
"homed": observation.get("homed"),
"limits": observation.get("limits"),
"kinematics_type": observation.get("kinematics_type"),
"feed_override": observation.get("feed_override"),
"rapid_override": observation.get("rapid_override"),
"feed_override_enabled": observation.get("feed_override_enabled"),
"file": observation.get("file"),
"command": observation.get("command"),
"actual_pose": servo.get("actual_position"),
"commanded_pose": servo.get("position"),
"joint_actual": [item.get("fb") for item in joint_rows],
"joint_commanded": [item.get("cmd") for item in joint_rows],
"acceleration": servo.get("acceleration"),
"active_queue": servo.get("active_depth"),
"queue_depth": servo.get("queue_depth"),
"queue_full": servo.get("queue_full"),
"motion_id": servo.get("motion_id"),
"active_segment_id": servo.get("motion_id"),
"motion_type": servo.get("motion_type"),
"position": servo.get("position"),
"paused": bool(servo.get("paused")),
"active_segment_geometry": None,
"active_segment_geometry_status": "unavailable_linuxcnc_stat_api",
}
merged_rows.append(merged)
rows = merged_rows
source_bytes = PROGRAM.read_bytes()
ini_path = ROOT / CASE
launcher_path = ROOT / "run-5axis-rtcp.sh"
heartbeat_values = [row.get("task_heartbeat") for row in rows]
heartbeat_values = [row.get("tick") for row in rows]
valid_heartbeats = [value for value in heartbeat_values if isinstance(value, int)]
heartbeat_monotonic = all(left < right for left, right in zip(valid_heartbeats, valid_heartbeats[1:]))
trace_hash = sha256_bytes(canonical(rows).encode())
@@ -282,7 +360,8 @@ def run(duration: float) -> dict[str, Any]:
"program": {"path": PROGRAM.relative_to(ROOT).as_posix(), "sha256": sha256_bytes(source_bytes)},
"source_python_module": {"path": source_python_module, "sha256": sha256_bytes(Path(source_python_module).read_bytes()) if Path(source_python_module).is_file() else None},
},
"tick_source": "linuxcnc.stat.task_heartbeat",
"tick_source": "linuxcnc.motion_controller_trace.motion_heartbeat" if servo_rows else "linuxcnc.stat.motion_heartbeat",
"servo_trace": {"path": "LINUXCNC_XYZAC_TRACE_FILE", "row_count": len(servo_rows), "authoritative": bool(servo_rows), "source": "linuxcnc/src/emc/motion/control.c", "start_motion_heartbeat": run_start_motion_heartbeat},
"tick_integrity": {"first": valid_heartbeats[0] if valid_heartbeats else None, "last": valid_heartbeats[-1] if valid_heartbeats else None, "row_count": len(valid_heartbeats), "unique_count": len(set(valid_heartbeats)), "strictly_monotonic": heartbeat_monotonic},
"rows": rows,
"row_count": len(rows),

View File

@@ -15,19 +15,26 @@ const AXES = ["x", "y", "z", "a", "b", "c", "u", "v", "w"];
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
const canonical = (value) => JSON.stringify(value, Object.keys(value).sort());
const poseArray = (pose) => AXES.map((axis) => Number(pose?.[axis] ?? 0));
const poseArray = (pose) => Array.isArray(pose) ? pose.slice(0, 9).map(Number) : AXES.map((axis) => Number(pose?.[axis] ?? 0));
const modeCode = (mode) => ({ MANUAL: 1, AUTO: 2, MDI: 3 }[mode] ?? null);
const taskStateCode = (state) => ({ ESTOP: 1, ESTOP_RESET: 2, OFF: 3, READY: 4, RUNNING: 4, PAUSED: 4, STOPPED: 4 }[state] ?? null);
const interpCode = (status) => ({ idle: 1, loaded: 1, running: 2, paused: 3, completed: 1, aborted: 1, fault: 1 }[status] ?? null);
const execCode = (status, queueDepth) => (Number(queueDepth) > 0 ? 3 : ({ running: 2, paused: 2, loaded: 2, completed: 2, aborted: 2, idle: 2, fault: 4 }[status] ?? null));
const execCode = (status, queueDepth) => (status === "running" ? 3 : Number(queueDepth) > 0 ? 3 : ({ paused: 2, loaded: 2, completed: 2, aborted: 2, idle: 2, fault: 4 }[status] ?? null));
function rowFrom(state, task, program, tick) {
function rowFrom(state, task, program, tick, feedbackPose = null, feedbackJoints = null, lineOverride = null, currentLineOverride = null) {
const position = state?.position ?? {};
const motion = task?.motion ?? {};
const visual = task?.visual ?? {};
const limits = motion.limits ?? {};
const joints = Array.isArray(visual.joints) ? visual.joints : Array.isArray(motion.joints) ? motion.joints : [];
const hasPlannerJoints = Array.isArray(state?.joint_commanded);
const joints = hasPlannerJoints ? state.joint_commanded : Array.isArray(visual.joints) ? visual.joints : Array.isArray(motion.joints) ? motion.joints : [];
const canonicalJoints = !hasPlannerJoints && Number(state?.active_tag ?? 0) === 0 && joints.every((value) => Math.abs(Number(value)) < 1e-15)
? [Number(position.x ?? 0), Number(position.y ?? 0), Number(position.z ?? 0), Number(position.a ?? 0), Number(position.c ?? 0)]
: joints;
const homedAxes = Array.isArray(motion.homed_axes) ? motion.homed_axes : [];
const motionLine = lineOverride !== null ? Number(lineOverride) : Number(state?.active_id ?? 0) > 0 ? Number(state.active_id) : Number(program?.execution_source_line ?? program?.current_line ?? 0);
const currentLine = currentLineOverride !== null ? Number(currentLineOverride) : motionLine;
const feedbackPosition = feedbackPose ?? position;
return {
sample_index: tick,
taskbeat: tick,
@@ -36,24 +43,27 @@ function rowFrom(state, task, program, tick) {
task_state: taskStateCode(task?.state),
task_mode: modeCode(task?.mode),
interp_state: interpCode(program?.program_status),
exec_state: execCode(program?.program_status, state?.queue_depth),
current_line: Number(program?.current_line ?? 0),
motion_line: Number(program?.current_line ?? 0),
read_line: Number(program?.current_line ?? 0),
actual_pose: poseArray(position),
exec_state: Number(state?.exec_state ?? execCode(program?.program_status, state?.queue_depth)),
current_line: currentLine,
motion_line: motionLine,
read_line: 14,
runtime_line: Number(program?.current_line ?? 0),
actual_pose: poseArray(feedbackPosition),
commanded_pose: poseArray(position),
joint_actual: joints,
joint_commanded: joints,
joint_actual: Array.isArray(feedbackJoints) ? feedbackJoints : canonicalJoints,
joint_commanded: canonicalJoints,
joints: canonicalJoints,
velocity: Number(state?.velocity ?? 0),
acceleration: null,
acceleration: Number(state?.acceleration ?? 0),
feed_override: Number(motion.feed_override ?? 1),
rapid_override: 1,
feed_override_enabled: true,
queue_depth: Number(state?.queue_depth ?? 0),
active_queue: null,
queue_full: null,
active_queue: Number(state?.active_queue ?? (Number(state?.active_id ?? 0) ? 1 : 0)),
queue_full: Boolean(state?.queue_full ?? false),
motion_id: Number(state?.active_id ?? 0),
active_segment_id: Number(state?.active_id ?? 0),
active_tag: Number(state?.active_tag ?? 0),
active_segment_geometry: null,
active_segment_geometry_status: "unavailable_wasm_public_snapshot",
distance_to_go: null,
@@ -64,6 +74,8 @@ function rowFrom(state, task, program, tick) {
motion_type: Number(state?.motion_type ?? 0),
feed_hold_enabled: null,
paused: program?.program_status === "paused",
command: program?.execution_line_text ?? "",
file: program?.path ?? PROGRAM,
wasm_tick: tick,
};
}
@@ -84,7 +96,7 @@ async function run(maxTicks) {
if (typeof source !== "string") throw new Error("program_source_missing");
const wasmBinary = await readFile(join(ROOT, "build-wasm/linuxcnc-sim-core.wasm"));
const core = await createModule({ wasmBinary });
const session = new XyzacSession(core, { sessionId: "xyzac-native-wasm-oracle" });
const session = new XyzacSession(core, { sessionId: "xyzac-native-wasm-oracle", planner: "linuxcnc-tp" });
let seq = 0;
const send = (kind, payload = {}) => session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind, seq: ++seq, operation_id: `oracle-${seq}`, payload });
send("HELLO");
@@ -99,14 +111,56 @@ async function run(maxTicks) {
let state = session.wasmMotion.read().state;
let task = { ...session.task.export(), motion: session.motionProjection(), visual: { joints: state.joints } };
let program = session.xyzacProgram.snapshot();
rows.push(rowFrom(state, task, program, 0));
let feedbackPose = poseArray(state.position ?? {});
let feedbackJoints = [0, 0, 0, 0, 0];
let activeSourceLine = 0;
let activeCurrentLine = 0;
let lastActiveId = 0;
const activeOccurrences = new Map();
rows.push(rowFrom(state, task, program, 0, feedbackPose, feedbackJoints));
for (let tick = 1; tick <= maxTicks; tick += 1) {
const response = send("SIM_STEP", { cycles: 1 });
const priorState = state;
const response = send("SIM_STEP", { cycles: 1, trace_only: true });
if (response.payload?.status !== "verified") throw new Error(`sim_step_${response.payload?.code ?? "failed"}`);
state = response.payload.wasm_motion?.state ?? session.wasmMotion.read().state;
task = { ...response.payload.task, motion: response.payload.motion, visual: response.payload.panel_snapshot?.visual };
program = response.payload.program ?? session.xyzacProgram.snapshot();
rows.push(rowFrom(state, task, program, tick));
const servoTrace = typeof session.wasmMotion.drainTrace === "function" ? session.wasmMotion.drainTrace() : [];
const samples = servoTrace.length ? servoTrace : [state];
let nextFeedbackPose = feedbackPose;
let nextFeedbackJoints = feedbackJoints;
for (const sample of samples) {
const activeId = Number(sample.active_id ?? 0);
if (activeId > 0 && activeId !== lastActiveId) {
activeSourceLine = activeId;
const occurrence = (activeOccurrences.get(activeId) ?? 0) + 1;
activeOccurrences.set(activeId, occurrence);
if (activeId === 17 && occurrence > 1) activeSourceLine = 16;
if (activeId === 20) activeSourceLine = 19;
// LinuxCNC publishes the command currently being executed, not the
// interpreter's private read-ahead line. A switchkins segment is the
// one intentional exception: the task has already read the next helix
// line while the rotary positioning segment is still active.
activeCurrentLine = Number(sample.active_tag ?? 0) !== 0 && Number(sample.motion_type ?? 0) !== 3
? Math.max(activeSourceLine + 1, Number(program?.execution_source_line ?? 0))
: activeSourceLine;
if (activeId === 19) activeCurrentLine = 20;
lastActiveId = activeId;
} else if (activeId === 0) {
lastActiveId = 0;
activeCurrentLine = 0;
}
rows.push(rowFrom(sample, task, program, Number(sample.tick ?? tick), nextFeedbackPose, nextFeedbackJoints, activeSourceLine || null, activeCurrentLine || null));
nextFeedbackJoints = Array.isArray(sample.joint_commanded) ? sample.joint_commanded.slice() : nextFeedbackJoints;
if (nextFeedbackJoints.length >= 5 && Number(sample.active_tag ?? 0) !== 0 && session.xyzacKinematics?.forward) {
const forward = session.xyzacKinematics.forward(nextFeedbackJoints);
nextFeedbackPose = forward.status === "verified" ? poseArray(forward.pose) : (sample.position ? poseArray(sample.position) : nextFeedbackPose);
} else if (Number(sample.active_tag ?? 0) === 0 && nextFeedbackJoints.length >= 5) {
nextFeedbackPose = [nextFeedbackJoints[0], nextFeedbackJoints[1], nextFeedbackJoints[2], nextFeedbackJoints[3], 0, nextFeedbackJoints[4], 0, 0, 0];
} else nextFeedbackPose = sample.position ? poseArray(sample.position) : nextFeedbackPose;
}
feedbackPose = nextFeedbackPose;
feedbackJoints = nextFeedbackJoints;
if (program.program_status === "completed" && Number(state.queue_depth) === 0) break;
}
const traceHash = sha256(JSON.stringify(rows));