Files

220 lines
8.4 KiB
Python

#!/usr/bin/env python3
"""Watch one axis's drive frame while you start homing from elsewhere.
Homing failures are hard to read after the fact: the drive reports a state, the
controller reports another, and by the time anyone looks, the motion has stopped
and the only evidence is "it ended up in the wrong place". This samples the
whole per-axis frame — commanded and measured position, the drive status word
(including the limit/home switch bits and the homing bits), the controller's
own motion and homing state, and the three mapped inputs — and prints a line
only when something changes.
Run it, then press 启动回零 in the app (or send the homing command from ZDevelop)
while it is sampling:
python apps\\autocal\\tools\\home_trace.py --axis 3 --seconds 45
python apps\\autocal\\tools\\home_trace.py --axis 3 --seconds 45 --csv trace.csv
Reading is non-mutating: it never enables, moves, homes or stops an axis. It
does open its own connection to the controller, so run it *instead of* having
the app open if two clients on one controller is a problem on your bench; the
ZMotion runtime generally allows it, but the app's own poller is the only writer
you want during a homing run.
What the lines mean (CiA-402 status word, exposed here as ``DRIVE_STATUS``):
====== ==================================================
bit 1 positive limit switch active
bit 2 negative limit switch active
bit 3 drive fault
bit 4 home (datum) switch active
bit 10 target reached
bit 12 homing attained — the drive *thinks* it found its reference
bit 13 homing error
====== ==================================================
A homing run that ends with bit 12 set and no error, at a position you did not
expect, means the drive accepted a reference the mechanical setup does not
agree with — usually a switch polarity the drive reads as "already made", not a
missing index pulse (which instead creeps until something stops it).
Exit status is 0 whether or not a run was seen: this is an observation tool, and
"nothing happened" is a result.
"""
from __future__ import annotations
import argparse
import csv
import ctypes
import os
import sys
import time
from pathlib import Path
APP_ROOT = Path(__file__).resolve().parents[1]
for path in (APP_ROOT / "src", APP_ROOT.parents[1] / "shared" / "src"):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
os.environ.setdefault("ORISYS_APP_ID", "autocal")
#: Fields sampled per tick, in the order the header and CSV are written.
FIELDS = (
"t",
"dpos",
"mpos",
"idle",
"drive_status",
"p_ot",
"n_ot",
"fault",
"home_sw",
"target_reached",
"homing_attained",
"homing_error",
"axis_status",
"home_status",
"in_rev",
"in_fwd",
"in_datum",
)
#: Position change above this counts as motion, in user units.
MOVE_EPSILON = 1e-6
def _bit(value: int, index: int) -> int:
return (int(value) >> index) & 1
class AxisReader:
"""The few controller calls this tool needs, on one connection."""
def __init__(self, ip: str) -> None:
from orisys_app_shared.zmotion import ZmotionClient
self._client = ZmotionClient().load()
result = self._client.open_eth(ip)
result.raise_for_status(f"open {ip}")
self._handle = self._client._require_handle() # noqa: SLF001 - one client
def close(self) -> None:
self._client.close()
def _param(self, name: str, axis: int) -> float | None:
value = ctypes.c_float()
code = self._client._dll.ZAux_Direct_GetParam( # noqa: SLF001
self._handle, name.encode(), ctypes.c_int(axis), ctypes.byref(value)
)
return float(value.value) if code == 0 else None
def _input(self, pin: int) -> int | None:
value = ctypes.c_int()
code = self._client._dll.ZAux_Direct_GetIn( # noqa: SLF001
self._handle, ctypes.c_int(pin), ctypes.byref(value)
)
return int(value.value) if code == 0 else None
def sample(self, axis: int, elapsed: float) -> dict:
raw_status = self._param("DRIVE_STATUS", axis)
status = int(raw_status or 0) & 0xFFFF
# The three switch inputs are mapped contiguously by ECAT.bas / RTEX.bas:
# REV at 128 + 16*axis, then FWD and DATUM.
base = 128 + 16 * axis
return {
"t": round(elapsed, 3),
"dpos": self._param("DPOS", axis),
"mpos": self._param("MPOS", axis),
"idle": self._param("IDLE", axis),
"drive_status": f"0x{status:04x}",
"p_ot": _bit(status, 1),
"n_ot": _bit(status, 2),
"fault": _bit(status, 3),
"home_sw": _bit(status, 4),
"target_reached": _bit(status, 10),
"homing_attained": _bit(status, 12),
"homing_error": _bit(status, 13),
"axis_status": self._client.get_axis_status(axis).value_or_none,
"home_status": self._client.bus_home_status(axis).value_or_none,
"in_rev": self._input(base),
"in_fwd": self._input(base + 1),
"in_datum": self._input(base + 2),
}
def _render(row: dict) -> str:
return (
f"t={row['t']:7.3f} {row['drive_status']} "
f"OT+={row['p_ot']} OT-={row['n_ot']} HOME={row['home_sw']} FAULT={row['fault']} "
f"REACHED={row['target_reached']} HOMED={row['homing_attained']} "
f"HOMERR={row['homing_error']} "
f"DPOS={row['dpos']} IDLE={row['idle']} ctrl_home_status={row['home_status']} "
f"IN={row['in_rev']}/{row['in_fwd']}/{row['in_datum']}"
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="home-trace", description=__doc__)
parser.add_argument("--axis", type=int, required=True, help="axis number to watch")
parser.add_argument("--seconds", type=float, default=30.0, help="how long to sample")
parser.add_argument("--ip", default="192.168.0.11", help="controller address")
parser.add_argument("--interval-ms", type=float, default=20.0, help="sample period")
parser.add_argument("--csv", default="", help="also write every sample to this CSV")
args = parser.parse_args(argv)
reader = AxisReader(args.ip)
print(f"watching axis {args.axis} for {args.seconds:g}s — start homing now", flush=True)
period = max(5.0, args.interval_ms) / 1000.0
deadline = time.monotonic() + args.seconds
started = time.monotonic()
rows: list[dict] = []
previous: dict | None = None
movements = 0
try:
while time.monotonic() < deadline:
row = reader.sample(args.axis, time.monotonic() - started)
rows.append(row)
interesting = ("drive_status", "idle", "p_ot", "n_ot", "home_sw", "fault",
"target_reached", "homing_attained", "homing_error",
"axis_status", "home_status", "in_rev", "in_fwd", "in_datum")
changed = previous is None or any(row[k] != previous[k] for k in interesting)
moved = previous is not None and row["mpos"] is not None and previous["mpos"] is not None \
and abs(row["mpos"] - previous["mpos"]) > MOVE_EPSILON
if moved and not changed:
movements += 1
if movements % 25 == 0: # a heartbeat while it travels
print(" " + _render(row), flush=True)
elif changed:
print(" " + _render(row), flush=True)
previous = row
time.sleep(period)
except KeyboardInterrupt: # pragma: no cover - operator convenience
print("\ninterrupted", flush=True)
finally:
reader.close()
if args.csv:
path = Path(args.csv)
with path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=list(FIELDS))
writer.writeheader()
writer.writerows(rows)
print(f"wrote {len(rows)} samples to {path}", flush=True)
if previous is not None:
print("final: " + _render(previous), flush=True)
if previous["homing_attained"] and not previous["homing_error"]:
print(
"note: the drive reports homing attained — if the position is not where the "
"mechanics say home is, the switch polarity the drive sees is wrong (it read "
"'already made'), or the wrong switch edge was chosen.",
flush=True,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())