Unblock native XYZAC TP oracle

This commit is contained in:
2026-08-31 20:08:00 +08:00
parent 18436149a2
commit 6015293ee9
17 changed files with 49339 additions and 61 deletions

View File

@@ -2,30 +2,34 @@
"schema": "task-handoff.v1", "schema": "task-handoff.v1",
"task_id": "TP-PARITY-00", "task_id": "TP-PARITY-00",
"context_id": "linuxcnc-2.10.0-pre0-wasm-m1-py-c1", "context_id": "linuxcnc-2.10.0-pre0-wasm-m1-py-c1",
"status": "blocked", "status": "verified",
"scope": "xyzac_only", "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", "result": "run-5axis-rtcp.sh launches the source LinuxCNC XYZAC case and the source Python NML extension captures a strictly monotonic native task-heartbeat trace with pose, joints, queue, current-line and dynamics fields",
"report": {"path": "coverage/xyzac-native-tick-trace.report.json", "status": "blocked", "sha256": "86d13e1a715c20b2a7bfc243301b46780a9f2942bd96d928c5ae24a53dbcca71"}, "report": {"path": "coverage/xyzac-native-tick-trace.report.json", "status": "verified", "sha256": "881571d0c75017a9610023a2d022d26606e653a0c62575563c0da7ada600e9a2"},
"parity_report": {"path": "coverage/xyzac-native-wasm-tick-parity.report.json", "status": "diverged", "sha256": "3ce76d65aee5405c01efca36b859b63da476f5f4d3bc4666be93691458fb86b3"},
"wasm_report": {"path": "coverage/xyzac-wasm-tick-trace.report.json", "status": "verified", "sha256": "21cbdaa8eaa1eda94ca107095ba7931cb5bd151c80e75c9b7dd9ec6149a71c73"},
"outputs": [ "outputs": [
{"path": "scripts/run_xyzac_native_tick_trace.py", "status": "created"}, {"path": "scripts/run_xyzac_native_tick_trace.py", "status": "verified"},
{"path": "doc-rtcp/xyzac-trt-full-tp-parity-plan.md", "status": "created"}, {"path": "scripts/run_xyzac_wasm_tick_trace.mjs", "status": "verified"},
{"path": "coverage/task-packets/TP-PARITY-00.json", "status": "created"} {"path": "scripts/compare_xyzac_native_wasm_tick_trace.py", "status": "verified"},
{"path": "doc-rtcp/xyzac-trt-full-tp-parity-plan.md", "status": "updated"},
{"path": "coverage/task-packets/TP-PARITY-00.json", "status": "verified"}
], ],
"checks": { "checks": {
"launcher": "run-5axis-rtcp.sh started native linuxcncsvr/io/halui/milltask/axis", "launcher": "run-5axis-rtcp.sh started native linuxcncsvr/io/halui/milltask/axis",
"axis_window": "visible during manual probe", "axis_window": "visible during manual probe",
"stat_attach": "blocked: emcStatusBuffer invalid err=3 / taskbeat invalid", "stat_attach": "verified: source extension attaches after AXIS/NML startup",
"python_module": "blocked: installed 2.10.0~pre1 extension differs from source 2.10.0~pre0 launcher", "python_module": "verified: source extension matches run-in-place launcher",
"native_trace": "blocked", "native_trace": "verified: 201 native heartbeat rows",
"wasm_trace": "verified: same XYZAC source/config loaded through linuxcnc-wasm worker",
"native_wasm_compare": "diverged: first difference $.rows[1].exec_state; release remains STOP_RELEASE",
"source_hashes": "recorded by collector", "source_hashes": "recorded by collector",
"full_parity_claim": "forbidden" "full_parity_claim": "forbidden"
}, },
"failure_samples": [ "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": "ACTIVE_SEGMENT_GEOMETRY_UNAVAILABLE", "detail": "public linuxcnc.stat exposes motion_id but not TP segment geometry; collector retains explicit null instead of fabricating geometry"}
{"code": "NATIVE_TASKBEAT_INVALID", "detail": "taskbeat remained 4294967296 and no valid native tick could be selected"},
{"code": "NATIVE_PYTHON_MODULE_MISMATCH", "detail": "collector imported /usr/lib/python3/dist-packages/linuxcnc.cpython-312-x86_64-linux-gnu.so while run-5axis-rtcp.sh uses the source 2.10.0~pre0 launcher"}
], ],
"next_task": "TP-PARITY-01", "next_task": "TP-PARITY-01",
"next_task_status": "blocked_by_TP-PARITY-00", "next_task_status": "in_progress",
"release_status": "STOP_RELEASE" "release_status": "STOP_RELEASE"
} }

View File

@@ -1,7 +1,7 @@
{ {
"schema": "task-packet.v1", "schema": "task-packet.v1",
"task_id": "TP-PARITY-00", "task_id": "TP-PARITY-00",
"status": "blocked", "status": "verified",
"title": "Native LinuxCNC XYZAC per-tick oracle", "title": "Native LinuxCNC XYZAC per-tick oracle",
"milestone": "M7-TP-NUMERICAL-PARITY", "milestone": "M7-TP-NUMERICAL-PARITY",
"context_id": "linuxcnc-2.10.0-pre0-wasm-m1-py-c1", "context_id": "linuxcnc-2.10.0-pre0-wasm-m1-py-c1",
@@ -22,14 +22,21 @@
], ],
"required_outputs": [ "required_outputs": [
"scripts/run_xyzac_native_tick_trace.py", "scripts/run_xyzac_native_tick_trace.py",
"scripts/run_xyzac_wasm_tick_trace.mjs",
"scripts/compare_xyzac_native_wasm_tick_trace.py",
"coverage/xyzac-native-tick-trace.report.json", "coverage/xyzac-native-tick-trace.report.json",
"coverage/xyzac-wasm-tick-trace.report.json",
"coverage/xyzac-native-wasm-tick-parity.report.json",
"coverage/task-packets/TP-PARITY-00.handoff.json" "coverage/task-packets/TP-PARITY-00.handoff.json"
], ],
"observed": { "observed": {
"report": "coverage/xyzac-native-tick-trace.report.json", "report": "coverage/xyzac-native-tick-trace.report.json",
"report_sha256": "86d13e1a715c20b2a7bfc243301b46780a9f2942bd96d928c5ae24a53dbcca71", "report_sha256": "881571d0c75017a9610023a2d022d26606e653a0c62575563c0da7ada600e9a2",
"row_count": 0, "row_count": 201,
"first_blocker": "NATIVE_STAT_BUFFER_INVALID" "first_blocker": null,
"tick_source": "linuxcnc.stat.task_heartbeat",
"tick_integrity": "strictly_monotonic",
"wasm_comparator": {"status": "diverged", "first_divergent_tick": 1, "difference_path": "$.rows[1].exec_state", "report": "coverage/xyzac-native-wasm-tick-parity.report.json"}
}, },
"acceptance": [ "acceptance": [
"run-5axis-rtcp.sh launches native LinuxCNC", "run-5axis-rtcp.sh launches native LinuxCNC",
@@ -53,7 +60,8 @@
"known_blockers": [ "known_blockers": [
"native stat API may not expose TP segment geometry; map motion_id and retain explicit unavailable field", "native stat API may not expose TP segment geometry; map motion_id and retain explicit unavailable field",
"native GUI process requires DISPLAY and cleanup", "native GUI process requires DISPLAY and cleanup",
"full LinuxCNC TP source integration may require ABI-compatible C port rather than simplified queue extension" "full LinuxCNC TP source integration may require ABI-compatible C port rather than simplified queue extension",
"native/WASM comparator currently diverges at current-line scheduling before TP numeric parity can be promoted"
], ],
"release_status": "STOP_RELEASE" "release_status": "STOP_RELEASE"
} }

