Files
linuxcncWebWASM/scripts/run_xyzac_native_tick_trace.py

322 lines
15 KiB
Python

#!/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 sys
import time
from pathlib import Path
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
from run_axis3_tutorial_native import cleanup_linuxcnc_services
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, "task_heartbeat", None))
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,
"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": commanded_pose,
"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": distance_to_go,
"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] = []
stage = "launch"
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
# The AXIS toplevel is created only after linuxcncsvr/milltask have
# opened the NML buffers. Creating linuxcnc.stat before that point
# permanently binds an invalid channel, so wait for the real
# window first and create fresh channels afterwards.
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
break
except BaseException:
stat = None
control = None
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.wait_complete(5)
wait_stat(stat, lambda item: item.task_state != linuxcnc.STATE_ESTOP, 5)
stage = "state_on"
control.state(linuxcnc.STATE_ON)
control.wait_complete(5)
wait_stat(stat, lambda item: item.task_state == linuxcnc.STATE_ON, 5)
stage = "mode_manual"
control.mode(linuxcnc.MODE_MANUAL)
control.wait_complete(5)
for joint in range(5):
stage = f"home_joint_{joint}"
control.home(joint)
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)
stage = "program_open"
control.program_open(str(PROGRAM))
control.wait_complete(5)
stage = "mode_auto"
control.mode(linuxcnc.MODE_AUTO)
control.wait_complete(5)
stage = "program_run"
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)
if final["taskbeat_changed"]:
final["native_elapsed_ms"] = round((time.monotonic() - start) * 1000.0, 3)
rows.append(final)
except Exception as error:
errors.append(f"{stage}:{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()
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())
python_module = getattr(linuxcnc, "__file__", None)
source_python_module = str(ROOT / "linuxcnc/lib/python/linuxcnc.so")
if python_module != source_python_module:
errors.append("NATIVE_PYTHON_MODULE_MISMATCH")
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 {
"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": 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},
"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,
"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",
"source run-in-place launcher and installed Python extension must use the same LinuxCNC build; mismatch is blocked",
"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())