Files
linuxcncWebWASM/scripts/run_xyzac_native_tick_trace.py

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