From b564792452402e9fffd37dc91172742e2187f3a0 Mon Sep 17 00:00:00 2001 From: wangdequan Date: Mon, 31 Aug 2026 09:30:27 +0800 Subject: [PATCH] Plan full LinuxCNC TP numerical parity --- .../task-packets/TP-PARITY-00.handoff.json | 29 ++ coverage/task-packets/TP-PARITY-00.json | 59 ++++ coverage/xyzac-native-tick-trace.report.json | 83 ++++++ doc-rtcp/xyzac-trt-full-tp-parity-plan.md | 178 ++++++++++++ scripts/run_xyzac_native_tick_trace.py | 269 ++++++++++++++++++ 5 files changed, 618 insertions(+) create mode 100644 coverage/task-packets/TP-PARITY-00.handoff.json create mode 100644 coverage/task-packets/TP-PARITY-00.json create mode 100644 coverage/xyzac-native-tick-trace.report.json create mode 100644 doc-rtcp/xyzac-trt-full-tp-parity-plan.md create mode 100644 scripts/run_xyzac_native_tick_trace.py diff --git a/coverage/task-packets/TP-PARITY-00.handoff.json b/coverage/task-packets/TP-PARITY-00.handoff.json new file mode 100644 index 0000000..c8ee3da --- /dev/null +++ b/coverage/task-packets/TP-PARITY-00.handoff.json @@ -0,0 +1,29 @@ +{ + "schema": "task-handoff.v1", + "task_id": "TP-PARITY-00", + "context_id": "linuxcnc-2.10.0-pre0-wasm-m1-py-c1", + "status": "blocked", + "scope": "xyzac_only", + "result": "run-5axis-rtcp.sh launches native LinuxCNC services and AXIS, but linuxcnc.stat cannot attach to the launcher status buffer on this host; no native per-tick oracle was generated", + "report": {"path": "coverage/xyzac-native-tick-trace.report.json", "status": "blocked"}, + "outputs": [ + {"path": "scripts/run_xyzac_native_tick_trace.py", "status": "created"}, + {"path": "doc-rtcp/xyzac-trt-full-tp-parity-plan.md", "status": "created"}, + {"path": "coverage/task-packets/TP-PARITY-00.json", "status": "created"} + ], + "checks": { + "launcher": "run-5axis-rtcp.sh started native linuxcncsvr/io/halui/milltask/axis", + "axis_window": "visible during manual probe", + "stat_attach": "blocked: emcStatusBuffer invalid err=3 / taskbeat invalid", + "native_trace": "blocked", + "source_hashes": "recorded by collector", + "full_parity_claim": "forbidden" + }, + "failure_samples": [ + {"code": "NATIVE_STAT_BUFFER_INVALID", "detail": "linuxcnc.stat().poll() returned emcStatusBuffer invalid err=3 or SystemError without exception while run-5axis-rtcp.sh services were alive"}, + {"code": "NATIVE_TASKBEAT_INVALID", "detail": "taskbeat remained 4294967296 and no valid native tick could be selected"} + ], + "next_task": "TP-PARITY-01", + "next_task_status": "blocked_by_TP-PARITY-00", + "release_status": "STOP_RELEASE" +} diff --git a/coverage/task-packets/TP-PARITY-00.json b/coverage/task-packets/TP-PARITY-00.json new file mode 100644 index 0000000..e0ef44a --- /dev/null +++ b/coverage/task-packets/TP-PARITY-00.json @@ -0,0 +1,59 @@ +{ + "schema": "task-packet.v1", + "task_id": "TP-PARITY-00", + "status": "blocked", + "title": "Native LinuxCNC XYZAC per-tick oracle", + "milestone": "M7-TP-NUMERICAL-PARITY", + "context_id": "linuxcnc-2.10.0-pre0-wasm-m1-py-c1", + "case": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini", + "scope": "xyzac_only", + "depends_on": ["RELEASE-00"], + "direct_inputs": [ + "run-5axis-rtcp.sh", + "scripts/run_axis3_tutorial_native_sequence.py", + "linuxcnc/src/emc/usr_intf/axis/extensions/emcmodule.cc", + "linuxcnc/src/emc/task/taskintf.cc", + "linuxcnc/src/emc/motion/motion.h", + "linuxcnc/src/emc/tp/tp_debug.h", + "linuxcnc/src/emc/tp/tp.c", + "linuxcnc/src/emc/tp/tcq.c", + "linuxcnc/src/emc/tp/tc.c", + "doc-rtcp/xyzac-trt-full-tp-parity-plan.md" + ], + "required_outputs": [ + "scripts/run_xyzac_native_tick_trace.py", + "coverage/xyzac-native-tick-trace.report.json", + "coverage/task-packets/TP-PARITY-00.handoff.json" + ], + "observed": { + "report": "coverage/xyzac-native-tick-trace.report.json", + "report_sha256": "812a0bb8de8438aa18bc16789c40a33bcaadfaf3fcdc704ecf109c4b77bfdbfd", + "row_count": 0, + "first_blocker": "NATIVE_STAT_BUFFER_INVALID" + }, + "acceptance": [ + "run-5axis-rtcp.sh launches native LinuxCNC", + "linuxcnc.stat captures task/interpreter/motion/queue/pose/joint/current-line fields", + "tick source is native taskbeat or explicitly blocked with evidence", + "empty queue and queue drain ticks are retained", + "source/config/program hashes are recorded", + "native trace report is self-hashed", + "no claim of full parity before TP-PARITY-06 passes" + ], + "next_tasks": [ + "TP-PARITY-01", + "TP-PARITY-02", + "TP-PARITY-03", + "TP-PARITY-04", + "TP-PARITY-05", + "TP-PARITY-06", + "TP-PARITY-07", + "TP-PARITY-08" + ], + "known_blockers": [ + "native stat API may not expose TP segment geometry; map motion_id and retain explicit unavailable field", + "native GUI process requires DISPLAY and cleanup", + "full LinuxCNC TP source integration may require ABI-compatible C port rather than simplified queue extension" + ], + "release_status": "STOP_RELEASE" +} diff --git a/coverage/xyzac-native-tick-trace.report.json b/coverage/xyzac-native-tick-trace.report.json new file mode 100644 index 0000000..2f54893 --- /dev/null +++ b/coverage/xyzac-native-tick-trace.report.json @@ -0,0 +1,83 @@ +{ + "case": { + "path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini", + "scope": "xyzac_only" + }, + "cleanup": { + "commands": [ + { + "command": [ + "axis-remote", + "--quit" + ], + "returncode": 1, + "stdout_sha256": "e887c3a096d768c45173b5087b76b19f871abedface8d6a05f107a2dbb93b3f9" + }, + { + "command": [ + "halcmd", + "stop" + ], + "returncode": 0, + "stdout_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "command": [ + "halcmd", + "unload", + "all" + ], + "returncode": 0, + "stdout_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "command": [ + "/usr/lib/linuxcnc/realtime", + "stop" + ], + "returncode": 0, + "stdout_sha256": "91338655380567a02b2f78410c6af8bb22361915c68b22572d9a09389d9599ab" + } + ], + "killed_pids": [], + "lock_path": "/tmp/linuxcnc.lock", + "lock_removed": false, + "remaining_services": [] + }, + "errors": [ + "RuntimeError:native_stat_transition_timeout" + ], + "known_boundaries": [ + "native stat exposes motion_id but not full TP segment geometry; active_segment_geometry is explicit null", + "taskbeat is the public native task-cycle counter; wall-clock is recorded only as diagnostic elapsed_ms", + "this report is an oracle input and does not claim native/WASM parity by itself" + ], + "launcher": [ + "./run-5axis-rtcp.sh", + "xyzac-trt.ini" + ], + "launcher_output_sha256": "8e18cd62ead0174b8c9c7c70b4fa62716ee066bb0984de99e288322ae6aecaa2", + "launcher_output_tail": "Starting 5-axis RTCP case: /home/mes006/linuxcncWebWASM/linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini\nDemo program: /home/mes006/linuxcncWebWASM/linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc\nRTCP subroutine: /home/mes006/linuxcncWebWASM/linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/helix_ac.ngc\nMachine configuration file is 'xyzac-trt.ini'\ncheck_config: Unchecked: [KINS]KINEMATICS={xyzac-trt-kins sparm=identityfirst}\nStarting LinuxCNC...\nlinuxcnc TPMOD=tpmod HOMEMOD=homemod EMCMOT=motmod\nNote: Using POSIX non-realtime\nFound file(LIB): /home/mes006/linuxcncWebWASM/linuxcnc/lib/hallib/basic_sim.tcl\nsetup_kins: cmd=loadrt xyzac-trt-kins sparm=identityfirst\nnote: MAXV max: 1000.000 units/sec 60000.000 units/min\nnote: LJOG max: 1000.000 units/sec 60000.000 units/min\nnote: LJOG default: 20.000 units/sec 1200.000 units/min\nnote: AJOG max: 360.000 units/sec 21600.000 units/min\nnote: AJOG default: 360.000 units/sec 21600.000 units/min\nnote: jog_order='XYZC'\nnote: jog_invert=set()\ntask: 0 cycles, min=0.000004, max=0.062398, avg=-1.000000, 0 latency excursions (> 10x expected cycle time of 0.010000s)\nTraceback (most recent call last):\n File \"/home/mes006/linuxcncWebWASM/linuxcnc/bin/axis\", line 4249, in \n o.mainloop()\n File \"/usr/lib/python3.12/tkinter/__init__.py\", line 1504, in mainloop\n self.tk.mainloop(n)\n File \"/usr/lib/python3.12/tkinter/__init__.py\", line 1962, in __call__\n def __call__(self, *args):\n\nKeyboardInterrupt\nShutting down and cleaning up LinuxCNC...\n\n!!! switchkins-type 0 is IDENTITY\n\nidentityKinematicsSetup: coordinates:xyzac\n Joint 0 ==> Axis X\n Joint 1 ==> Axis Y\n Joint 2 ==> Axis Z\n Joint 3 ==> Axis A\n Joint 4 ==> Axis C\n\nxyzac-trt-kins coordinates=xyzac assigns:\n Joint 0 ==> Axis X\n Joint 1 ==> Axis Y\n Joint 2 ==> Axis Z\n Joint 3 ==> Axis A\n Joint 4 ==> Axis C\n\nuserkKinematicsSetup:\n emc/kinematics/userkfuncs.c max_joints=16 allow_duplicates=1\n\nNote: Using POSIX non-realtime", + "native_axis_window": { + "height": 1377, + "raw": "0x4c0001f \"AXIS 2.10.0~pre0 on sim-xyzac-trt-kins (switchkins) (no file)\": (\"axis\" \"Axis\") 1552x1377+28+98 +280+286", + "title": "AXIS 2.10.0~pre0 on sim-xyzac-trt-kins (switchkins) (no file)", + "width": 1552 + }, + "native_linuxcnc": { + "display": ":1", + "python_module": "/usr/lib/python3/dist-packages/linuxcnc.cpython-312-x86_64-linux-gnu.so" + }, + "program": { + "path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc", + "sha256": "5afc03d5c1b692edd4154a6d37e5f94d74936a7bd5e82dc9c022f7bc21c7dc17" + }, + "release_status": "STOP_RELEASE", + "report_sha256": "812a0bb8de8438aa18bc16789c40a33bcaadfaf3fcdc704ecf109c4b77bfdbfd", + "row_count": 0, + "rows": [], + "schema": "xyzac-native-linuxcnc-tick-trace.v1", + "status": "blocked", + "tick_source": "linuxcnc.stat.taskbeat", + "trace_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945" +} diff --git a/doc-rtcp/xyzac-trt-full-tp-parity-plan.md b/doc-rtcp/xyzac-trt-full-tp-parity-plan.md new file mode 100644 index 0000000..acf3d77 --- /dev/null +++ b/doc-rtcp/xyzac-trt-full-tp-parity-plan.md @@ -0,0 +1,178 @@ +# XYZAC TRT 完整 TP/前瞻数值对标任务轨道 + +状态:`TP-PARITY-00` 已建立,完整 LinuxCNC 数值对标尚未完成。 + +目标不是证明页面能绘制一条曲线,而是对同一 `xyzac-trt.ini`、同一 G-code、同一初始状态,比较 native LinuxCNC 与 WASM Worker 的每个运动 tick: + +```text +source G-code + -> LinuxCNC RS-274 canonical output + -> LinuxCNC TP/lookahead/blend + -> motion/kinematics + -> pose/joints/velocity/active motion/queue/current line +``` + +Web 端只有在该链路和 native trace 逐字段一致后,才能声明 `PARITY_VERIFIED`。Three.js、React 和浏览器时间不能作为运动数据来源。 + +## 任务分解 + +### TP-PARITY-00:Native LinuxCNC 逐 tick oracle + +来源: + +- `run-5axis-rtcp.sh` +- `linuxcnc/src/emc/usr_intf/axis/extensions/emcmodule.cc` +- `linuxcnc/src/emc/task/taskintf.cc` +- `linuxcnc/src/emc/motion/motion.h` +- `linuxcnc/src/emc/tp/tp_debug.h` +- `linuxcnc/src/emc/tp/tp.c` +- `linuxcnc/src/emc/tp/tcq.c` +- `linuxcnc/src/emc/tp/tc.c` + +交付: + +- `scripts/run_xyzac_native_tick_trace.py` +- `coverage/xyzac-native-tick-trace.report.json` +- native trace JSONL,包含 `tick/taskbeat`、task/interpreter/exec state、current/motion/read line、actual/commanded pose、joint actual pose、velocity、acceleration、queue、active queue、queue full、motion id、feed/rapid override 和 G-code source hash。 + +验收: + +- 必须由 `./run-5axis-rtcp.sh` 启动 native LinuxCNC。 +- 使用 `linuxcnc.stat()` 读取状态,不能从 GUI 截图推断运动值。 +- trace 必须保留重复 tick、空 queue tick 和程序结束排空阶段。 +- `motion_id` 映射为 native active motion identifier;若 native API 不提供 segment geometry,必须显式记录 `active_segment_geometry: unavailable`,不能伪造。 + +### TP-PARITY-01:锁定 LinuxCNC TP 数据模型和 C ABI + +来源:`linuxcnc/src/emc/tp/{tp.c,tp.h,tp_types.h,tc.c,tcq.c,tc_types.h,blendmath.c,blendmath.h,spherical_arc.c}`。 + +工作: + +- 逐项登记 `TP_STRUCT`、`TC_STRUCT`、termination condition、queue depth、active depth、current velocity、acceleration、jerk、tolerance 和 motion id。 +- 决定采用源码等价移植还是直接编译可移植 LinuxCNC TP 源码到 WASM;优先保留原始 C 算法和数据布局,避免再写第二套近似 planner。 +- 为每个 ABI 增加 native/WASM size、endianness、state hash 和 checkpoint restore 测试。 + +退出条件:native 和 WASM 对同一静态 TC 队列的 enqueue、activate、step、pop 逐字段一致。 + +### TP-PARITY-02:G64/G61/G61.1 完整路径控制 + +来源: + +- `linuxcnc/src/emc/rs274ngc/interp_convert.cc` 的 G64/G61 处理 +- `linuxcnc/src/emc/rs274ngc/interp_internal.hh` 的 `tolerance`/`naivecam_tolerance` +- `linuxcnc/src/emc/tp/tp.c` 的 `tpSetTermCond` 和 termination condition + +工作: + +- `G61`:exact path;`G61.1`:exact stop;`G64`:blend;`G64 P... Q...`:路径和 naive-cam tolerance。 +- 保存 modal 状态、每个 canonical motion 的 termination condition 和 tolerance。 +- 增加直线-直线、直线-圆弧、圆弧-直线、圆弧-圆弧、急转角、短段、零容差和非法 P/Q 样本。 + +退出条件:native/WASM 每条 TC 的 termination mode、tolerance、blend point、速度和完成 tick 一致。 + +### TP-PARITY-03:Lookahead、corner blending、acceleration/jerk + +来源: + +- `linuxcnc/src/emc/tp/tp.c`:target/final velocity、activate、queue starvation、periodic update +- `linuxcnc/src/emc/tp/blendmath.c`:cornering speed、blend geometry、acceleration/jerk bound +- `linuxcnc/src/emc/tp/tc.c`:segment acceleration and interpolation +- `linuxcnc/src/emc/motion/control.c`:trajectory period and current velocity + +工作: + +- 移除当前 `lookahead=1` 的最小近似,复用或等价移植 LinuxCNC 的完整 active-depth/lookahead。 +- 按 LinuxCNC 的 blend geometry 计算 corner speed 和 transition segment。 +- 保持 queue starvation、pause、abort、feed hold、queue drain 的真实语义。 +- acceleration、jerk、cycle time、velocity limit 从 INI/TRAJ 配置进入同一 planner。 + +退出条件:至少覆盖 2、4、8、32 段 lookahead,连续/精确路径,短段和速度突变;逐 tick pose/velocity/acceleration/queue 一致。 + +### TP-PARITY-04:XYZAC rotary velocity semantics 和 RTCP + +来源: + +- `linuxcnc/src/emc/task/emccanon.cc` +- `linuxcnc/src/emc/kinematics/{xyzac-trt-kins.c,trtfuncs.c,switchkins.c,kins_util.c}` +- `linuxcnc/src/emc/motion/control.c` + +工作: + +- 统一线性轴 mm、旋转轴 deg 的速度单位和合成路径长度。 +- 验证 A/C 单独运动、XYZ+AC 同时运动、G93 inverse time、G94 units/min、RTCP pivot/tool offset。 +- native trace 同时导出 Cartesian actual/commanded pose 和五 joint actual/commanded pose。 +- 将 M428/M429/M430 的切换点纳入 planner queue 和 trace,而不是只比较最终姿态。 + +退出条件:同一切换点前后 pose 连续、joint 速度/路径长度/active motion id 与 native 一致。 + +### TP-PARITY-05:WASM Worker 真实 planner 接入 + +工作: + +- `WasmProgramRuntime` 输出 LinuxCNC canonical motion/termination 条件。 +- Worker 只调用 WASM TP/kinematics/motion ABI;删除 selected-case 中的简化 `sim_motion_queue` 速度决策。 +- React 继续只消费 snapshot/delta,不参与 tick、前瞻、姿态补偿或路径重建。 +- Feed/rapid override、pause/resume、abort、restart、checkpoint 都必须落到同一 WASM planner state。 + +退出条件:Worker trace 与 WASM C trace byte-identical,且不会回退到 `AuthoritativeRuntime` 或 `ProgramRunner` 的 authority path。 + +### TP-PARITY-06:Native/WASM 逐 tick comparator + +交付: + +- `scripts/compare_xyzac_native_wasm_tick_trace.py` +- `coverage/xyzac-native-wasm-tick-parity.report.json` + +比较字段: + +```text +tick +task_state/mode/interp_state/exec_state +current_line/motion_line/read_line +actual_pose/commanded_pose +joint_actual/joint_commanded +velocity/acceleration +queue/active_queue/queue_full +motion_id/active_segment +feed_override/rapid_override +kinematics_mode/RTCP offsets +state_hash +``` + +比较规则: + +- 浮点使用固定绝对/相对容差,并记录原始值和差异值。 +- 列表长度、字段缺失、状态顺序不允许静默忽略。 +- 保存 `first_divergent_tick`、`difference_path`、native/WASM 两侧样本。 +- 最终坐标相同但中间 tick 不同必须判定为 `diverged`。 + +### TP-PARITY-07:预览和实际执行一致性 + +工作: + +- 预览使用同一 canonical/TP 输入生成 planned path,不使用独立的几何近似。 +- 实时路径使用同一 planner 的 actual motion trace。 +- 对比 planned TC、executed TC、active segment 和完成后的 endpoint;明确 rapid、blend、exact-stop 的颜色/线型只属于显示层。 + +退出条件:预览不改变 planner state;执行 trace 的 segment/source line 可回指预览 TC;同一程序重复运行 preview hash 稳定。 + +### TP-PARITY-08:最终门禁 + +必须通过: + +- native `run-5axis-rtcp.sh` trace +- native CTest 和 WASM CTest +- Worker trace +- native/WASM 逐 tick comparator +- XYZAC M428/M429/M430、G64/G61/G61.1、G93/G94、短段、拐角、queue full、pause/abort/restart/recovery +- Chromium 浏览器只读显示验证 + +只有 `first_divergent_tick: null`、所有字段状态 hash 通过、所有输入和报告自哈希一致,才允许将状态写为 `PARITY_VERIFIED`。否则保持 `TP-PARITY-*` 对应任务的 `blocked` 或 `diverged`。 + +## 当前明确边界 + +- 当前 `coverage/xyzac-trt-trace-parity.report.json` 证明的是项目内 native C/WASM C/Worker 实现之间的 selected-case 一致,不是 native LinuxCNC TP oracle 对标。 +- 当前 `wasm-program-runtime.mjs` 的 preview 是 WASM interpreter canonical segments;完整 LinuxCNC TP 前瞻和 G64 corner blend 尚未接入。 +- 当前 `sim_motion_queue.c` 的 lookahead/blend 是简化实现,不能作为真实 LinuxCNC TP 数值等价证明。 +- `run-5axis-rtcp.sh` 当前只启动 native AXIS;TP-PARITY-00 必须补齐 `linuxcnc.stat()` trace 导出后,才有资格进行逐 tick comparator。 +- Safari、物理断电和真实硬件仍按项目范围排除,不影响本仿真任务的 native/WASM 数值闭环。 diff --git a/scripts/run_xyzac_native_tick_trace.py b/scripts/run_xyzac_native_tick_trace.py new file mode 100644 index 0000000..125d9ec --- /dev/null +++ b/scripts/run_xyzac_native_tick_trace.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +"""Run the repository's native XYZAC RTCP case and record LinuxCNC stat ticks. + +This is an oracle collector, not a simulator. It intentionally starts the +same run-5axis-rtcp.sh launcher used by the native case and reads values only +from linuxcnc.stat(). Fields unavailable through the public stat API remain +explicitly null. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import signal +import subprocess +import time +from pathlib import Path +from typing import Any + +import linuxcnc + +from run_axis3_tutorial_native import cleanup_linuxcnc_services + + +ROOT = Path(__file__).resolve().parents[1] +CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini" +PROGRAM = ROOT / "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc" +SCHEMA = "xyzac-native-linuxcnc-tick-trace.v1" + + +def canonical(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def finite_list(value: Any) -> list[float] | None: + try: + return [float(item) for item in value] + except (TypeError, ValueError): + return None + + +def scalar(value: Any) -> float | int | bool | str | None: + if value is None or isinstance(value, (bool, int, str)): + return value + try: + number = float(value) + except (TypeError, ValueError): + return 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]: + stat.poll() + taskbeat = scalar(getattr(stat, "taskbeat", None)) + return { + "sample_index": sample_index, + "taskbeat": taskbeat, + "taskbeat_changed": previous_taskbeat is None or taskbeat != previous_taskbeat, + "task_state": scalar(getattr(stat, "task_state", None)), + "task_mode": scalar(getattr(stat, "task_mode", None)), + "interp_state": scalar(getattr(stat, "interp_state", None)), + "exec_state": scalar(getattr(stat, "exec_state", None)), + "current_line": scalar(getattr(stat, "current_line", None)), + "motion_line": scalar(getattr(stat, "motion_line", None)), + "read_line": scalar(getattr(stat, "read_line", None)), + "actual_pose": finite_list(getattr(stat, "actual_position", None)), + "commanded_pose": finite_list(getattr(stat, "command_position", None)), + "joint_actual": finite_list(getattr(stat, "joint_actual_position", None)), + "joint_commanded": finite_list(getattr(stat, "joint_position", None)), + "velocity": scalar(getattr(stat, "current_vel", None)), + "acceleration": scalar(getattr(stat, "acceleration", None)), + "feed_override": scalar(getattr(stat, "feedrate", None)), + "rapid_override": scalar(getattr(stat, "rapidrate", None)), + "feed_override_enabled": scalar(getattr(stat, "feed_override_enabled", None)), + "queue_depth": scalar(getattr(stat, "queue", None)), + "active_queue": scalar(getattr(stat, "active_queue", None)), + "queue_full": scalar(getattr(stat, "queue_full", None)), + "motion_id": scalar(getattr(stat, "motion_id", None)), + "active_segment_id": scalar(getattr(stat, "motion_id", None)), + "active_segment_geometry": None, + "active_segment_geometry_status": "unavailable_linuxcnc_stat_api", + "distance_to_go": finite_list(getattr(stat, "distance_to_go", None)), + "position": finite_list(getattr(stat, "position", None)), + "homed": list(getattr(stat, "homed", [])) if getattr(stat, "homed", None) is not None else None, + "limits": list(getattr(stat, "limit", [])) if getattr(stat, "limit", None) is not None else None, + "kinematics_type": scalar(getattr(stat, "kinematics_type", None)), + "motion_type": scalar(getattr(stat, "motion_type", None)), + "feed_hold_enabled": scalar(getattr(stat, "feed_hold_enabled", None)), + "paused": scalar(getattr(stat, "paused", None)), + } + + +def wait_stat(stat: Any, predicate: Any, timeout: float) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + stat.poll() + if predicate(stat): + return + time.sleep(0.02) + raise RuntimeError("native_stat_transition_timeout") + + +def terminate_process(process: subprocess.Popen[str]) -> None: + if process.poll() is not None: + return + try: + os.killpg(process.pid, signal.SIGTERM) + process.wait(timeout=5) + except (ProcessLookupError, subprocess.TimeoutExpired): + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + pass + + +def native_axis_window(environment: dict[str, str]) -> dict[str, Any] | None: + result = subprocess.run(["xwininfo", "-root", "-tree"], env=environment, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, check=False) + for line in result.stdout.splitlines(): + if '("axis" "Axis")' not in line or "xyzac-trt" not in line.lower(): + continue + marker = line.strip().split('"', 2) + if len(marker) < 2: + continue + size = re.search(r"\)\s+(\d+)x(\d+)\+", line) + if not size or int(size.group(1)) < 600 or int(size.group(2)) < 400: + continue + return {"title": marker[1], "width": int(size.group(1)), "height": int(size.group(2)), "raw": line.strip()} + return None + + +def run(duration: float) -> dict[str, Any]: + if not PROGRAM.is_file(): + return {"schema": SCHEMA, "status": "blocked", "code": "native_program_missing", "program": PROGRAM.as_posix()} + cleanup = cleanup_linuxcnc_services() + environment = os.environ.copy() + environment["DISPLAY"] = environment.get("DISPLAY", ":1") + 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] = [] + rows: list[dict[str, Any]] = [] + errors: list[str] = [] + stat: Any = None + control: Any = None + connected = False + window: dict[str, Any] | None = None + try: + deadline = time.monotonic() + 20 + while time.monotonic() < deadline and process.poll() is None: + window = native_axis_window(environment) + if not window: + time.sleep(0.1) + continue + try: + stat = linuxcnc.stat() + control = linuxcnc.command() + stat.poll() + if getattr(stat, "task_state", None) in (linuxcnc.STATE_ESTOP, linuxcnc.STATE_ESTOP_RESET, linuxcnc.STATE_OFF, linuxcnc.STATE_ON): + connected = True + break + except Exception: + time.sleep(0.1) + if stat is None or control is None or not connected: + raise RuntimeError("native_linuxcnc_stat_unavailable") + + control.state(linuxcnc.STATE_ESTOP_RESET) + control.wait_complete(5) + wait_stat(stat, lambda item: item.task_state != linuxcnc.STATE_ESTOP, 5) + control.state(linuxcnc.STATE_ON) + control.wait_complete(5) + wait_stat(stat, lambda item: item.task_state == linuxcnc.STATE_ON, 5) + control.mode(linuxcnc.MODE_MANUAL) + control.wait_complete(5) + for joint in range(5): + control.home(joint) + control.wait_complete(5) + wait_stat(stat, lambda item: all(bool(value) for value in list(getattr(item, "homed", []))[:5]), 10) + control.program_open(str(PROGRAM)) + control.wait_complete(5) + control.mode(linuxcnc.MODE_AUTO) + control.wait_complete(5) + control.auto(linuxcnc.AUTO_RUN, 0) + + start = time.monotonic() + sample_index = 0 + previous_taskbeat: int | None = None + last_change = start + while time.monotonic() - start < duration: + row = stat_row(stat, sample_index, previous_taskbeat) + current_taskbeat = row["taskbeat"] + 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 + 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) + final = stat_row(stat, sample_index + 1, previous_taskbeat) + final["native_elapsed_ms"] = round((time.monotonic() - start) * 1000.0, 3) + rows.append(final) + except Exception as error: + errors.append(f"{type(error).__name__}:{error}") + finally: + terminate_process(process) + try: + output, _ = process.communicate(timeout=2) + output_lines.extend(output.splitlines()) + except (subprocess.TimeoutExpired, ValueError): + pass + cleanup_linuxcnc_services() + + source_bytes = PROGRAM.read_bytes() + trace_hash = sha256_bytes(canonical(rows).encode()) + status = "verified" if rows and not errors else "blocked" + return { + "schema": SCHEMA, + "status": status, + "case": {"path": CASE, "scope": "xyzac_only"}, + "launcher": command, + "native_axis_window": window, + "program": {"path": PROGRAM.relative_to(ROOT).as_posix(), "sha256": sha256_bytes(source_bytes)}, + "native_linuxcnc": {"python_module": getattr(linuxcnc, "__file__", None), "display": environment["DISPLAY"]}, + "tick_source": "linuxcnc.stat.taskbeat", + "rows": rows, + "row_count": len(rows), + "trace_sha256": trace_hash, + "errors": errors, + "launcher_output_tail": "\n".join(output_lines[-80:]), + "launcher_output_sha256": sha256_bytes("\n".join(output_lines).encode()), + "cleanup": cleanup, + "known_boundaries": [ + "native stat exposes motion_id but not full TP segment geometry; active_segment_geometry is explicit null", + "taskbeat is the public native task-cycle counter; wall-clock is recorded only as diagnostic elapsed_ms", + "this report is an oracle input and does not claim native/WASM parity by itself", + ], + "release_status": "STOP_RELEASE", + "report_sha256": None, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--duration", type=float, default=8.0) + parser.add_argument("--output", type=Path, default=Path("coverage/xyzac-native-tick-trace.report.json")) + args = parser.parse_args() + report = run(max(0.1, min(args.duration, 300.0))) + unsigned = {key: value for key, value in report.items() if key != "report_sha256"} + report["report_sha256"] = sha256_bytes(canonical(unsigned).encode()) + destination = args.output if args.output.is_absolute() else ROOT / args.output + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8") + print(json.dumps({"schema": SCHEMA, "status": report["status"], "row_count": report["row_count"], "trace_sha256": report["trace_sha256"], "errors": report["errors"], "report_sha256": report["report_sha256"]}, ensure_ascii=False)) + return 0 if report["status"] == "verified" else 1 + + +if __name__ == "__main__": + raise SystemExit(main())