Z轴可利用代码控制移动
This commit is contained in:
@@ -48,13 +48,13 @@
|
|||||||
},
|
},
|
||||||
"move": {
|
"move": {
|
||||||
"execute": "Execute",
|
"execute": "Execute",
|
||||||
"execute_hint": "Send the selected point's solved pose to the platform: yaw, then pitch, then table X/Y. The press axis Z is not moved.",
|
"execute_hint": "Send the selected point's solved pose to the platform: yaw, pitch and table X/Y move together. The press axis Z is not moved.",
|
||||||
"stop": "Stop",
|
"stop": "Stop",
|
||||||
"stop_hint": "Stop every axis now (the same command as 全部停止 in the strip).",
|
"stop_hint": "Stop every axis now (the same command as 全部停止 in the strip).",
|
||||||
"return_initial": "Return to zero",
|
"return_initial": "Return to zero",
|
||||||
"return_hint": "Every axis back to the initial state, press axis Z first.",
|
"return_hint": "Every axis back to the initial state, press axis Z first.",
|
||||||
"confirm_title": "Confirm motion",
|
"confirm_title": "Confirm motion",
|
||||||
"confirm_execute": "Will move, in this order:\n\n{steps}\n\nCheck this against the machine before continuing.",
|
"confirm_execute": "Will move these axes together:\n\n{steps}\n\nCheck this against the machine before continuing.",
|
||||||
"confirm_return": "Will move every axis back to zero (press axis Z first):\n\n{steps}",
|
"confirm_return": "Will move every axis back to zero (press axis Z first):\n\n{steps}",
|
||||||
"hold_note": "Not moved: {roles} (the press axis is P7's business — contact detection and press depth)",
|
"hold_note": "Not moved: {roles} (the press axis is P7's business — contact detection and press depth)",
|
||||||
"cancelled": "Cancelled; nothing was commanded",
|
"cancelled": "Cancelled; nothing was commanded",
|
||||||
|
|||||||
@@ -48,13 +48,13 @@
|
|||||||
},
|
},
|
||||||
"move": {
|
"move": {
|
||||||
"execute": "执行动作",
|
"execute": "执行动作",
|
||||||
"execute_hint": "把选中点解算出的位姿送到平台:先回转、再俯仰,最后工作台 X/Y。压轴 Z 不动作。",
|
"execute_hint": "把选中点解算出的位姿送到平台:回转、俯仰、工作台 X/Y 同时移动。压轴 Z 不动作。",
|
||||||
"stop": "停止",
|
"stop": "停止",
|
||||||
"stop_hint": "立即停止所有轴(与顶栏「全部停止」相同)。",
|
"stop_hint": "立即停止所有轴(与顶栏「全部停止」相同)。",
|
||||||
"return_initial": "回初始位",
|
"return_initial": "回初始位",
|
||||||
"return_hint": "五个轴全部回到零点,先退压轴 Z。",
|
"return_hint": "五个轴全部回到零点,先退压轴 Z。",
|
||||||
"confirm_title": "确认运动",
|
"confirm_title": "确认运动",
|
||||||
"confirm_execute": "将按以下顺序移动:\n\n{steps}\n\n请确认仿真与机床一致。",
|
"confirm_execute": "将同时移动:\n\n{steps}\n\n请确认仿真与机床一致。",
|
||||||
"confirm_return": "将把五个轴全部移回零点(先退压轴 Z):\n\n{steps}",
|
"confirm_return": "将把五个轴全部移回零点(先退压轴 Z):\n\n{steps}",
|
||||||
"hold_note": "不动作:{roles}(压轴由 P7 的接触检测与压深另外管)",
|
"hold_note": "不动作:{roles}(压轴由 P7 的接触检测与压深另外管)",
|
||||||
"cancelled": "已取消,未发出任何运动",
|
"cancelled": "已取消,未发出任何运动",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Moving the platform to a simulated pose, one axis at a time.
|
"""Moving the platform to a simulated pose.
|
||||||
|
|
||||||
The finger screen solves a pose — rotate the stages, slide the table — and shows it.
|
The finger screen solves a pose — rotate the stages, slide the table — and shows it.
|
||||||
Executing it is a *sequence* of coordinated moves, and where that sequence lives
|
Executing it is a *sequence* of coordinated moves, and where that sequence lives
|
||||||
@@ -10,9 +10,10 @@ matters:
|
|||||||
:class:`~orisys_autocal.motion.poller.MotionPoller` is built, and it is why a stop
|
:class:`~orisys_autocal.motion.poller.MotionPoller` is built, and it is why a stop
|
||||||
is possible at all: a mover that sat in a loop inside one command could not be
|
is possible at all: a mover that sat in a loop inside one command could not be
|
||||||
interrupted, because the stop would have to wait for the loop it is meant to abort.
|
interrupted, because the stop would have to wait for the loop it is meant to abort.
|
||||||
* **This module** owns the order and the waiting. It watches the snapshots the poller
|
* **This module** owns the waiting. An execute plan starts yaw, pitch and table X/Y
|
||||||
already produces, issues the next axis when the current one has arrived, and gives
|
in one command so they move together; a return plan still retracts one axis at a
|
||||||
up on its own if a step stops making progress.
|
time. It watches the snapshots the poller already produces and gives up on its
|
||||||
|
own if an axis stops short of its target.
|
||||||
|
|
||||||
That shape is the whole reason for the class: ``stop`` is submitted as an ordinary
|
That shape is the whole reason for the class: ``stop`` is submitted as an ordinary
|
||||||
command, so it runs at the next tick and stops the axis that is moving *now*.
|
command, so it runs at the next tick and stops the axis that is moving *now*.
|
||||||
@@ -27,7 +28,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, Signal
|
from PySide6.QtCore import QObject, Signal
|
||||||
|
|
||||||
@@ -80,6 +81,9 @@ class MovePlan:
|
|||||||
#: Roles deliberately left alone. Reported, because "z did not move" is
|
#: Roles deliberately left alone. Reported, because "z did not move" is
|
||||||
#: information the operator needs, not an omission.
|
#: information the operator needs, not an omission.
|
||||||
held: tuple[str, ...] = ()
|
held: tuple[str, ...] = ()
|
||||||
|
#: When set, every step is commanded in one call and they travel together.
|
||||||
|
#: Return-to-zero leaves this off so the press axis clears before anything turns.
|
||||||
|
together: bool = False
|
||||||
|
|
||||||
def describe(self) -> str:
|
def describe(self) -> str:
|
||||||
parts = [step.label() for step in self.steps]
|
parts = [step.label() for step in self.steps]
|
||||||
@@ -100,6 +104,7 @@ class MoveState:
|
|||||||
failure: str = ""
|
failure: str = ""
|
||||||
cancelled: bool = False
|
cancelled: bool = False
|
||||||
_idle_since: float | None = None
|
_idle_since: float | None = None
|
||||||
|
_group_idle: dict = field(default_factory=dict)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def step(self) -> MoveStep | None:
|
def step(self) -> MoveStep | None:
|
||||||
@@ -119,6 +124,7 @@ def plan_pose_move(
|
|||||||
title: str = "",
|
title: str = "",
|
||||||
hold: Sequence[str] = (),
|
hold: Sequence[str] = (),
|
||||||
order: Sequence[str] = MOVE_ORDER,
|
order: Sequence[str] = MOVE_ORDER,
|
||||||
|
together: bool = False,
|
||||||
) -> MovePlan:
|
) -> MovePlan:
|
||||||
"""Turn per-role targets into a stepped plan, in ``order``.
|
"""Turn per-role targets into a stepped plan, in ``order``.
|
||||||
|
|
||||||
@@ -143,7 +149,7 @@ def plan_pose_move(
|
|||||||
unit="mm" if role in LINEAR_ROLES else "°",
|
unit="mm" if role in LINEAR_ROLES else "°",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return MovePlan(steps=tuple(steps), title=title, held=tuple(hold))
|
return MovePlan(steps=tuple(steps), title=title, held=tuple(hold), together=together)
|
||||||
|
|
||||||
|
|
||||||
def plan_return_to_initial(service: MotionService) -> MovePlan:
|
def plan_return_to_initial(service: MotionService) -> MovePlan:
|
||||||
@@ -220,6 +226,10 @@ class PoseMover(QObject):
|
|||||||
self.cancelled.emit()
|
self.cancelled.emit()
|
||||||
|
|
||||||
def _issue_current(self) -> None:
|
def _issue_current(self) -> None:
|
||||||
|
plan = self.state.plan
|
||||||
|
if plan is not None and plan.together:
|
||||||
|
self._issue_together(plan)
|
||||||
|
return
|
||||||
step = self.state.step
|
step = self.state.step
|
||||||
if step is None:
|
if step is None:
|
||||||
self._complete()
|
self._complete()
|
||||||
@@ -231,6 +241,19 @@ class PoseMover(QObject):
|
|||||||
self._submit(lambda service, step=step: service.move_abs(step.axis, step.units))
|
self._submit(lambda service, step=step: service.move_abs(step.axis, step.units))
|
||||||
self.stepStarted.emit(step)
|
self.stepStarted.emit(step)
|
||||||
|
|
||||||
|
def _issue_together(self, plan: MovePlan) -> None:
|
||||||
|
"""Command every step in one worker call. Z is absent from the plan."""
|
||||||
|
moves: list[tuple[int, float]] = []
|
||||||
|
for step in plan.steps:
|
||||||
|
if not step.units == step.units:
|
||||||
|
self._fail(f"{step.role} 无法换算成用户单位 / cannot convert {step.role}")
|
||||||
|
return
|
||||||
|
moves.append((step.axis, step.units))
|
||||||
|
self.state._group_idle = {}
|
||||||
|
self._submit(lambda service, moves=tuple(moves): service.move_abs_many(moves))
|
||||||
|
if plan.steps:
|
||||||
|
self.stepStarted.emit(plan.steps[0])
|
||||||
|
|
||||||
def _complete(self) -> None:
|
def _complete(self) -> None:
|
||||||
self.state.finished = True
|
self.state.finished = True
|
||||||
plan = self.state.plan
|
plan = self.state.plan
|
||||||
@@ -248,6 +271,10 @@ class PoseMover(QObject):
|
|||||||
"""Advance the plan from a poll. Called on every refresh."""
|
"""Advance the plan from a poll. Called on every refresh."""
|
||||||
if not self.running:
|
if not self.running:
|
||||||
return
|
return
|
||||||
|
plan = self.state.plan
|
||||||
|
if plan is not None and plan.together:
|
||||||
|
self._on_snapshot_together(snapshot)
|
||||||
|
return
|
||||||
step = self.state.step
|
step = self.state.step
|
||||||
if step is None:
|
if step is None:
|
||||||
self._complete()
|
self._complete()
|
||||||
@@ -289,12 +316,58 @@ class PoseMover(QObject):
|
|||||||
f"{step.target:.4f}{step.unit} / stopped short of target"
|
f"{step.target:.4f}{step.unit} / stopped short of target"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _on_snapshot_together(self, snapshot: Snapshot) -> None:
|
||||||
|
"""Finish when every commanded axis has arrived. Z is not in the plan."""
|
||||||
|
plan = self.state.plan
|
||||||
|
if plan is None:
|
||||||
|
return
|
||||||
|
now = time.monotonic()
|
||||||
|
pending = False
|
||||||
|
for step in plan.steps:
|
||||||
|
state = snapshot.axis(step.axis)
|
||||||
|
if state is None:
|
||||||
|
return
|
||||||
|
if state.error or not state.read_ok:
|
||||||
|
self._fail(f"{step.role}: {state.error or 'read failed'}")
|
||||||
|
return
|
||||||
|
if state.status.alarm:
|
||||||
|
self._fail(f"{step.role} 报警 / alarm on {step.role}")
|
||||||
|
return
|
||||||
|
if step.unit == "mm":
|
||||||
|
travelled = self._service.to_mm(step.axis, state.dpos)
|
||||||
|
else:
|
||||||
|
travelled = user_units_to_deg(step.axis, state.dpos)
|
||||||
|
tolerance = SETTLE_MM if step.unit == "mm" else SETTLE_DEG
|
||||||
|
arrived = state.idle and abs(travelled - step.target) <= tolerance
|
||||||
|
if arrived:
|
||||||
|
self.state._group_idle.pop(step.axis, None)
|
||||||
|
continue
|
||||||
|
pending = True
|
||||||
|
if not state.idle:
|
||||||
|
self.state._group_idle.pop(step.axis, None)
|
||||||
|
continue
|
||||||
|
since = self.state._group_idle.get(step.axis)
|
||||||
|
if since is None:
|
||||||
|
self.state._group_idle[step.axis] = now
|
||||||
|
elif now - since > STALL_GRACE_S:
|
||||||
|
self._fail(
|
||||||
|
f"{step.role} 停在 {travelled:.4f}{step.unit},目标 "
|
||||||
|
f"{step.target:.4f}{step.unit} / stopped short of target"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not pending:
|
||||||
|
self._complete()
|
||||||
|
|
||||||
def progress_text(self) -> str:
|
def progress_text(self) -> str:
|
||||||
"""A one-line status for the panel."""
|
"""A one-line status for the panel."""
|
||||||
if self.state.failure:
|
if self.state.failure:
|
||||||
return f"✗ {self.state.failure}"
|
return f"✗ {self.state.failure}"
|
||||||
if self.state.finished:
|
if self.state.finished:
|
||||||
return "✓ 已完成 / done"
|
return "✓ 已完成 / done"
|
||||||
|
plan = self.state.plan
|
||||||
|
if plan is not None and plan.together:
|
||||||
|
roles = " ".join(step.role for step in plan.steps)
|
||||||
|
return f"同时 {roles}"
|
||||||
step = self.state.step
|
step = self.state.step
|
||||||
if step is None:
|
if step is None:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import logging
|
|||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable, Iterable, Mapping
|
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||||
|
|
||||||
from orisys_app_shared.zmotion import ZAuxError
|
from orisys_app_shared.zmotion import ZAuxError
|
||||||
|
|
||||||
@@ -399,12 +399,20 @@ class MotionService:
|
|||||||
return MotionResult.failed(code, message)
|
return MotionResult.failed(code, message)
|
||||||
|
|
||||||
self._spec = spec
|
self._spec = spec
|
||||||
logger.info("Connected: %s via %s", spec.describe(), self._transport.source)
|
|
||||||
# A controller may already have a BAS program running from a previous
|
# A controller may already have a BAS program running from a previous
|
||||||
# session; adopt it rather than claiming the bus is uninitialised. The
|
# session; adopt it rather than claiming the bus is uninitialised. The
|
||||||
# adopted state is *not* marked verified: its globals are a latch, and
|
# adopted state is *not* marked verified: its globals are a latch, and
|
||||||
# nothing in this session has yet seen a scan happen.
|
# nothing in this session has yet seen a scan happen.
|
||||||
|
# The remembered file and the preference-file axis parameters stay on
|
||||||
|
# the PC. Writing either here would replace configuration set from the
|
||||||
|
# vendor software, so connect only reads.
|
||||||
self._adopt_existing_bas(bas_path=bas_path)
|
self._adopt_existing_bas(bas_path=bas_path)
|
||||||
|
logger.info(
|
||||||
|
"Connected: %s via %s. BAS was not downloaded and axis parameters were not written.",
|
||||||
|
spec.describe(),
|
||||||
|
self._transport.source,
|
||||||
|
)
|
||||||
|
self._log_axis_readback("connect")
|
||||||
return MotionResult.success(f"已连接 {spec.describe()}" if self._is_zh else f"Connected {spec.describe()}")
|
return MotionResult.success(f"已连接 {spec.describe()}" if self._is_zh else f"Connected {spec.describe()}")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -748,7 +756,17 @@ class MotionService:
|
|||||||
code = getattr(exc, "code", 0)
|
code = getattr(exc, "code", 0)
|
||||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
||||||
self._params[axis] = params
|
self._params[axis] = params
|
||||||
return MotionResult.success()
|
result = MotionResult.success()
|
||||||
|
logger.info(
|
||||||
|
"Wrote units/speed/accel on axis %s (units=%s speed=%s accel=%s); "
|
||||||
|
"drive profile and following-error limit were not written. %s",
|
||||||
|
axis,
|
||||||
|
params.units,
|
||||||
|
params.speed,
|
||||||
|
params.accel,
|
||||||
|
self._position_text(axis),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
def set_axis_enable(self, axis: int, enabled: bool) -> MotionResult:
|
def set_axis_enable(self, axis: int, enabled: bool) -> MotionResult:
|
||||||
if not self.connected:
|
if not self.connected:
|
||||||
@@ -759,10 +777,18 @@ class MotionService:
|
|||||||
self._transport.set_axis_enable(axis, enabled)
|
self._transport.set_axis_enable(axis, enabled)
|
||||||
except (ZAuxError, OSError) as exc:
|
except (ZAuxError, OSError) as exc:
|
||||||
code = getattr(exc, "code", 0)
|
code = getattr(exc, "code", 0)
|
||||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
return self._logged(
|
||||||
return MotionResult.success(
|
"enable",
|
||||||
|
axis,
|
||||||
|
MotionResult.failed(code, self._msg(code) if code else str(exc)),
|
||||||
|
)
|
||||||
|
return self._logged(
|
||||||
|
"enable",
|
||||||
|
axis,
|
||||||
|
MotionResult.success(
|
||||||
self._text(f"轴{axis} 已{'使能' if enabled else '关闭'}",
|
self._text(f"轴{axis} 已{'使能' if enabled else '关闭'}",
|
||||||
f"Axis {axis} {'enabled' if enabled else 'disabled'}")
|
f"Axis {axis} {'enabled' if enabled else 'disabled'}")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def clear_alarm(self, axis: int) -> MotionResult:
|
def clear_alarm(self, axis: int) -> MotionResult:
|
||||||
@@ -774,8 +800,16 @@ class MotionService:
|
|||||||
self._transport.bus_drive_clear(axis, 0)
|
self._transport.bus_drive_clear(axis, 0)
|
||||||
except (ZAuxError, OSError) as exc:
|
except (ZAuxError, OSError) as exc:
|
||||||
code = getattr(exc, "code", 0)
|
code = getattr(exc, "code", 0)
|
||||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
return self._logged(
|
||||||
return MotionResult.success(self._text("已清除报警", "Alarm cleared"))
|
"clear_alarm",
|
||||||
|
axis,
|
||||||
|
MotionResult.failed(code, self._msg(code) if code else str(exc)),
|
||||||
|
)
|
||||||
|
return self._logged(
|
||||||
|
"clear_alarm",
|
||||||
|
axis,
|
||||||
|
MotionResult.success(self._text("已清除报警", "Alarm cleared")),
|
||||||
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ motion
|
# ------------------------------------------------------------------ motion
|
||||||
|
|
||||||
@@ -797,15 +831,59 @@ class MotionService:
|
|||||||
def jog(self, axis: int, forward: bool) -> MotionResult:
|
def jog(self, axis: int, forward: bool) -> MotionResult:
|
||||||
refusal = self._motion_guard(axis)
|
refusal = self._motion_guard(axis)
|
||||||
if refusal is not None:
|
if refusal is not None:
|
||||||
return refusal
|
return self._logged("jog", axis, refusal)
|
||||||
try:
|
try:
|
||||||
self._transport.jog(axis, forward)
|
self._transport.jog(axis, forward)
|
||||||
except (ZAuxError, OSError) as exc:
|
except (ZAuxError, OSError) as exc:
|
||||||
code = getattr(exc, "code", 0)
|
code = getattr(exc, "code", 0)
|
||||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
return self._logged(
|
||||||
return MotionResult.success(
|
"jog",
|
||||||
self._text("正转" if forward else "反转", "Forward" if forward else "Reverse")
|
axis,
|
||||||
|
MotionResult.failed(code, self._msg(code) if code else str(exc)),
|
||||||
)
|
)
|
||||||
|
return self._logged(
|
||||||
|
"jog",
|
||||||
|
axis,
|
||||||
|
MotionResult.success(
|
||||||
|
self._text("正转" if forward else "反转", "Forward" if forward else "Reverse")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def move_abs_many(self, moves: Sequence[tuple[int, float]]) -> MotionResult:
|
||||||
|
"""Absolute-move several axes in one call so they travel together.
|
||||||
|
|
||||||
|
Guards are checked for every axis before the first command is sent, so a
|
||||||
|
refusal does not leave the earlier axes already moving. Each move still
|
||||||
|
uses the same absolute-move call as a single axis.
|
||||||
|
"""
|
||||||
|
pending = [(int(axis), float(value)) for axis, value in moves]
|
||||||
|
if not pending:
|
||||||
|
return MotionResult.refused(self._text("没有要移动的轴", "Nothing to move"))
|
||||||
|
for axis, value in pending:
|
||||||
|
refusal = self._motion_guard(axis)
|
||||||
|
if refusal is not None:
|
||||||
|
return self._logged("move_abs", axis, refusal)
|
||||||
|
if not math.isfinite(value):
|
||||||
|
return self._logged(
|
||||||
|
"move_abs",
|
||||||
|
axis,
|
||||||
|
MotionResult.refused(
|
||||||
|
self._text("目标位置请输入数字", "Enter a numeric position")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
last = MotionResult.success()
|
||||||
|
for axis, value in pending:
|
||||||
|
try:
|
||||||
|
self._transport.move_abs(axis, value)
|
||||||
|
except (ZAuxError, OSError) as exc:
|
||||||
|
code = getattr(exc, "code", 0)
|
||||||
|
return self._logged(
|
||||||
|
"move_abs",
|
||||||
|
axis,
|
||||||
|
MotionResult.failed(code, self._msg(code) if code else str(exc)),
|
||||||
|
)
|
||||||
|
last = self._logged("move_abs", axis, MotionResult.success())
|
||||||
|
return last
|
||||||
|
|
||||||
def move_abs(self, axis: int, target: float) -> MotionResult:
|
def move_abs(self, axis: int, target: float) -> MotionResult:
|
||||||
return self._move(axis, target, absolute=True)
|
return self._move(axis, target, absolute=True)
|
||||||
@@ -814,12 +892,17 @@ class MotionService:
|
|||||||
return self._move(axis, distance, absolute=False)
|
return self._move(axis, distance, absolute=False)
|
||||||
|
|
||||||
def _move(self, axis: int, value: float, *, absolute: bool) -> MotionResult:
|
def _move(self, axis: int, value: float, *, absolute: bool) -> MotionResult:
|
||||||
|
action = "move_abs" if absolute else "move_rel"
|
||||||
refusal = self._motion_guard(axis)
|
refusal = self._motion_guard(axis)
|
||||||
if refusal is not None:
|
if refusal is not None:
|
||||||
return refusal
|
return self._logged(action, axis, refusal)
|
||||||
if not isinstance(value, (int, float)) or not math.isfinite(float(value)):
|
if not isinstance(value, (int, float)) or not math.isfinite(float(value)):
|
||||||
return MotionResult.refused(
|
return self._logged(
|
||||||
|
action,
|
||||||
|
axis,
|
||||||
|
MotionResult.refused(
|
||||||
self._text("目标位置请输入数字", "Enter a numeric position")
|
self._text("目标位置请输入数字", "Enter a numeric position")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
if absolute:
|
if absolute:
|
||||||
@@ -828,8 +911,12 @@ class MotionService:
|
|||||||
self._transport.move_rel(axis, float(value))
|
self._transport.move_rel(axis, float(value))
|
||||||
except (ZAuxError, OSError) as exc:
|
except (ZAuxError, OSError) as exc:
|
||||||
code = getattr(exc, "code", 0)
|
code = getattr(exc, "code", 0)
|
||||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
return self._logged(
|
||||||
return MotionResult.success()
|
action,
|
||||||
|
axis,
|
||||||
|
MotionResult.failed(code, self._msg(code) if code else str(exc)),
|
||||||
|
)
|
||||||
|
return self._logged(action, axis, MotionResult.success())
|
||||||
|
|
||||||
def stop(self, axis: int) -> MotionResult:
|
def stop(self, axis: int) -> MotionResult:
|
||||||
"""Stop one axis (decelerating stop, mode 2)."""
|
"""Stop one axis (decelerating stop, mode 2)."""
|
||||||
@@ -841,8 +928,16 @@ class MotionService:
|
|||||||
self._transport.stop(axis, 2)
|
self._transport.stop(axis, 2)
|
||||||
except (ZAuxError, OSError) as exc:
|
except (ZAuxError, OSError) as exc:
|
||||||
code = getattr(exc, "code", 0)
|
code = getattr(exc, "code", 0)
|
||||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
return self._logged(
|
||||||
return MotionResult.success(self._text("已停止", "Stopped"))
|
"stop",
|
||||||
|
axis,
|
||||||
|
MotionResult.failed(code, self._msg(code) if code else str(exc)),
|
||||||
|
)
|
||||||
|
return self._logged(
|
||||||
|
"stop",
|
||||||
|
axis,
|
||||||
|
MotionResult.success(self._text("已停止", "Stopped")),
|
||||||
|
)
|
||||||
|
|
||||||
def stop_all(self) -> list[MotionResult]:
|
def stop_all(self) -> list[MotionResult]:
|
||||||
"""Stop every axis. Used before disconnecting."""
|
"""Stop every axis. Used before disconnecting."""
|
||||||
@@ -882,6 +977,46 @@ class MotionService:
|
|||||||
self._home_done[axis] = False
|
self._home_done[axis] = False
|
||||||
return MotionResult.success(self._text("回零已启动", "Homing started"))
|
return MotionResult.success(self._text("回零已启动", "Homing started"))
|
||||||
|
|
||||||
|
def _log_axis_readback(self, action: str) -> None:
|
||||||
|
"""Read every axis and log it. Does not write parameters or start motion."""
|
||||||
|
for axis in range(self.axis_count):
|
||||||
|
self._log_command(action, axis, MotionResult.success("read"))
|
||||||
|
|
||||||
|
def _logged(self, action: str, axis: int, result: MotionResult) -> MotionResult:
|
||||||
|
self._log_command(action, axis, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _log_command(self, action: str, axis: int, result: MotionResult) -> None:
|
||||||
|
text = (
|
||||||
|
f"{action} axis {axis} -> ok={result.ok} code={result.code} "
|
||||||
|
f"message={result.message!r} {self._position_text(axis)}"
|
||||||
|
)
|
||||||
|
if result.ok:
|
||||||
|
logger.info(text)
|
||||||
|
else:
|
||||||
|
logger.warning(text)
|
||||||
|
|
||||||
|
def _position_text(self, axis: int) -> str:
|
||||||
|
"""Commanded and measured position, for the command log.
|
||||||
|
|
||||||
|
Reads only. A failed read is part of the line rather than a second error,
|
||||||
|
because the command result itself is what the caller is waiting on.
|
||||||
|
"""
|
||||||
|
if not self.connected:
|
||||||
|
return "dpos=unread mpos=unread status=unread enable=unread"
|
||||||
|
try:
|
||||||
|
return (
|
||||||
|
f"dpos={self._transport.get_dpos(axis)} "
|
||||||
|
f"mpos={self._transport.get_mpos(axis)} "
|
||||||
|
f"status={self._transport.get_axis_status(axis)} "
|
||||||
|
f"enable={self._transport.get_axis_enable(axis)} "
|
||||||
|
f"atype={self._transport.get_atype(axis)} "
|
||||||
|
f"units={self._transport.get_units(axis)} "
|
||||||
|
f"speed={self._transport.get_speed(axis)}"
|
||||||
|
)
|
||||||
|
except (ZAuxError, OSError) as exc:
|
||||||
|
return f"dpos=unread mpos=unread ({exc})"
|
||||||
|
|
||||||
def _known_axis(self, axis: int) -> bool:
|
def _known_axis(self, axis: int) -> bool:
|
||||||
return 0 <= int(axis) < self.axis_count
|
return 0 <= int(axis) < self.axis_count
|
||||||
|
|
||||||
|
|||||||
@@ -372,7 +372,20 @@ class AxisPanel(QGroupBox):
|
|||||||
self._recolour_rows()
|
self._recolour_rows()
|
||||||
|
|
||||||
def _axis_present(self, row: int) -> bool:
|
def _axis_present(self, row: int) -> bool:
|
||||||
return not self._available_axes or row < self._available_axes
|
"""Rows the bus counted, plus any axis the controller has already mapped.
|
||||||
|
|
||||||
|
``Bus_TotalAxisnum`` is a global left by the last scan. It can stay
|
||||||
|
short of the real axis list after a drive is brought back without
|
||||||
|
another scan, which disabled axis 4 while that same controller axis
|
||||||
|
still answered and could be moved from the vendor software. A non-zero
|
||||||
|
axis type is that answer. Axes 0-3 already pass the count check; axis
|
||||||
|
4 passes this one once it is mapped. An unmapped axis (type 0) stays
|
||||||
|
disabled, and none of this writes parameters or starts motion.
|
||||||
|
"""
|
||||||
|
if not self._available_axes or row < self._available_axes:
|
||||||
|
return True
|
||||||
|
state = self._snapshot.axis(row)
|
||||||
|
return state is not None and state.read_ok and state.atype != 0
|
||||||
|
|
||||||
def _refresh_availability(self) -> None:
|
def _refresh_availability(self) -> None:
|
||||||
"""Recompute every control's enabled state from connection + bus."""
|
"""Recompute every control's enabled state from connection + bus."""
|
||||||
|
|||||||
@@ -1013,10 +1013,12 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
self._pending[token] = ""
|
self._pending[token] = ""
|
||||||
|
|
||||||
def _on_jog(self, axis: int, forward: bool) -> None:
|
def _on_jog(self, axis: int, forward: bool) -> None:
|
||||||
self.poller.submit(lambda service: service.jog(axis, forward))
|
token = self.poller.submit(lambda service: service.jog(axis, forward))
|
||||||
|
self._pending[token] = ""
|
||||||
|
|
||||||
def _on_stop(self, axis: int) -> None:
|
def _on_stop(self, axis: int) -> None:
|
||||||
self.poller.submit(lambda service: service.stop(axis))
|
token = self.poller.submit(lambda service: service.stop(axis))
|
||||||
|
self._pending[token] = ""
|
||||||
|
|
||||||
def _on_stop_all(self) -> None:
|
def _on_stop_all(self) -> None:
|
||||||
def _stop_all(service: MotionService) -> object:
|
def _stop_all(service: MotionService) -> object:
|
||||||
@@ -1043,10 +1045,10 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
def _on_execute_pose(self) -> None:
|
def _on_execute_pose(self) -> None:
|
||||||
"""Move the platform to the solved pose of the finger screen's selection.
|
"""Move the platform to the solved pose of the finger screen's selection.
|
||||||
|
|
||||||
Stages and table only: the press axis is deliberately not commanded, so this
|
Yaw, pitch and table X/Y start together. The press axis is deliberately
|
||||||
is workflow P6's dry run for one point — it cannot reach the sensor. The
|
not commanded, so this is workflow P6's dry run for one point — it cannot
|
||||||
numbers shown in the confirmation are the ones the preview drew, so the
|
reach the sensor. The numbers shown in the confirmation are the ones the
|
||||||
operator checks the simulation against the machine, not a summary of it.
|
preview drew, so the operator checks the simulation against the machine.
|
||||||
"""
|
"""
|
||||||
solve = self.finger_panel.solved_pose()
|
solve = self.finger_panel.solved_pose()
|
||||||
if solve is None:
|
if solve is None:
|
||||||
@@ -1058,6 +1060,7 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
targets,
|
targets,
|
||||||
title=t("move.execute"),
|
title=t("move.execute"),
|
||||||
hold=held,
|
hold=held,
|
||||||
|
together=True,
|
||||||
)
|
)
|
||||||
self._start_move(plan, t("move.execute"))
|
self._start_move(plan, t("move.execute"))
|
||||||
|
|
||||||
@@ -1080,8 +1083,15 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
# cannot be turned off is one that gets clicked through unread.
|
# cannot be turned off is one that gets clicked through unread.
|
||||||
if self._confirm_moves:
|
if self._confirm_moves:
|
||||||
steps = "\n".join(
|
steps = "\n".join(
|
||||||
|
(
|
||||||
|
f" {step.role} → {step.target:+.4f} {step.unit}"
|
||||||
|
f" (驱动器 {step.axis})"
|
||||||
|
if plan.together
|
||||||
|
else (
|
||||||
f" {index + 1}. {step.role} → {step.target:+.4f} {step.unit}"
|
f" {index + 1}. {step.role} → {step.target:+.4f} {step.unit}"
|
||||||
f" (驱动器 {step.axis})"
|
f" (驱动器 {step.axis})"
|
||||||
|
)
|
||||||
|
)
|
||||||
for index, step in enumerate(plan.steps)
|
for index, step in enumerate(plan.steps)
|
||||||
)
|
)
|
||||||
if plan.held:
|
if plan.held:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ No test here touches a controller or a DLL.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ctypes
|
import ctypes
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ from orisys_autocal.motion import (
|
|||||||
DEFAULT_BAS_TASK,
|
DEFAULT_BAS_TASK,
|
||||||
INIT_OK,
|
INIT_OK,
|
||||||
INIT_RUNNING,
|
INIT_RUNNING,
|
||||||
|
BusStatus,
|
||||||
ConnectionSpec,
|
ConnectionSpec,
|
||||||
DemoTransport,
|
DemoTransport,
|
||||||
MotionService,
|
MotionService,
|
||||||
@@ -224,6 +226,44 @@ def test_adopted_program_without_a_known_file_says_so():
|
|||||||
assert "文件名" in result.message or "file name" in result.message
|
assert "文件名" in result.message or "file name" in result.message
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_reads_axis_4_and_does_not_write_parameters(caplog):
|
||||||
|
"""Connecting must not replace configuration the vendor software just set."""
|
||||||
|
transport = DemoTransport()
|
||||||
|
writes: list[tuple] = []
|
||||||
|
transport.set_units = lambda axis, value: writes.append(("units", axis, value))
|
||||||
|
transport.set_speed = lambda axis, value: writes.append(("speed", axis, value))
|
||||||
|
transport.set_accel = lambda axis, value: writes.append(("accel", axis, value))
|
||||||
|
transport.bas_download = lambda filename, run_mode=1: writes.append(("bas", filename))
|
||||||
|
service = MotionService(transport)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO):
|
||||||
|
result = service.connect(ConnectionSpec(mode="demo"))
|
||||||
|
|
||||||
|
assert result.ok, result.message
|
||||||
|
assert writes == []
|
||||||
|
readback = [record.message for record in caplog.records if record.message.startswith("connect axis 4")]
|
||||||
|
assert readback, caplog.text
|
||||||
|
assert "mpos=" in readback[-1]
|
||||||
|
assert "not written" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_axis_4_jog_reuses_the_shared_motion_call_and_logs_position(caplog):
|
||||||
|
service = MotionService(DemoTransport())
|
||||||
|
assert service.connect(ConnectionSpec(mode="demo")).ok
|
||||||
|
service._bus = BusStatus(bus_type="ecat", init_status=INIT_OK, total_axes=4) # noqa: SLF001
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO):
|
||||||
|
result = service.jog(4, True)
|
||||||
|
|
||||||
|
assert result.ok, result.message
|
||||||
|
assert result.code == 0
|
||||||
|
logged = [record.message for record in caplog.records if record.message.startswith("jog axis 4")]
|
||||||
|
assert logged, caplog.text
|
||||||
|
assert "ok=True" in logged[-1]
|
||||||
|
assert "code=0" in logged[-1]
|
||||||
|
assert "mpos=" in logged[-1]
|
||||||
|
|
||||||
|
|
||||||
def test_reinit_needs_the_bus_not_to_be_mid_run():
|
def test_reinit_needs_the_bus_not_to_be_mid_run():
|
||||||
service = MotionService(DemoTransport())
|
service = MotionService(DemoTransport())
|
||||||
service.connect(ConnectionSpec(mode="demo"))
|
service.connect(ConnectionSpec(mode="demo"))
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
from orisys_autocal.motion import BusStatus
|
from orisys_autocal.motion import AxisState, BusStatus, Snapshot
|
||||||
from orisys_autocal.motion.pose_move import plan_pose_move
|
from orisys_autocal.motion.pose_move import plan_pose_move
|
||||||
|
|
||||||
|
|
||||||
@@ -37,6 +37,44 @@ def _connected(make_window, *, confirm: bool = False):
|
|||||||
return window
|
return window
|
||||||
|
|
||||||
|
|
||||||
|
def test_axis_4_controls_stay_available_when_the_bus_count_is_short(make_window):
|
||||||
|
"""A mapped axis 4 uses the same buttons as axes 0-3.
|
||||||
|
|
||||||
|
The bus-count global can still say 4 after the drive is working again.
|
||||||
|
Type 0 stays disabled, so an axis the controller has not mapped is not
|
||||||
|
given a button.
|
||||||
|
"""
|
||||||
|
window = make_window()
|
||||||
|
try:
|
||||||
|
mapped = tuple(AxisState(axis=index, atype=65, read_ok=True) for index in range(5))
|
||||||
|
window.axis_panel.update_snapshot(
|
||||||
|
Snapshot(
|
||||||
|
connected=True,
|
||||||
|
bus=BusStatus(bus_type="ecat", init_status=1, total_axes=4),
|
||||||
|
axes=mapped,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = window.axis_panel._rows[4] # noqa: SLF001
|
||||||
|
assert row["enable_button"].isEnabled()
|
||||||
|
assert row["fwd_button"].isEnabled()
|
||||||
|
assert row["rev_button"].isEnabled()
|
||||||
|
assert row["stop_button"].isEnabled()
|
||||||
|
|
||||||
|
unmapped = mapped[:-1] + (AxisState(axis=4, atype=0, read_ok=True),)
|
||||||
|
window.axis_panel.update_snapshot(
|
||||||
|
Snapshot(
|
||||||
|
connected=True,
|
||||||
|
bus=BusStatus(bus_type="ecat", init_status=1, total_axes=4),
|
||||||
|
axes=unmapped,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = window.axis_panel._rows[4] # noqa: SLF001
|
||||||
|
assert not row["fwd_button"].isEnabled()
|
||||||
|
assert row["stop_button"].isEnabled() is False
|
||||||
|
finally:
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
def test_execute_lives_with_the_point_it_executes(make_window):
|
def test_execute_lives_with_the_point_it_executes(make_window):
|
||||||
window = make_window()
|
window = make_window()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -216,6 +216,63 @@ def test_the_guard_names_an_unknown_direction():
|
|||||||
# ------------------------------------------------------------------------ mover
|
# ------------------------------------------------------------------------ mover
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_execute_plan_moves_the_four_axes_together_and_leaves_z():
|
||||||
|
"""Calibration execute starts yaw, pitch and X/Y in one command. Z stays put."""
|
||||||
|
service = make_service()
|
||||||
|
called: list[list[tuple[int, float]]] = []
|
||||||
|
|
||||||
|
def record(moves):
|
||||||
|
called.append(list(moves))
|
||||||
|
return MotionResult.success()
|
||||||
|
|
||||||
|
service.move_abs_many = record # type: ignore[method-assign]
|
||||||
|
poller = FakePoller(service)
|
||||||
|
mover = PoseMover(service, poller.submit)
|
||||||
|
plan = plan_pose_move(
|
||||||
|
service,
|
||||||
|
{"x": 2.0, "y": 1.0, "yaw": 10.0, "pitch": -5.0},
|
||||||
|
hold=("z",),
|
||||||
|
together=True,
|
||||||
|
)
|
||||||
|
assert plan.together
|
||||||
|
assert [step.role for step in plan.steps] == ["yaw", "pitch", "x", "y"]
|
||||||
|
assert mover.start(plan).ok
|
||||||
|
assert len(poller.commands) == 1
|
||||||
|
poller.commands[0](service)
|
||||||
|
assert [axis for axis, _value in called[0]] == [3, 2, 0, 1]
|
||||||
|
|
||||||
|
mover.on_snapshot(
|
||||||
|
snapshot_of(service, {0: 1000.0, 1: 0.0, 2: 0.0, 3: 0.0}, idle=False)
|
||||||
|
)
|
||||||
|
assert mover.running
|
||||||
|
assert mover.progress_text().startswith("同时")
|
||||||
|
assert len(poller.commands) == 1, "a later axis was queued instead of moving with the first"
|
||||||
|
|
||||||
|
mover.on_snapshot(
|
||||||
|
snapshot_of(
|
||||||
|
service,
|
||||||
|
{
|
||||||
|
0: 1000.0,
|
||||||
|
1: 500.0,
|
||||||
|
2: -5.0 * 12500.0 / 90.0,
|
||||||
|
3: 10.0 * 29500.0 / 90.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert mover.state.finished
|
||||||
|
|
||||||
|
|
||||||
|
def test_return_to_zero_stays_one_axis_at_a_time():
|
||||||
|
service = make_service()
|
||||||
|
poller = FakePoller(service)
|
||||||
|
mover = PoseMover(service, poller.submit)
|
||||||
|
plan = plan_return_to_initial(service)
|
||||||
|
assert not plan.together
|
||||||
|
assert mover.start(plan).ok
|
||||||
|
assert len(poller.commands) == 1
|
||||||
|
assert mover.state.step.role == "z"
|
||||||
|
|
||||||
|
|
||||||
def test_the_mover_commands_one_axis_and_waits_for_it_to_arrive():
|
def test_the_mover_commands_one_axis_and_waits_for_it_to_arrive():
|
||||||
service = make_service()
|
service = make_service()
|
||||||
poller = FakePoller(service)
|
poller = FakePoller(service)
|
||||||
|
|||||||
Reference in New Issue
Block a user