View File

@@ -0,0 +1,20 @@
{
"schema": "tp-parity-release-state.v1",
"scope": "xyzac_only",
"tasks": [
{"task_id": "TP-PARITY-00", "status": "verified", "evidence": "coverage/xyzac-native-tick-trace.report.json"},
{"task_id": "TP-PARITY-01", "status": "in_progress", "evidence": "coverage/xyzac-native-wasm-tick-parity.report.json"},
{"task_id": "TP-PARITY-02", "status": "in_progress", "evidence": "wasm-core/src/sim_program.c"},
{"task_id": "TP-PARITY-03", "status": "blocked_by_TP-PARITY-01", "evidence": null},
{"task_id": "TP-PARITY-04", "status": "blocked_by_TP-PARITY-01", "evidence": null},
{"task_id": "TP-PARITY-05", "status": "blocked_by_TP-PARITY-01", "evidence": null},
{"task_id": "TP-PARITY-06", "status": "diverged", "evidence": "coverage/xyzac-native-wasm-tick-parity.report.json"},
{"task_id": "TP-PARITY-07", "status": "blocked_by_TP-PARITY-06", "evidence": null},
{"task_id": "TP-PARITY-08", "status": "blocked_by_TP-PARITY-06", "evidence": null}
],
"first_blocker": null,
"first_divergent_tick": 1,
"difference_path": "$.rows[1].exec_state",
"release_status": "STOP_RELEASE",
"reason": "The native oracle is available, but the existing WASM planner diverges from source LinuxCNC before full TP field parity is established."
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
{
"candidate_eligible": false,
"case": {
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
"scope": "xyzac_only"
},
"comparison": {
"difference_path": "$.rows[1].exec_state",
"first_divergent_tick": 1,
"native": 3,
"status": "diverged",
"wasm": 2
},
"difference_path": "$.rows[1].exec_state",
"first_divergent_tick": 1,
"known_boundaries": [
"native public stat does not expose TP segment geometry",
"WASM public snapshot does not expose acceleration/active blend geometry",
"verified oracle availability is not a full TP numerical parity claim"
],
"native": {
"report": "coverage/xyzac-native-tick-trace.report.json",
"report_sha256": "881571d0c75017a9610023a2d022d26606e653a0c62575563c0da7ada600e9a2",
"row_count": 201,
"trace_sha256": "173b5a5644254adef9ca5fd3b4fb2919889cce938d3aae7079c5004a4bfb364f"
},
"release_status": "STOP_RELEASE",
"report_sha256": "3ce76d65aee5405c01efca36b859b63da476f5f4d3bc4666be93691458fb86b3",
"schema": "xyzac-native-wasm-tick-parity.v1",
"status": "diverged",
"wasm": {
"report": "coverage/xyzac-wasm-tick-trace.report.json",
"report_sha256": "21cbdaa8eaa1eda94ca107095ba7931cb5bd151c80e75c9b7dd9ec6149a71c73",
"row_count": 202,
"runner_output_sha256": "5f493d9e726ec7bf656f738f92b61a17e0b6455562e68c85199c282d58817843",
"runner_returncode": 0,
"trace_sha256": "4202692d7f92ad9d4bfacb0ec1bb88eaad9e5b308e3a016c670f8fc4a73a5612"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,13 @@
# XYZAC TRT 完整 TP/前瞻数值对标任务轨道 # XYZAC TRT 完整 TP/前瞻数值对标任务轨道
状态:`TP-PARITY-00` 已建立,完整 LinuxCNC 数值对标尚未完成。 状态:`TP-PARITY-00` 已 verified,完整 LinuxCNC 数值对标尚未完成。
当前 oracle 证据:`coverage/xyzac-native-tick-trace.report.json` 已由源
`run-5axis-rtcp.sh` 产生连续 task-heartbeat tick;源 Python NML 扩展暴露
`task_heartbeat`,并记录 XYZAC pose/joints、queue、current-line、速度和加速度。
`scripts/compare_xyzac_native_wasm_tick_trace.py` 已接入同一 case 的 WASM trace,
当前首个差异保留为 `$.rows[1].exec_state`,因此不得把 TP-PARITY-01..08 写成
数值 parity verified。
目标不是证明页面能绘制一条曲线,而是对同一 `xyzac-trt.ini`、同一 G-code、同一初始状态,比较 native LinuxCNC 与 WASM Worker 的每个运动 tick: 目标不是证明页面能绘制一条曲线,而是对同一 `xyzac-trt.ini`、同一 G-code、同一初始状态,比较 native LinuxCNC 与 WASM Worker 的每个运动 tick:
@@ -44,6 +51,9 @@ Web 端只有在该链路和 native trace 逐字段一致后,才能声明 `PAR
### TP-PARITY-01:锁定 LinuxCNC TP 数据模型和 C ABI ### TP-PARITY-01:锁定 LinuxCNC TP 数据模型和 C ABI
状态:`in_progress`。native tick oracle 已可用;当前 comparator 显示现有
WASM queue 与源 TP 的执行顺序/速度尚未一致。
来源:`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}`。 来源:`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}`。
工作: 工作:
@@ -56,6 +66,9 @@ Web 端只有在该链路和 native trace 逐字段一致后,才能声明 `PAR
### TP-PARITY-02:G64/G61/G61.1 完整路径控制 ### TP-PARITY-02:G64/G61/G61.1 完整路径控制
状态:`in_progress`,WASM interpreter 侧已闭合指令模式和容差状态,numeric
blend 仍待 TP port 和 comparator 通过。
来源: 来源:
- `linuxcnc/src/emc/rs274ngc/interp_convert.cc` 的 G64/G61 处理 - `linuxcnc/src/emc/rs274ngc/interp_convert.cc` 的 G64/G61 处理
@@ -68,6 +81,11 @@ Web 端只有在该链路和 native trace 逐字段一致后,才能声明 `PAR
- 保存 modal 状态、每个 canonical motion 的 termination condition 和 tolerance。 - 保存 modal 状态、每个 canonical motion 的 termination condition 和 tolerance。
- 增加直线-直线、直线-圆弧、圆弧-直线、圆弧-圆弧、急转角、短段、零容差和非法 P/Q 样本。 - 增加直线-直线、直线-圆弧、圆弧-直线、圆弧-圆弧、急转角、短段、零容差和非法 P/Q 样本。
当前 WASM C interpreter 已输出三种模式及 P/Q 容差到 `sim_program.v1`,Worker
在入队前应用对应 queue profile;LinuxCNC exact-path 与 exact-stop 的细微
差别、P/Q 驱动的 corner blend 仍由 TP-PARITY-03/06 的 native tick comparator
验证,尚未标记完成。
退出条件:native/WASM 每条 TC 的 termination mode、tolerance、blend point、速度和完成 tick 一致。 退出条件:native/WASM 每条 TC 的 termination mode、tolerance、blend point、速度和完成 tick 一致。
### TP-PARITY-03:Lookahead、corner blending、acceleration/jerk ### TP-PARITY-03:Lookahead、corner blending、acceleration/jerk
@@ -144,6 +162,12 @@ state_hash
- 浮点使用固定绝对/相对容差,并记录原始值和差异值。 - 浮点使用固定绝对/相对容差,并记录原始值和差异值。
- 列表长度、字段缺失、状态顺序不允许静默忽略。 - 列表长度、字段缺失、状态顺序不允许静默忽略。
- 保存 `first_divergent_tick`、`difference_path`、native/WASM 两侧样本。 - 保存 `first_divergent_tick`、`difference_path`、native/WASM 两侧样本。
已提供可重复入口:
- `scripts/run_xyzac_wasm_tick_trace.mjs`
- `scripts/compare_xyzac_native_wasm_tick_trace.py`
- `coverage/xyzac-native-wasm-tick-parity.report.json`
- 最终坐标相同但中间 tick 不同必须判定为 `diverged`。 - 最终坐标相同但中间 tick 不同必须判定为 `diverged`。
### TP-PARITY-07:预览和实际执行一致性 ### TP-PARITY-07:预览和实际执行一致性

View File

@@ -0,0 +1,26 @@
diff --git a/src/emc/task/emctaskmain.cc b/src/emc/task/emctaskmain.cc
--- a/src/emc/task/emctaskmain.cc
+++ b/src/emc/task/emctaskmain.cc
@@ -3490,4 +3490,9 @@ int main(int argc, char *argv[])
// since emcStatus was passed to the WM init functions, it
// will be updated in the _update() functions above. There's
// no need to call the individual functions on all WM items.
+ // EMC_TASK_STAT_MSG::heartbeat is the native task-cycle oracle used by
+ // external clients. Keep it monotonic and publish it with this cycle's
+ // status image so a reader can identify every task tick without relying on
+ // wall-clock sampling.
+ emcStatus->task.heartbeat++;
emcStatusBuffer->write(emcStatus);
diff --git a/src/emc/usr_intf/axis/extensions/emcmodule.cc b/src/emc/usr_intf/axis/extensions/emcmodule.cc
--- a/src/emc/usr_intf/axis/extensions/emcmodule.cc
+++ b/src/emc/usr_intf/axis/extensions/emcmodule.cc
@@ -409,4 +409,7 @@ static PyMemberDef Stat_members[] = {
{(char*)"echo_serial_number", T_INT, O(echo_serial_number), READONLY},
{(char*)"echo_serial_number", T_INT, O(echo_serial_number), READONLY},
{(char*)"state", T_INT, O(status), READONLY},
+ {(char*)"task_heartbeat", T_UINT, O(task.heartbeat), READONLY,
+ "Native EMC task-loop heartbeat. This counter is incremented by the "
+ "LinuxCNC task thread and is suitable for identifying native status ticks."},
// task

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Compare the source LinuxCNC XYZAC oracle with the WASM worker trace."""
from __future__ import annotations
import hashlib
import json
import subprocess
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
NATIVE_REPORT = ROOT / "coverage/xyzac-native-tick-trace.report.json"
WASM_REPORT = ROOT / "coverage/xyzac-wasm-tick-trace.report.json"
REPORT = ROOT / "coverage/xyzac-native-wasm-tick-parity.report.json"
def canonical(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def digest(value: Any) -> str:
return hashlib.sha256((value if isinstance(value, bytes) else canonical(value).encode())).hexdigest()
def first_difference(left: Any, right: Any, path: str = "$") -> str | None:
if type(left) is not type(right):
return path
if isinstance(left, dict):
for key in sorted(set(left) | set(right)):
if key not in left or key not in right:
return f"{path}.{key}"
difference = first_difference(left[key], right[key], f"{path}.{key}")
if difference:
return difference
return None
if isinstance(left, list):
for index in range(max(len(left), len(right))):
if index >= len(left) or index >= len(right):
return f"{path}[{index}]"
difference = first_difference(left[index], right[index], f"{path}[{index}]")
if difference:
return difference
return None
return None if left == right else path
def canonical_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in ("joint_actual", "joint_commanded", "homed", "limits"):
if isinstance(result.get(key), list):
result[key] = result[key][:5]
return result
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}
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)
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}
report = {
"schema": "xyzac-native-wasm-tick-parity.v1",
"status": comparison["status"],
"candidate_eligible": comparison["status"] == "verified",
"case": native.get("case"),
"native": {"report": str(NATIVE_REPORT.relative_to(ROOT)), "report_sha256": native.get("report_sha256"), "trace_sha256": native.get("trace_sha256"), "row_count": native.get("row_count")},
"wasm": {"report": str(WASM_REPORT.relative_to(ROOT)), "report_sha256": wasm.get("report_sha256"), "trace_sha256": wasm.get("trace_sha256"), "row_count": wasm.get("row_count"), "runner_returncode": build.returncode, "runner_output_sha256": digest(build.stdout)},
"comparison": comparison,
"first_divergent_tick": comparison.get("first_divergent_tick"),
"difference_path": comparison.get("difference_path"),
"known_boundaries": ["native public stat does not expose TP segment geometry", "WASM public snapshot does not expose acceleration/active blend geometry", "verified oracle availability is not a full TP numerical parity claim"],
"release_status": "STOP_RELEASE",
}
report["report_sha256"] = digest({key: value for key, value in report.items() if key != "report_sha256"})
REPORT.write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8")
print(json.dumps({"schema": report["schema"], "status": report["status"], "first_divergent_tick": report["first_divergent_tick"], "difference_path": report["difference_path"], "report_sha256": report["report_sha256"]}, ensure_ascii=False))
return 0 if report["status"] == "verified" else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -100,10 +100,20 @@ def cleanup_linuxcnc_services() -> dict[str, object]:
pass pass
lock = Path("/tmp/linuxcnc.lock") lock = Path("/tmp/linuxcnc.lock")
lock_removed = False lock_removed = False
lock_backup = None
if lock.exists() and not any(service_pattern.search(command) for _, command in process_rows()): if lock.exists() and not any(service_pattern.search(command) for _, command in process_rows()):
lock.unlink() # Preserve stale lock evidence for post-mortem inspection. A lock is
# runtime state, not a repository artifact, so moving it is sufficient
# and recoverable while allowing the next source launcher to start.
lock_backup_path = Path(f"/tmp/linuxcnc.lock.stale-{time.strftime('%Y%m%d-%H%M%S')}")
suffix = 1
while lock_backup_path.exists():
lock_backup_path = Path(f"/tmp/linuxcnc.lock.stale-{time.strftime('%Y%m%d-%H%M%S')}-{suffix}")
suffix += 1
lock.rename(lock_backup_path)
lock_removed = True lock_removed = True
return {"commands": commands, "killed_pids": killed, "lock_path": lock.as_posix(), "lock_removed": lock_removed, "remaining_services": [command for _, command in process_rows() if service_pattern.search(command)]} lock_backup = lock_backup_path.as_posix()
return {"commands": commands, "killed_pids": killed, "lock_path": lock.as_posix(), "lock_removed": lock_removed, "lock_backup": lock_backup, "remaining_services": [command for _, command in process_rows() if service_pattern.search(command)]}
def run(screenshot_path: Path | None = None) -> dict[str, object]: def run(screenshot_path: Path | None = None) -> dict[str, object]:

View File

@@ -16,16 +16,22 @@ import os
import re import re
import signal import signal
import subprocess import subprocess
import sys
import time import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
# The oracle must bind to the same source build that run-5axis-rtcp.sh starts.
# In particular, importing the distro linuxcnc extension would silently bind to
# a different NML ABI/version and produce plausible but unusable status values.
ROOT = Path(__file__).resolve().parents[1]
SOURCE_PYTHON_DIR = ROOT / "linuxcnc/lib/python"
sys.path.insert(0, str(SOURCE_PYTHON_DIR))
import linuxcnc import linuxcnc
from run_axis3_tutorial_native import cleanup_linuxcnc_services 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" 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" PROGRAM = ROOT / "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc"
SCHEMA = "xyzac-native-linuxcnc-tick-trace.v1" SCHEMA = "xyzac-native-linuxcnc-tick-trace.v1"
@@ -58,10 +64,13 @@ def scalar(value: Any) -> float | int | bool | str | 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_taskbeat: int | None) -> dict[str, Any]:
stat.poll() stat.poll()
taskbeat = scalar(getattr(stat, "taskbeat", None)) taskbeat = scalar(getattr(stat, "task_heartbeat", None))
commanded_pose = finite_list(getattr(stat, "position", None))
distance_to_go = finite_list(getattr(stat, "dtg", None))
return { return {
"sample_index": sample_index, "sample_index": sample_index,
"taskbeat": taskbeat, "taskbeat": taskbeat,
"task_heartbeat": taskbeat,
"taskbeat_changed": previous_taskbeat is None or taskbeat != previous_taskbeat, "taskbeat_changed": previous_taskbeat is None or taskbeat != previous_taskbeat,
"task_state": scalar(getattr(stat, "task_state", None)), "task_state": scalar(getattr(stat, "task_state", None)),
"task_mode": scalar(getattr(stat, "task_mode", None)), "task_mode": scalar(getattr(stat, "task_mode", None)),
@@ -71,7 +80,7 @@ def stat_row(stat: Any, sample_index: int, previous_taskbeat: int | None) -> dic
"motion_line": scalar(getattr(stat, "motion_line", None)), "motion_line": scalar(getattr(stat, "motion_line", None)),
"read_line": scalar(getattr(stat, "read_line", None)), "read_line": scalar(getattr(stat, "read_line", None)),
"actual_pose": finite_list(getattr(stat, "actual_position", None)), "actual_pose": finite_list(getattr(stat, "actual_position", None)),
"commanded_pose": finite_list(getattr(stat, "command_position", None)), "commanded_pose": commanded_pose,
"joint_actual": finite_list(getattr(stat, "joint_actual_position", None)), "joint_actual": finite_list(getattr(stat, "joint_actual_position", None)),
"joint_commanded": finite_list(getattr(stat, "joint_position", None)), "joint_commanded": finite_list(getattr(stat, "joint_position", None)),
"velocity": scalar(getattr(stat, "current_vel", None)), "velocity": scalar(getattr(stat, "current_vel", None)),
@@ -86,7 +95,7 @@ def stat_row(stat: Any, sample_index: int, previous_taskbeat: int | None) -> dic
"active_segment_id": scalar(getattr(stat, "motion_id", None)), "active_segment_id": scalar(getattr(stat, "motion_id", None)),
"active_segment_geometry": None, "active_segment_geometry": None,
"active_segment_geometry_status": "unavailable_linuxcnc_stat_api", "active_segment_geometry_status": "unavailable_linuxcnc_stat_api",
"distance_to_go": finite_list(getattr(stat, "distance_to_go", None)), "distance_to_go": distance_to_go,
"position": finite_list(getattr(stat, "position", None)), "position": finite_list(getattr(stat, "position", None)),
"homed": list(getattr(stat, "homed", [])) if getattr(stat, "homed", None) is not None else 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, "limits": list(getattr(stat, "limit", [])) if getattr(stat, "limit", None) is not None else None,
@@ -150,6 +159,7 @@ def run(duration: float) -> dict[str, Any]:
output_lines: list[str] = [] output_lines: list[str] = []
rows: list[dict[str, Any]] = [] rows: list[dict[str, Any]] = []
errors: list[str] = [] errors: list[str] = []
stage = "launch"
stat: Any = None stat: Any = None
control: Any = None control: Any = None
connected = False connected = False
@@ -161,34 +171,56 @@ def run(duration: float) -> dict[str, Any]:
if not window: if not window:
time.sleep(0.1) time.sleep(0.1)
continue continue
try: # The AXIS toplevel is created only after linuxcncsvr/milltask have
stat = linuxcnc.stat() # opened the NML buffers. Creating linuxcnc.stat before that point
control = linuxcnc.command() # permanently binds an invalid channel, so wait for the real
stat.poll() # window first and create fresh channels afterwards.
if getattr(stat, "task_state", None) in (linuxcnc.STATE_ESTOP, linuxcnc.STATE_ESTOP_RESET, linuxcnc.STATE_OFF, linuxcnc.STATE_ON): time.sleep(0.1)
if stat is None or control is None or not connected:
if not window:
raise RuntimeError("native_linuxcnc_window_unavailable")
stat = None
control = None
attach_deadline = time.monotonic() + 10
while time.monotonic() < attach_deadline and process.poll() is None:
try:
candidate_stat = linuxcnc.stat()
candidate_stat.poll()
stat = candidate_stat
control = linuxcnc.command()
connected = True connected = True
break break
except Exception: except BaseException:
time.sleep(0.1) stat = None
if stat is None or control is None or not connected: control = None
raise RuntimeError("native_linuxcnc_stat_unavailable") time.sleep(0.1)
if stat is None or control is None or not connected:
raise RuntimeError("native_linuxcnc_stat_unavailable")
stage = "state_estop_reset"
control.state(linuxcnc.STATE_ESTOP_RESET) control.state(linuxcnc.STATE_ESTOP_RESET)
control.wait_complete(5) control.wait_complete(5)
wait_stat(stat, lambda item: item.task_state != linuxcnc.STATE_ESTOP, 5) wait_stat(stat, lambda item: item.task_state != linuxcnc.STATE_ESTOP, 5)
stage = "state_on"
control.state(linuxcnc.STATE_ON) control.state(linuxcnc.STATE_ON)
control.wait_complete(5) control.wait_complete(5)
wait_stat(stat, lambda item: item.task_state == linuxcnc.STATE_ON, 5) wait_stat(stat, lambda item: item.task_state == linuxcnc.STATE_ON, 5)
stage = "mode_manual"
control.mode(linuxcnc.MODE_MANUAL) control.mode(linuxcnc.MODE_MANUAL)
control.wait_complete(5) control.wait_complete(5)
for joint in range(5): for joint in range(5):
stage = f"home_joint_{joint}"
control.home(joint) control.home(joint)
control.wait_complete(5) control.wait_complete(5)
stage = "wait_home_all"
wait_stat(stat, lambda item: all(bool(value) for value in list(getattr(item, "homed", []))[:5]), 10) wait_stat(stat, lambda item: all(bool(value) for value in list(getattr(item, "homed", []))[:5]), 10)
stage = "program_open"
control.program_open(str(PROGRAM)) control.program_open(str(PROGRAM))
control.wait_complete(5) control.wait_complete(5)
stage = "mode_auto"
control.mode(linuxcnc.MODE_AUTO) control.mode(linuxcnc.MODE_AUTO)
control.wait_complete(5) control.wait_complete(5)
stage = "program_run"
control.auto(linuxcnc.AUTO_RUN, 0) control.auto(linuxcnc.AUTO_RUN, 0)
start = time.monotonic() start = time.monotonic()
@@ -208,10 +240,11 @@ def run(duration: float) -> dict[str, Any]:
sample_index += 1 sample_index += 1
time.sleep(0.005) time.sleep(0.005)
final = stat_row(stat, sample_index + 1, previous_taskbeat) final = stat_row(stat, sample_index + 1, previous_taskbeat)
final["native_elapsed_ms"] = round((time.monotonic() - start) * 1000.0, 3) if final["taskbeat_changed"]:
rows.append(final) final["native_elapsed_ms"] = round((time.monotonic() - start) * 1000.0, 3)
rows.append(final)
except Exception as error: except Exception as error:
errors.append(f"{type(error).__name__}:{error}") errors.append(f"{stage}:{type(error).__name__}:{error}")
finally: finally:
terminate_process(process) terminate_process(process)
try: try:
@@ -222,12 +255,19 @@ def run(duration: float) -> dict[str, Any]:
cleanup_linuxcnc_services() cleanup_linuxcnc_services()
source_bytes = PROGRAM.read_bytes() 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]
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()) trace_hash = sha256_bytes(canonical(rows).encode())
python_module = getattr(linuxcnc, "__file__", None) python_module = getattr(linuxcnc, "__file__", None)
source_python_module = str(ROOT / "linuxcnc/lib/python/linuxcnc.so") source_python_module = str(ROOT / "linuxcnc/lib/python/linuxcnc.so")
if python_module != source_python_module: if python_module != source_python_module:
errors.append("NATIVE_PYTHON_MODULE_MISMATCH") errors.append("NATIVE_PYTHON_MODULE_MISMATCH")
status = "verified" if rows and not errors else "blocked" status = "verified" if rows and not errors and heartbeat_monotonic and len(valid_heartbeats) == len(set(valid_heartbeats)) else "blocked"
if rows and not heartbeat_monotonic:
errors.append("NATIVE_TASK_HEARTBEAT_NON_MONOTONIC")
return { return {
"schema": SCHEMA, "schema": SCHEMA,
"status": status, "status": status,
@@ -235,8 +275,15 @@ def run(duration: float) -> dict[str, Any]:
"launcher": command, "launcher": command,
"native_axis_window": window, "native_axis_window": window,
"program": {"path": PROGRAM.relative_to(ROOT).as_posix(), "sha256": sha256_bytes(source_bytes)}, "program": {"path": PROGRAM.relative_to(ROOT).as_posix(), "sha256": sha256_bytes(source_bytes)},
"native_linuxcnc": {"python_module": python_module, "expected_source_python_module": source_python_module, "display": environment["DISPLAY"]}, "native_linuxcnc": {"python_module": python_module, "expected_source_python_module": source_python_module, "display": environment["DISPLAY"], "python_module_sha256": sha256_bytes(Path(python_module).read_bytes()) if python_module and Path(python_module).is_file() else None},
"tick_source": "linuxcnc.stat.taskbeat", "inputs": {
"case_ini": {"path": CASE, "sha256": sha256_bytes(ini_path.read_bytes())},
"launcher": {"path": "run-5axis-rtcp.sh", "sha256": sha256_bytes(launcher_path.read_bytes())},
"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_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, "rows": rows,
"row_count": len(rows), "row_count": len(rows),
"trace_sha256": trace_hash, "trace_sha256": trace_hash,

View File

@@ -0,0 +1,145 @@
#!/usr/bin/env node
import { readFile, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { join, resolve } from "node:path";
import createModule from "../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../web/workspace/sim-case-loader.mjs";
import { XyzacSession, CONTEXT_ID } from "../web/worker/xyzac-session.mjs";
const ROOT = resolve(process.cwd());
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
const PROGRAM = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc";
const SCHEMA = "xyzac-wasm-linuxcnc-tick-trace.v1";
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 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));
function rowFrom(state, task, program, tick) {
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 homedAxes = Array.isArray(motion.homed_axes) ? motion.homed_axes : [];
return {
sample_index: tick,
taskbeat: tick,
task_heartbeat: tick,
taskbeat_changed: true,
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),
commanded_pose: poseArray(position),
joint_actual: joints,
joint_commanded: joints,
velocity: Number(state?.velocity ?? 0),
acceleration: null,
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,
motion_id: Number(state?.active_id ?? 0),
active_segment_id: Number(state?.active_id ?? 0),
active_segment_geometry: null,
active_segment_geometry_status: "unavailable_wasm_public_snapshot",
distance_to_go: null,
position: poseArray(position),
homed: homedAxes.map((value) => (value ? 1 : 0)),
limits: ["x", "y", "z", "a", "b", "c", "u", "v", "w"].map((axis) => limits[axis] ? 1 : 0),
kinematics_type: 4,
motion_type: Number(state?.motion_type ?? 0),
feed_hold_enabled: null,
paused: program?.program_status === "paused",
wasm_tick: tick,
};
}
const fetchImpl = async (url) => {
try {
const body = await readFile(join(ROOT, String(url).replace(/^\//, "")));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
async function run(maxTicks) {
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
if (bundle.status !== "ready") throw new Error(`case_load_${bundle.code ?? bundle.status}`);
const source = bundle.files?.[PROGRAM];
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" });
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");
send("SESSION_ATTACH");
const loadedWorkspace = send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
if (loadedWorkspace.payload?.status !== "verified") throw new Error("workspace_load_failed");
for (const [kind, payload] of [["POWER", { active: true }], ["HOME", { all: true }], ["PROGRAM_LOAD", { source, path: PROGRAM }], ["PROGRAM_RUN"]]) {
const result = send(kind, payload);
if (result.payload?.status !== "verified") throw new Error(`${kind.toLowerCase()}_${result.payload?.code ?? "failed"}`);
}
const rows = [];
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));
for (let tick = 1; tick <= maxTicks; tick += 1) {
const response = send("SIM_STEP", { cycles: 1 });
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));
if (program.program_status === "completed" && Number(state.queue_depth) === 0) break;
}
const traceHash = sha256(JSON.stringify(rows));
const sourceHash = sha256(source);
return {
schema: SCHEMA,
status: "verified",
case: { path: CASE, scope: "xyzac_only" },
native_equivalent_inputs: { case_ini: sha256(await readFile(join(ROOT, CASE))), program: sourceHash },
wasm: { module: "build-wasm/linuxcnc-sim-core.wasm", module_sha256: sha256(wasmBinary), authority: "linuxcnc-wasm-worker" },
tick_source: "wasm_simulation_tick",
tick_integrity: { first: rows[0]?.wasm_tick ?? null, last: rows.at(-1)?.wasm_tick ?? null, row_count: rows.length, unique_count: new Set(rows.map((row) => row.wasm_tick)).size, strictly_monotonic: rows.every((row, index) => index === 0 || row.wasm_tick > rows[index - 1].wasm_tick) },
rows,
row_count: rows.length,
trace_sha256: traceHash,
errors: [],
release_status: "STOP_RELEASE",
};
}
const args = process.argv.slice(2);
const ticksIndex = args.indexOf("--ticks");
const outputIndex = args.indexOf("--output");
const maxTicks = Math.max(1, Math.min(100000, Number(ticksIndex >= 0 ? args[ticksIndex + 1] : 3000)));
const output = outputIndex >= 0 ? resolve(args[outputIndex + 1]) : join(ROOT, "coverage/xyzac-wasm-tick-trace.report.json");
try {
const report = await run(maxTicks);
const unsigned = { ...report, report_sha256: undefined };
delete unsigned.report_sha256;
report.report_sha256 = sha256(JSON.stringify(unsigned));
await writeFile(output, `${JSON.stringify(report, null, 2)}\n`);
console.log(JSON.stringify({ schema: report.schema, status: report.status, row_count: report.row_count, trace_sha256: report.trace_sha256, report_sha256: report.report_sha256 }));
} catch (error) {
console.error(JSON.stringify({ schema: SCHEMA, status: "blocked", error: String(error?.message ?? error) }));
process.exitCode = 1;
}

View File

@@ -37,6 +37,12 @@ enum sim_program_status {
SIM_PROGRAM_ERROR = 6, SIM_PROGRAM_ERROR = 6,
}; };
enum sim_program_motion_control {
SIM_PROGRAM_MOTION_CONTINUOUS = 0,
SIM_PROGRAM_MOTION_EXACT_PATH = 1,
SIM_PROGRAM_MOTION_EXACT_STOP = 2,
};
typedef struct sim_program_subroutine { typedef struct sim_program_subroutine {
char name[SIM_PROGRAM_SUBROUTINE_NAME_CAPACITY]; char name[SIM_PROGRAM_SUBROUTINE_NAME_CAPACITY];
uint32_t body_line; uint32_t body_line;
@@ -70,6 +76,9 @@ typedef struct sim_program {
uint32_t has_control; uint32_t has_control;
int32_t control_code; int32_t control_code;
double rapid_velocity; double rapid_velocity;
int motion_control_mode;
double path_tolerance;
double naive_cam_tolerance;
} sim_program; } sim_program;
uint32_t sim_program_size(void); uint32_t sim_program_size(void);

View File

@@ -85,6 +85,10 @@ static int code_is(double value, int integer_code) {
return isfinite(value) && fabs(value - (double)integer_code) <= 1e-12; return isfinite(value) && fabs(value - (double)integer_code) <= 1e-12;
} }
static int code_is_value(double value, double expected) {
return isfinite(value) && fabs(value - expected) <= 1e-12;
}
static int axis_index(char letter) { static int axis_index(char letter) {
switch (letter) { switch (letter) {
case 'X': return 0; case 'Y': return 1; case 'Z': return 2; case 'X': return 0; case 'Y': return 1; case 'Z': return 2;
@@ -346,7 +350,7 @@ static int apply_g(sim_interpreter_state *state, interpreter_meta *meta, double
if (code_is(code, 40) || code_is(code, 41) || code_is(code, 42)) { if (++groups[4] > 1) return SIM_INTERP_ERR_STATE; if (meta) meta->cutter_comp = (int)code; return SIM_INTERP_OK; } if (code_is(code, 40) || code_is(code, 41) || code_is(code, 42)) { if (++groups[4] > 1) return SIM_INTERP_ERR_STATE; if (meta) meta->cutter_comp = (int)code; return SIM_INTERP_OK; }
if (code_is(code, 43) || code_is(code, 49)) { if (++groups[5] > 1) return SIM_INTERP_ERR_STATE; if (meta && code_is(code, 49)) meta->tool_length = 0.0; return SIM_INTERP_OK; } if (code_is(code, 43) || code_is(code, 49)) { if (++groups[5] > 1) return SIM_INTERP_ERR_STATE; if (meta && code_is(code, 49)) meta->tool_length = 0.0; return SIM_INTERP_OK; }
if (code_is(code, 80) || (code >= 81.0 && code <= 89.0 && floor(code) == code)) { if (meta) meta->cycle_mode = (int)code; return SIM_INTERP_OK; } if (code_is(code, 80) || (code >= 81.0 && code <= 89.0 && floor(code) == code)) { if (meta) meta->cycle_mode = (int)code; return SIM_INTERP_OK; }
if (code_is(code, 64)) return SIM_INTERP_OK; if (code_is(code, 61) || code_is_value(code, 61.1) || code_is(code, 64)) { if (++groups[7] > 1) return SIM_INTERP_ERR_STATE; return SIM_INTERP_OK; }
if (code_is(code, 54) || code_is(code, 55) || code_is(code, 56) || code_is(code, 57) || code_is(code, 58) || code_is(code, 59)) { if (meta) meta->coordinate_system = (int)code - 53; return SIM_INTERP_OK; } if (code_is(code, 54) || code_is(code, 55) || code_is(code, 56) || code_is(code, 57) || code_is(code, 58) || code_is(code, 59)) { if (meta) meta->coordinate_system = (int)code - 53; return SIM_INTERP_OK; }
if (code_is(code, 53)) return SIM_INTERP_OK; if (code_is(code, 53)) return SIM_INTERP_OK;
if (code_is(code, 10)) return SIM_INTERP_OK; if (code_is(code, 10)) return SIM_INTERP_OK;
@@ -444,7 +448,7 @@ int sim_interpreter_init(sim_interpreter *interpreter) { return sim_interpreter_
int sim_interpreter_execute(sim_interpreter *interpreter, const char *block, uint32_t length) { int sim_interpreter_execute(sim_interpreter *interpreter, const char *block, uint32_t length) {
sim_interpreter_state previous; sim_interpreter_state next; interpreter_meta *meta; expression_parser parser; const char *end; sim_interpreter_state previous; sim_interpreter_state next; interpreter_meta *meta; expression_parser parser; const char *end;
int groups[7] = {0}, seen_axis[9] = {0}, seen_feed = 0, seen_speed = 0, seen_line = 0, seen_p = 0, seen_r = 0, seen_l = 0, seen_g10 = 0, seen_g53 = 0, result = SIM_INTERP_OK; int groups[8] = {0}, seen_axis[9] = {0}, seen_feed = 0, seen_speed = 0, seen_line = 0, seen_p = 0, seen_r = 0, seen_l = 0, seen_g10 = 0, seen_g53 = 0, result = SIM_INTERP_OK;
int seen_center[3] = {0}; int seen_center[3] = {0};
double axis_values[9] = {0}, center_values[3] = {0}, feed_value = 0.0, speed_value = 0.0, p_value = 0.0, r_value = 0.0, l_value = 0.0; double axis_values[9] = {0}, center_values[3] = {0}, feed_value = 0.0, speed_value = 0.0, p_value = 0.0, r_value = 0.0, l_value = 0.0;
if (interpreter == NULL || block == NULL || length == 0) return SIM_INTERP_ERR_ARGUMENT; if (interpreter == NULL || block == NULL || length == 0) return SIM_INTERP_ERR_ARGUMENT;

View File

@@ -253,6 +253,45 @@ static int switch_mcode(const char *block, uint32_t length) {
return 0; return 0;
} }
static int parse_motion_control(sim_program *program, const char *block, uint32_t length) {
const char *cursor = block;
const char *end = block + length;
int active = 0;
double path = 0.0;
double naive = 0.0;
while (cursor < end) {
char letter;
char *number_end;
double value;
while (cursor < end && isspace((unsigned char)*cursor)) ++cursor;
if (cursor >= end || *cursor == ';') break;
if (*cursor == '(') { while (cursor < end && *cursor++ != ')') {} continue; }
if (!isalpha((unsigned char)*cursor)) { ++cursor; continue; }
letter = (char)toupper((unsigned char)*cursor++);
value = strtod(cursor, &number_end);
if (number_end == cursor) continue;
cursor = number_end;
if (letter == 'G') {
if (fabs(value - 61.0) <= 1e-12) active = SIM_PROGRAM_MOTION_EXACT_PATH;
else if (fabs(value - 61.1) <= 1e-12) active = SIM_PROGRAM_MOTION_EXACT_STOP;
else if (fabs(value - 64.0) <= 1e-12) active = SIM_PROGRAM_MOTION_CONTINUOUS;
else active = 0;
if (active != 0 || fabs(value - 64.0) <= 1e-12) {
program->motion_control_mode = active;
path = 0.0;
naive = 0.0;
}
} else if (active == SIM_PROGRAM_MOTION_CONTINUOUS && (letter == 'P' || letter == 'Q')) {
if (!isfinite(value) || value < 0.0) return SIM_PROGRAM_ERR_RANGE;
if (letter == 'P') path = value;
else naive = value;
program->path_tolerance = path;
program->naive_cam_tolerance = naive;
}
}
return SIM_PROGRAM_OK;
}
static void reset_runtime(sim_program *program) { static void reset_runtime(sim_program *program) {
sim_interpreter_reset(&program->interpreter); sim_interpreter_reset(&program->interpreter);
program->source_length = 0; program->source_length = 0;
@@ -268,6 +307,9 @@ static void reset_runtime(sim_program *program) {
program->has_control = 0; program->has_control = 0;
program->control_code = 0; program->control_code = 0;
program->rapid_velocity = 35.0; program->rapid_velocity = 35.0;
program->motion_control_mode = SIM_PROGRAM_MOTION_CONTINUOUS;
program->path_tolerance = 0.0;
program->naive_cam_tolerance = 0.0;
memset(&program->motion, 0, sizeof(program->motion)); memset(&program->motion, 0, sizeof(program->motion));
memset(program->subroutines, 0, sizeof(program->subroutines)); memset(program->subroutines, 0, sizeof(program->subroutines));
memset(program->call_stack, 0, sizeof(program->call_stack)); memset(program->call_stack, 0, sizeof(program->call_stack));
@@ -356,6 +398,8 @@ int32_t sim_program_execute_block(sim_program *program, const char *block, uint3
int result; int result;
if (program == NULL || block == NULL || length == 0) return SIM_PROGRAM_ERR_ARGUMENT; if (program == NULL || block == NULL || length == 0) return SIM_PROGRAM_ERR_ARGUMENT;
if (control_line(block)) return SIM_PROGRAM_ERR_CONTROL; if (control_line(block)) return SIM_PROGRAM_ERR_CONTROL;
result = parse_motion_control(program, block, length);
if (result != SIM_PROGRAM_OK) return result;
program->has_control = 0; program->has_control = 0;
program->control_code = switch_mcode(block, length); program->control_code = switch_mcode(block, length);
if (program->control_code != 0) program->has_control = 1; if (program->control_code != 0) program->has_control = 1;
@@ -449,8 +493,8 @@ uint32_t sim_program_canonical(const sim_program *program, char *output, uint32_
int written; int written;
if (program == NULL) return 0; if (program == NULL) return 0;
written = snprintf(destination, destination_capacity, written = snprintf(destination, destination_capacity,
"{\"call_depth\":%u,\"control_code\":%d,\"current_line\":%u,\"error_line\":%u,\"feed\":%.17g,\"has_control\":%u,\"has_motion\":%u,\"last_error\":%d,\"line_count\":%u,\"main_line_count\":%u,\"motion_mode\":%d,\"plane\":%d,\"rapid_velocity\":%.17g,\"schema\":\"sim_program.v1\",\"speed\":%.17g,\"spindle_mode\":%d,\"status\":%u,\"subroutine_count\":%u,\"source_length\":%u,\"units\":%d,\"distance_mode\":%d,\"interpreter_state_hash\":\"%llu\"}", "{\"call_depth\":%u,\"control_code\":%d,\"current_line\":%u,\"error_line\":%u,\"feed\":%.17g,\"has_control\":%u,\"has_motion\":%u,\"last_error\":%d,\"line_count\":%u,\"main_line_count\":%u,\"motion_control_mode\":%d,\"path_tolerance\":%.17g,\"naive_cam_tolerance\":%.17g,\"motion_mode\":%d,\"plane\":%d,\"rapid_velocity\":%.17g,\"schema\":\"sim_program.v1\",\"speed\":%.17g,\"spindle_mode\":%d,\"status\":%u,\"subroutine_count\":%u,\"source_length\":%u,\"units\":%d,\"distance_mode\":%d,\"interpreter_state_hash\":\"%llu\"}",
program->call_depth, program->control_code, program->current_line, program->error_line, program->interpreter.state.feed, program->has_control, program->has_motion, program->last_error, program->line_count, program->main_line_count, program->interpreter.state.motion_mode, program->interpreter.state.plane, program->rapid_velocity, program->interpreter.state.speed, program->interpreter.state.spindle_mode, program->status, program->subroutine_count, program->source_length, program->interpreter.state.units, program->interpreter.state.distance_mode, program->call_depth, program->control_code, program->current_line, program->error_line, program->interpreter.state.feed, program->has_control, program->has_motion, program->last_error, program->line_count, program->main_line_count, program->motion_control_mode, program->path_tolerance, program->naive_cam_tolerance, program->interpreter.state.motion_mode, program->interpreter.state.plane, program->rapid_velocity, program->interpreter.state.speed, program->interpreter.state.spindle_mode, program->status, program->subroutine_count, program->source_length, program->interpreter.state.units, program->interpreter.state.distance_mode,
(unsigned long long)sim_interpreter_state_hash(&program->interpreter)); (unsigned long long)sim_interpreter_state_hash(&program->interpreter));
return written < 0 ? 0 : (uint32_t)written + 1u; return written < 0 ? 0 : (uint32_t)written + 1u;
} }

View File

@@ -1436,6 +1436,13 @@ export class SimSession {
} }
const step = this.xyzacProgram.step(); const step = this.xyzacProgram.step();
if (step.status !== "verified") throw new Error(step.code); if (step.status !== "verified") throw new Error(step.code);
// Path control is produced by the WASM interpreter and applied
// to the WASM trajectory queue before the block's motion is
// enqueued. G61/G61.1 conservatively use the queue's exact
// profile; G64 restores the continuous lookahead profile.
const motionControlMode = Number(step.program?.motion_control_mode ?? 0);
const queueProfile = motionControlMode === 0 ? 0 : 1;
if (this.wasmMotion.controls.profile !== queueProfile) this.wasmMotion.setProfile(queueProfile);
if (step.has_control && [428, 429, 430].includes(step.control_code)) { if (step.has_control && [428, 429, 430].includes(step.control_code)) {
this.pendingProgramControl = step.control_code; this.pendingProgramControl = step.control_code;
if (this.wasmMotion.read().state.queue_depth === 0) { if (this.wasmMotion.read().state.queue_depth === 0) {

View File

@@ -266,8 +266,9 @@ export class WasmProgramRuntime {
const state = JSON.parse(readString(this.core, buffer, required)); const state = JSON.parse(readString(this.core, buffer, required));
const hash = call(this.core, "sim_program_state_hash", "number", ["number"], [this.pointer]); const hash = call(this.core, "sim_program_state_hash", "number", ["number"], [this.pointer]);
const programStatus = { 0: "idle", 1: "loaded", 2: "running", 3: "paused", 4: "completed", 5: "aborted", 6: "fault" }[state.status] ?? "idle"; const programStatus = { 0: "idle", 1: "loaded", 2: "running", 3: "paused", 4: "completed", 5: "aborted", 6: "fault" }[state.status] ?? "idle";
const motionControl = { 0: "continuous", 1: "exact_path", 2: "exact_stop" }[state.motion_control_mode] ?? "continuous";
const executionLineText = state.current_line > 0 ? (this.executionLines[state.current_line - 1] ?? "").trim() : ""; const executionLineText = state.current_line > 0 ? (this.executionLines[state.current_line - 1] ?? "").trim() : "";
return { schema: SCHEMA, source: "linuxcnc-wasm-sim_program", ...state, active_gcodes: activeGcodes(state), loaded: state.status !== 0, line: state.current_line, currentLine: state.current_line, execution_line: state.current_line, execution_line_text: executionLineText, program_status: programStatus, path: this.path, subroutines: this.subroutines.map((subroutine) => subroutine.name), state_hash: `fnv1a64:${BigInt.asUintN(64, BigInt(hash >>> 0)).toString(16).padStart(16, "0")}`, source_hash: this.source ? fnv1a64(this.source) : null }; return { schema: SCHEMA, source: "linuxcnc-wasm-sim_program", ...state, motion_control: { mode: motionControl, path_tolerance: Number(state.path_tolerance ?? 0), naive_cam_tolerance: Number(state.naive_cam_tolerance ?? 0) }, active_gcodes: activeGcodes(state), loaded: state.status !== 0, line: state.current_line, currentLine: state.current_line, execution_line: state.current_line, execution_line_text: executionLineText, program_status: programStatus, path: this.path, subroutines: this.subroutines.map((subroutine) => subroutine.name), state_hash: `fnv1a64:${BigInt.asUintN(64, BigInt(hash >>> 0)).toString(16).padStart(16, "0")}`, source_hash: this.source ? fnv1a64(this.source) : null };
} finally { this.core._free(buffer); } } finally { this.core._free(buffer); }
} }