added ruff test
This commit is contained in:
@@ -112,6 +112,17 @@ calls them:
|
||||
|
||||
- **Motion is refused** while the bus is not initialised, while the axis is
|
||||
unknown, or on a non-finite target — with a reason, never a silent no-op.
|
||||
- **A pose move is refused before the first axis moves** if any part of it is wrong:
|
||||
a target past the P0 limit — millimetres for x/y/z, **degrees** for yaw/pitch, so a
|
||||
400° stage turn cannot pass a 100 mm check — an axis mapping that is incomplete,
|
||||
points outside the drive range, or claims one drive twice, or a rotary role on an
|
||||
axis with no user-units-per-degree calibration. A sequence that stopped halfway
|
||||
would leave the machine in a pose nobody simulated, so every check runs first.
|
||||
- **A move is stepped, not looped.** The drives are commanded one at a time from the
|
||||
poll snapshots; a step that stalls (idle but short of its target), times out or
|
||||
raises an alarm aborts the rest. That is also what makes **stop** work — it is an
|
||||
ordinary command, so it runs at the next poll tick, and the queue is cancelled
|
||||
before it is sent so no later axis is commanded.
|
||||
- **Homing needs a configured mode.** The vendor example pre-fills `17`, which
|
||||
is one specific drive's mode; AutoCal starts at `0` and refuses, so the
|
||||
operator chooses for the drive in front of them instead of inheriting a number
|
||||
|
||||
@@ -34,9 +34,39 @@
|
||||
"hint": "Connecting and bringing the bus up are checked once per machine start-up, so they live here. The status strip keeps reporting the result, and this window can stay open while you work.",
|
||||
"tab_connection": "Connection",
|
||||
"tab_bus": "Bus initialisation",
|
||||
"tab_axes": "Axis mapping",
|
||||
"axes_hint": "The bus reports axis numbers, not what each one moves. The mapping below comes from the bench wiring, and it must be complete and free of duplicates before anything moves.",
|
||||
"role_x": "X (table)",
|
||||
"role_y": "Y (table)",
|
||||
"role_z": "Z (press)",
|
||||
"role_yaw": "yaw (rotation)",
|
||||
"role_pitch": "pitch (tilt)",
|
||||
"role_axis": "Drive",
|
||||
"axes_duplicate": "One drive is claimed by more than one direction: {detail}",
|
||||
"close": "Close",
|
||||
"chip_hint": "Open device setup — {name}"
|
||||
},
|
||||
"move": {
|
||||
"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.",
|
||||
"stop": "Stop",
|
||||
"stop_hint": "Stop every axis now (the same command as 全部停止 in the strip).",
|
||||
"return_initial": "Return to zero",
|
||||
"return_hint": "Every axis back to the initial state, press axis Z first.",
|
||||
"confirm_title": "Confirm motion",
|
||||
"confirm_execute": "Will move, in this order:\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}",
|
||||
"hold_note": "Not moved: {roles} (the press axis is P7's business — contact detection and press depth)",
|
||||
"cancelled": "Cancelled; nothing was commanded",
|
||||
"confirm_check": "Confirm before moving",
|
||||
"confirm_hint": "Unchecked, Execute and Return to zero are sent directly. Stop is never affected.",
|
||||
"no_point": "No calibration point is selected",
|
||||
"busy": "A move is already running",
|
||||
"refused": "Motion refused",
|
||||
"done": "Move finished",
|
||||
"failed": "Move failed",
|
||||
"stopped": "Stopped; the remaining steps were cancelled"
|
||||
},
|
||||
"connection": {
|
||||
"title": "Controller connection",
|
||||
"mode": "Connection",
|
||||
|
||||
@@ -34,9 +34,39 @@
|
||||
"hint": "连接与总线初始化每次开机只需确认一次,因此收进这里;顶栏状态条持续显示结果,设置窗口可以一直开着。",
|
||||
"tab_connection": "连接",
|
||||
"tab_bus": "总线初始化",
|
||||
"tab_axes": "轴映射",
|
||||
"axes_hint": "总线只报告轴号,不报告它推的是哪个方向。这里的对应关系来自实际接线,运动前必须完整且不重复。",
|
||||
"role_x": "X(工作台)",
|
||||
"role_y": "Y(工作台)",
|
||||
"role_z": "Z(压轴)",
|
||||
"role_yaw": "yaw(回转)",
|
||||
"role_pitch": "pitch(俯仰)",
|
||||
"role_axis": "驱动器",
|
||||
"axes_duplicate": "同一驱动器被分配给多个方向:{detail}",
|
||||
"close": "关闭",
|
||||
"chip_hint": "点击打开设备设置 — {name}"
|
||||
},
|
||||
"move": {
|
||||
"execute": "执行动作",
|
||||
"execute_hint": "把选中点解算出的位姿送到平台:先回转、再俯仰,最后工作台 X/Y。压轴 Z 不动作。",
|
||||
"stop": "停止",
|
||||
"stop_hint": "立即停止所有轴(与顶栏「全部停止」相同)。",
|
||||
"return_initial": "回初始位",
|
||||
"return_hint": "五个轴全部回到零点,先退压轴 Z。",
|
||||
"confirm_title": "确认运动",
|
||||
"confirm_execute": "将按以下顺序移动:\n\n{steps}\n\n请确认仿真与机床一致。",
|
||||
"confirm_return": "将把五个轴全部移回零点(先退压轴 Z):\n\n{steps}",
|
||||
"hold_note": "不动作:{roles}(压轴由 P7 的接触检测与压深另外管)",
|
||||
"cancelled": "已取消,未发出任何运动",
|
||||
"confirm_check": "移动前确认",
|
||||
"confirm_hint": "取消勾选后,执行动作与回初始位将直接发出(停止不受影响)。",
|
||||
"no_point": "未选中标定点,无法执行",
|
||||
"busy": "已有运动在执行",
|
||||
"refused": "运动被拒绝",
|
||||
"done": "运动完成",
|
||||
"failed": "运动失败",
|
||||
"stopped": "已停止,后续动作已取消"
|
||||
},
|
||||
"connection": {
|
||||
"title": "连接控制器",
|
||||
"mode": "连接方式",
|
||||
|
||||
@@ -61,6 +61,30 @@ which can put the face on the far side of the body from the default camera. The
|
||||
axis is drawn with depth testing **off** for that reason — it is a reference line,
|
||||
and hiding it behind the body it is meant to align with is the opposite of useful.
|
||||
|
||||
## Running the pose on the machine
|
||||
|
||||
Three buttons, and where each one lives is deliberate:
|
||||
|
||||
| Button | Where | What it does |
|
||||
| --- | --- | --- |
|
||||
| **执行动作** Execute | this screen, next to 播放动作 | Sends the selected point's solved pose to the platform: **yaw, then pitch, then table X/Y**. The press axis **z is not commanded** — pressing is P7, with contact detection and press depth, neither of which exists here, so a move to the contact height would be a press with none of its guards. |
|
||||
| **停止** Stop | here *and* the strip's 全部停止 | The same command. Two access points, one implementation: during a move the operator's eyes are on this pane, and an emergency stop has to be where they are. |
|
||||
| **回初始位** Return to zero | the axis rail, on every tab | Every drive to 0 — press axis first, then the stages, then the table. A machine-level action, wanted after work in any tab, so it is not repeated inside one. |
|
||||
|
||||
**移动前确认** decides whether the moving buttons ask first. The prompt lists the steps
|
||||
with their numbers and the drive each one uses, and says what is *not* being moved.
|
||||
|
||||
The order is the model's own: the kinematics composes `R_pitch · R_yaw`, so yaw acts on
|
||||
the body first and the machine yaws first — every intermediate pose is one the preview
|
||||
drew. Table X/Y last, because the translation is what the rotations are resolved
|
||||
against.
|
||||
|
||||
Each drive is commanded on its own and the next goes out only once the previous has
|
||||
arrived, which is what keeps the move interruptible; a step that stops short, times out
|
||||
or alarms aborts the rest rather than finishing a sequence nobody has verified. What is
|
||||
checked *before* the first axis moves — the mapping, the P0 limits, the rotary
|
||||
calibration — is in `../README.md` under **Safety model**.
|
||||
|
||||
The chain the solver and the drawing share is, in order: **mount** (sensor axes into
|
||||
table axes) → **setup** (the mounting pose above) → **stages** (the solved yaw and
|
||||
pitch, about the table origin) → **table** (the XY slide). A point of the sensor is
|
||||
|
||||
@@ -236,6 +236,17 @@ measured time per point.
|
||||
**Gate:** the operator has seen the path and a dry run has completed without a
|
||||
guard violation.
|
||||
|
||||
**The single-point dry run is built**, in the finger debug screen: 执行动作 sends the
|
||||
selected point's *solved pose* to the platform — yaw, then pitch, then table X/Y — and
|
||||
does **not** command the press axis, so it cannot reach the sensor. The order is the
|
||||
model's own (`R_pitch · R_yaw`), so every intermediate pose is one the operator
|
||||
watched in the preview. Before it moves it checks the mapping, the target against the
|
||||
P0 limits (millimetres for x/y/z, degrees for yaw/pitch) and the rotary calibration —
|
||||
all in `motion/service.py`, so a later caller cannot walk around them — and the drives
|
||||
are commanded one at a time, each waiting for the previous to arrive, with a stall or
|
||||
an alarm aborting the rest. 停止 next to it is the same command as the strip's
|
||||
全部停止; 回初始位 in the rail sends every axis to zero with the press axis first.
|
||||
|
||||
---
|
||||
|
||||
## P7 — Run + acquire
|
||||
|
||||
@@ -66,14 +66,16 @@ def main(argv: list[str] | None = None) -> int:
|
||||
args = _parse_args(list(argv if argv is not None else sys.argv[1:]))
|
||||
_force_utf8_streams()
|
||||
|
||||
from orisys_app_shared import API_VERSION, __version__ as shared_version
|
||||
from orisys_app_shared import API_VERSION
|
||||
from orisys_app_shared import __version__ as shared_version
|
||||
from orisys_app_shared.compat import check_shared_compat
|
||||
from orisys_app_shared.i18n import set_locale
|
||||
from orisys_app_shared.logging import init_logging, install_exception_hooks
|
||||
from orisys_app_shared.theme import build_qss
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from . import REQUIRES_SHARED_API, __version__ as app_version
|
||||
from . import REQUIRES_SHARED_API
|
||||
from . import __version__ as app_version
|
||||
from .motion import ConnectionSpec, DemoTransport
|
||||
from .preferences import load_preferences
|
||||
from .ui.window import AutoCalMainWindow
|
||||
|
||||
@@ -16,7 +16,22 @@ from .models import (
|
||||
Snapshot,
|
||||
)
|
||||
from .poller import MotionPoller
|
||||
from .service import DEFAULT_BAS_TASK, PLATFORM_AXES, MotionService, clamp_bas_task
|
||||
from .pose_move import (
|
||||
MovePlan,
|
||||
MoveStep,
|
||||
PoseMover,
|
||||
plan_pose_move,
|
||||
plan_return_to_initial,
|
||||
)
|
||||
from .service import (
|
||||
AXIS_ROLES,
|
||||
DEFAULT_AXIS_ROLES,
|
||||
DEFAULT_BAS_TASK,
|
||||
PLATFORM_AXES,
|
||||
MotionService,
|
||||
clamp_bas_task,
|
||||
normalize_axis_roles,
|
||||
)
|
||||
from .transport import DemoTransport, NativeTransport, ZmcTransport
|
||||
from .units import (
|
||||
DEFAULT_ACCEL,
|
||||
@@ -33,7 +48,9 @@ from .units import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AXIS_ROLES",
|
||||
"DEFAULT_ACCEL",
|
||||
"DEFAULT_AXIS_ROLES",
|
||||
"DEFAULT_BAS_TASK",
|
||||
"DEFAULT_SPEED",
|
||||
"DEFAULT_UNITS",
|
||||
@@ -55,12 +72,18 @@ __all__ = [
|
||||
"MotionPoller",
|
||||
"MotionResult",
|
||||
"MotionService",
|
||||
"MovePlan",
|
||||
"MoveStep",
|
||||
"NativeTransport",
|
||||
"PoseMover",
|
||||
"Snapshot",
|
||||
"ZmcTransport",
|
||||
"clamp_bas_task",
|
||||
"deg_to_user_units",
|
||||
"mm_to_user_units",
|
||||
"normalize_axis_roles",
|
||||
"plan_pose_move",
|
||||
"plan_return_to_initial",
|
||||
"safe_float",
|
||||
"units_per_degree",
|
||||
"user_units_to_deg",
|
||||
|
||||
@@ -438,7 +438,7 @@ class PlatformStack:
|
||||
|
||||
def alignment_branches(
|
||||
self, unit: np.ndarray, tool: np.ndarray
|
||||
) -> list["Branch"]:
|
||||
) -> list[Branch]:
|
||||
"""Every exact pose that puts ``unit`` on ``tool``, in closed form.
|
||||
|
||||
Two ``atan2`` calls, no search. The chain is yaw-then-pitch, so:
|
||||
@@ -583,7 +583,7 @@ class PlatformStack:
|
||||
return math.degrees(math.atan2(sine_term, cosine_term))
|
||||
|
||||
@staticmethod
|
||||
def select_branch(branches: list["Branch"]) -> "Branch":
|
||||
def select_branch(branches: list[Branch]) -> Branch:
|
||||
"""Pick from :meth:`alignment_branches`: reachable, then upright, then best.
|
||||
|
||||
Every branch aligns to within floating point, so alignment itself rarely
|
||||
@@ -596,7 +596,7 @@ class PlatformStack:
|
||||
stages is to align. The tie only decides between poses that are the same
|
||||
to any tolerance the machine can hold.
|
||||
"""
|
||||
def rank(branch: "Branch") -> tuple:
|
||||
def rank(branch: Branch) -> tuple:
|
||||
# Alignment first: if one branch genuinely aligns better, that is the
|
||||
# answer, because aligning is what the stages are for. Reachability and
|
||||
# orientation decide between branches that align the same, which is the
|
||||
@@ -1051,7 +1051,7 @@ class PlatformStack:
|
||||
def _better_pose(
|
||||
candidate: tuple[float, float, float],
|
||||
best: tuple[float, float, float],
|
||||
stack: "PlatformStack",
|
||||
stack: PlatformStack,
|
||||
) -> bool:
|
||||
"""Rank two ``(residual, yaw, pitch)`` candidates.
|
||||
|
||||
|
||||
@@ -173,14 +173,14 @@ class MotionResult:
|
||||
message: str = ""
|
||||
|
||||
@classmethod
|
||||
def success(cls, message: str = "") -> "MotionResult":
|
||||
def success(cls, message: str = "") -> MotionResult:
|
||||
return cls(ok=True, code=0, message=message)
|
||||
|
||||
@classmethod
|
||||
def refused(cls, message: str) -> "MotionResult":
|
||||
def refused(cls, message: str) -> MotionResult:
|
||||
"""The command was not sent: a guard rejected it before any motion."""
|
||||
return cls(ok=False, code=0, message=message)
|
||||
|
||||
@classmethod
|
||||
def failed(cls, code: int, message: str) -> "MotionResult":
|
||||
def failed(cls, code: int, message: str) -> MotionResult:
|
||||
return cls(ok=False, code=int(code), message=message)
|
||||
|
||||
@@ -21,7 +21,8 @@ Contract:
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable, Iterable
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import (
|
||||
QMetaObject,
|
||||
@@ -288,7 +289,6 @@ class MotionPoller(QObject):
|
||||
# closes immediately and leaves the platform coasting.
|
||||
service.stop_all()
|
||||
service.disconnect()
|
||||
return None
|
||||
|
||||
return self.submit(_disconnect)
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Moving the platform to a simulated pose, one axis at a time.
|
||||
|
||||
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
|
||||
matters:
|
||||
|
||||
* The **service** owns the guards: connected, bus initialised, mapping sane, targets
|
||||
finite, inside the P0 limits, rotary axes calibrated. Nothing here bypasses them.
|
||||
* The **worker thread** owns the controller calls. One command per poll tick is how
|
||||
: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
|
||||
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
|
||||
already produces, issues the next axis when the current one has arrived, and gives
|
||||
up on its own if a step stops making progress.
|
||||
|
||||
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*.
|
||||
|
||||
Why the order is what it is: the model composes ``R_pitch · R_yaw``, i.e. the yaw acts
|
||||
on the body first. Commanding yaw before pitch keeps every intermediate pose on the
|
||||
path the operator watched in the preview. Table X/Y last, because the table
|
||||
translation is what the rotations are resolved against.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from .models import MotionResult, Snapshot
|
||||
from .service import LINEAR_ROLES, MotionService
|
||||
from .units import user_units_to_deg
|
||||
|
||||
#: How close an axis must be to its target before the next one starts, per unit:
|
||||
#: millimetres for the linear roles, degrees for the rotary ones. One user unit is
|
||||
#: 0.2 µm on the bench (``units=10``, 5000 pulses/mm), so 0.01 mm is 50 units —
|
||||
#: loose enough that a drive which reports its final position one tick late does not
|
||||
#: stall the sequence, tight enough that a missed target is caught.
|
||||
SETTLE_MM = 0.01
|
||||
SETTLE_DEG = 0.01
|
||||
|
||||
#: Per-step patience. A step that is neither arriving nor moving this long is
|
||||
#: treated as stuck: reported and abandoned, with no further axes commanded.
|
||||
STEP_TIMEOUT_S = 20.0
|
||||
|
||||
#: How long a step may report itself idle while still short of its target before it
|
||||
#: counts as failed — a drive that has stopped without arriving will never arrive.
|
||||
STALL_GRACE_S = 2.0
|
||||
|
||||
#: The order a role move is issued in, for the reason in the module docstring.
|
||||
MOVE_ORDER: tuple[str, ...] = ("yaw", "pitch", "x", "y", "z")
|
||||
|
||||
#: The order "return to the initial state" retracts in: the press axis first, so the
|
||||
#: tool is clear of the sensor before anything rotates, then the stages, then the
|
||||
#: table. Returning in one coordinated swoop would move five axes through poses
|
||||
#: nobody has simulated.
|
||||
RETURN_ORDER: tuple[str, ...] = ("z", "pitch", "yaw", "x", "y")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MoveStep:
|
||||
"""One axis, one target, in that role's own unit."""
|
||||
|
||||
role: str
|
||||
axis: int
|
||||
target: float
|
||||
units: float
|
||||
unit: str
|
||||
|
||||
def label(self) -> str:
|
||||
return f"{self.role} {self.target:+.4f} {self.unit}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MovePlan:
|
||||
"""A sequence of steps, and what it is for."""
|
||||
|
||||
steps: tuple[MoveStep, ...]
|
||||
title: str
|
||||
#: Roles deliberately left alone. Reported, because "z did not move" is
|
||||
#: information the operator needs, not an omission.
|
||||
held: tuple[str, ...] = ()
|
||||
|
||||
def describe(self) -> str:
|
||||
parts = [step.label() for step in self.steps]
|
||||
text = " · ".join(parts) if parts else "-"
|
||||
if self.held:
|
||||
text += f" · {', '.join(self.held)} 不动作"
|
||||
return text
|
||||
|
||||
|
||||
@dataclass
|
||||
class MoveState:
|
||||
"""What the mover is doing, for the panel to show."""
|
||||
|
||||
plan: MovePlan | None = None
|
||||
index: int = 0
|
||||
started_at: float = 0.0
|
||||
finished: bool = False
|
||||
failure: str = ""
|
||||
cancelled: bool = False
|
||||
_step_started_at: float = field(default=0.0)
|
||||
_idle_since: float | None = None
|
||||
|
||||
@property
|
||||
def step(self) -> MoveStep | None:
|
||||
if self.plan is None or not 0 <= self.index < len(self.plan.steps):
|
||||
return None
|
||||
return self.plan.steps[self.index]
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return len(self.plan.steps) if self.plan else 0
|
||||
|
||||
|
||||
def plan_pose_move(
|
||||
service: MotionService,
|
||||
targets: Mapping[str, float],
|
||||
*,
|
||||
title: str = "",
|
||||
hold: Sequence[str] = (),
|
||||
order: Sequence[str] = MOVE_ORDER,
|
||||
) -> MovePlan:
|
||||
"""Turn per-role targets into a stepped plan, in ``order``.
|
||||
|
||||
``targets`` is in each role's own unit. Roles absent from it are not commanded —
|
||||
that is how "z does not move" is expressed: not by a target equal to its current
|
||||
position, which would still command it.
|
||||
"""
|
||||
steps: list[MoveStep] = []
|
||||
for role in order:
|
||||
if role not in targets:
|
||||
continue
|
||||
axis = service.role_axis(role)
|
||||
if axis is None:
|
||||
continue
|
||||
value = float(targets[role])
|
||||
steps.append(
|
||||
MoveStep(
|
||||
role=role,
|
||||
axis=axis,
|
||||
target=value,
|
||||
units=service.to_units(role, value),
|
||||
unit="mm" if role in LINEAR_ROLES else "°",
|
||||
)
|
||||
)
|
||||
return MovePlan(steps=tuple(steps), title=title, held=tuple(hold))
|
||||
|
||||
|
||||
def plan_return_to_initial(service: MotionService) -> MovePlan:
|
||||
"""Every axis to zero, press axis first, in :data:`RETURN_ORDER`."""
|
||||
return plan_pose_move(
|
||||
service,
|
||||
{role: 0.0 for role in RETURN_ORDER},
|
||||
order=RETURN_ORDER,
|
||||
)
|
||||
|
||||
|
||||
class PoseMover(QObject):
|
||||
"""Issues a :class:`MovePlan` one step at a time, off the poller's snapshots."""
|
||||
|
||||
started = Signal(object)
|
||||
stepStarted = Signal(object)
|
||||
finished = Signal(object)
|
||||
failed = Signal(str)
|
||||
cancelled = Signal()
|
||||
|
||||
def __init__(self, service: MotionService, submit, parent: QObject | None = None) -> None:
|
||||
"""
|
||||
Args:
|
||||
service: the motion service, for unit conversion and per-axis guards.
|
||||
submit: the poller's ``submit``, taking a ``service -> result`` callable.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self._service = service
|
||||
self._submit = submit
|
||||
self.state = MoveState()
|
||||
|
||||
# ------------------------------------------------------------------ control
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
plan = self.state.plan
|
||||
return bool(
|
||||
plan is not None
|
||||
and not self.state.finished
|
||||
and not self.state.failure
|
||||
and not self.state.cancelled
|
||||
)
|
||||
|
||||
def start(self, plan: MovePlan, *, now: float | None = None) -> MotionResult:
|
||||
"""Validate and begin. Refusal is returned, not raised."""
|
||||
if self.running:
|
||||
return MotionResult.refused("已有运动在执行 / a move is already running")
|
||||
refusal = self._service.check_axes_move(
|
||||
{step.role: step.target for step in plan.steps}
|
||||
)
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
if not plan.steps:
|
||||
return MotionResult.refused("没有要移动的轴 / nothing to move")
|
||||
|
||||
stamp = time.monotonic() if now is None else now
|
||||
self.state = MoveState(plan=plan, started_at=stamp, _step_started_at=stamp)
|
||||
self.started.emit(plan)
|
||||
self._issue_current()
|
||||
return MotionResult.success(plan.describe())
|
||||
|
||||
def cancel(self, reason: str = "") -> None:
|
||||
"""Abandon the queue. The axis already moving is stopped by the caller.
|
||||
|
||||
Deliberately does not stop anything itself: stopping is a controller command
|
||||
with its own guard, and the window issues it as one. What this guarantees is
|
||||
that no *further* axis is commanded after the stop.
|
||||
"""
|
||||
if self.state.plan is None or self.state.finished:
|
||||
return
|
||||
self.state.cancelled = True
|
||||
self.state.failure = reason
|
||||
self.state.plan = None
|
||||
self.cancelled.emit()
|
||||
|
||||
def _issue_current(self) -> None:
|
||||
step = self.state.step
|
||||
if step is None:
|
||||
self._complete()
|
||||
return
|
||||
self.state._step_started_at = time.monotonic()
|
||||
self.state._idle_since = None
|
||||
if not step.units == step.units: # NaN: no calibration for this role
|
||||
self._fail(f"{step.role} 无法换算成用户单位 / cannot convert {step.role}")
|
||||
return
|
||||
self._submit(lambda service, step=step: service.move_abs(step.axis, step.units))
|
||||
self.stepStarted.emit(step)
|
||||
|
||||
def _complete(self) -> None:
|
||||
self.state.finished = True
|
||||
plan = self.state.plan
|
||||
self.state.plan = None
|
||||
self.finished.emit(plan)
|
||||
|
||||
def _fail(self, message: str) -> None:
|
||||
self.state.failure = message
|
||||
self.state.plan = None
|
||||
self.failed.emit(message)
|
||||
|
||||
# ---------------------------------------------------------------- progress
|
||||
|
||||
def on_snapshot(self, snapshot: Snapshot) -> None:
|
||||
"""Advance the plan from a poll. Called on every refresh."""
|
||||
if not self.running:
|
||||
return
|
||||
step = self.state.step
|
||||
if step is None:
|
||||
self._complete()
|
||||
return
|
||||
now = time.monotonic()
|
||||
if now - self.state._step_started_at > STEP_TIMEOUT_S:
|
||||
self._fail(
|
||||
f"{step.role} 等待超时 / timed out waiting for {step.role}"
|
||||
)
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
# A linear role reads in millimetres, a rotary one in degrees; the service
|
||||
# converts lengths, the units module converts calibrated angles.
|
||||
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
|
||||
|
||||
if not state.idle:
|
||||
self.state._idle_since = None
|
||||
return
|
||||
if abs(travelled - step.target) <= tolerance:
|
||||
self.state.index += 1
|
||||
self._issue_current()
|
||||
return
|
||||
# Idle, but not there. A drive that has stopped short will not resume, so
|
||||
# this is a failure rather than something to keep waiting for.
|
||||
if self.state._idle_since is None:
|
||||
self.state._idle_since = now
|
||||
elif now - self.state._idle_since > STALL_GRACE_S:
|
||||
self._fail(
|
||||
f"{step.role} 停在 {travelled:.4f}{step.unit},目标 "
|
||||
f"{step.target:.4f}{step.unit} / stopped short of target"
|
||||
)
|
||||
|
||||
def progress_text(self) -> str:
|
||||
"""A one-line status for the panel."""
|
||||
if self.state.failure:
|
||||
return f"✗ {self.state.failure}"
|
||||
if self.state.finished:
|
||||
return "✓ 已完成 / done"
|
||||
step = self.state.step
|
||||
if step is None:
|
||||
return ""
|
||||
return (
|
||||
f"{self.state.index + 1}/{self.state.total} "
|
||||
f"{step.role} → {step.target:+.4f} {step.unit}"
|
||||
)
|
||||
@@ -5,18 +5,19 @@ without a GUI and without hardware. :mod:`~orisys_autocal.motion.poller` drives
|
||||
it from a worker thread; the UI only ever calls these methods.
|
||||
|
||||
Guards live here rather than in the UI on purpose. A motion command is refused
|
||||
for a stated reason — bus not initialised, axis not idle, non-numeric input —
|
||||
and :meth:`MotionService.jog` and friends return that reason as a
|
||||
``MotionResult`` instead of silently doing nothing.
|
||||
for a stated reason — bus not initialised, axis not idle, non-numeric input, a
|
||||
target past the P0 travel limit, an axis mapping that does not add up — and
|
||||
:meth:`MotionService.jog` and friends return that reason as a ``MotionResult``
|
||||
instead of silently doing nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
import os
|
||||
from typing import Callable, Iterable
|
||||
import time
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
|
||||
from orisys_app_shared.zmotion import ZAuxError
|
||||
|
||||
@@ -34,13 +35,14 @@ from .models import (
|
||||
MotionResult,
|
||||
Snapshot,
|
||||
)
|
||||
from .transport import DemoTransport, ZmcTransport, NativeTransport
|
||||
from .transport import NativeTransport, ZmcTransport
|
||||
from .units import (
|
||||
DEFAULT_ACCEL,
|
||||
DEFAULT_SPEED,
|
||||
DEFAULT_UNITS,
|
||||
PULSES_PER_MM,
|
||||
deg_to_user_units,
|
||||
mm_to_user_units,
|
||||
safe_float,
|
||||
units_per_degree,
|
||||
user_units_to_mm,
|
||||
)
|
||||
|
||||
@@ -49,6 +51,32 @@ logger = logging.getLogger(__name__)
|
||||
#: The platform has five axes; the bus may report fewer if a drive is missing.
|
||||
PLATFORM_AXES = 5
|
||||
|
||||
#: The platform directions a drive can be, in the order the model's frames are
|
||||
#: named. A drive's *number* says nothing about what it moves: the bus reports five
|
||||
#: identical CiA-402 axes, and whether drive 2 tilts the sensor or lifts the table is
|
||||
#: a property of the bench's wiring. This is the mapping the whole "move the platform
|
||||
#: to the simulated pose" path rests on, so it is configuration with a page in the
|
||||
#: device setup rather than a constant.
|
||||
AXIS_ROLES: tuple[str, ...] = ("x", "y", "z", "yaw", "pitch")
|
||||
|
||||
#: Roles whose target is a length, and roles whose target is an angle. A degree
|
||||
#: target checked against a millimetre limit is not a limit at all.
|
||||
LINEAR_ROLES: tuple[str, ...] = ("x", "y", "z")
|
||||
ROTARY_ROLES: tuple[str, ...] = ("yaw", "pitch")
|
||||
|
||||
#: Default mapping, from the bench: drives 0-4 are x, y, pitch, yaw, z.
|
||||
#:
|
||||
#: Deliberately *not* the same as the model's own order. It is written the way the
|
||||
#: machine is wired, so reading it against the terminal strip is a check rather than
|
||||
#: a translation.
|
||||
DEFAULT_AXIS_ROLES: dict[str, int] = {
|
||||
"x": 0,
|
||||
"y": 1,
|
||||
"pitch": 2,
|
||||
"yaw": 3,
|
||||
"z": 4,
|
||||
}
|
||||
|
||||
#: ``BUS_TYPE`` values written by the vendor ``.bas`` programs.
|
||||
_BUS_TYPE_NAMES: dict[int, BusType] = {0: "ecat", 1: "rtex"}
|
||||
|
||||
@@ -78,6 +106,27 @@ def clamp_bas_task(value: object, fallback: int = DEFAULT_BAS_TASK) -> int:
|
||||
return task if low <= task <= high else fallback
|
||||
|
||||
|
||||
def normalize_axis_roles(roles: Mapping[str, int] | None) -> dict[str, int]:
|
||||
"""A clean role -> drive mapping, falling back to the bench default.
|
||||
|
||||
Unusable entries are dropped rather than coerced: a role mapped to a drive the
|
||||
file did not contain is a configuration the app should report through
|
||||
:meth:`MotionService.roles_problem`, not silently repair.
|
||||
"""
|
||||
if roles is None:
|
||||
return dict(DEFAULT_AXIS_ROLES)
|
||||
clean: dict[str, int] = {}
|
||||
for role in AXIS_ROLES:
|
||||
value = roles.get(role)
|
||||
if value is None:
|
||||
continue
|
||||
try:
|
||||
clean[role] = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return clean
|
||||
|
||||
|
||||
def _online_failure(reply: str) -> str:
|
||||
"""The controller's own refusal text from an online command, or ``""``.
|
||||
|
||||
@@ -107,6 +156,8 @@ class MotionService:
|
||||
pulses_per_mm: float = PULSES_PER_MM,
|
||||
language: str = "zh_CN",
|
||||
bas_task: int = DEFAULT_BAS_TASK,
|
||||
axis_roles: Mapping[str, int] | None = None,
|
||||
limits: Mapping[str, float] | None = None,
|
||||
) -> None:
|
||||
self._transport: ZmcTransport = transport or NativeTransport()
|
||||
self.axis_count = int(axis_count)
|
||||
@@ -114,6 +165,14 @@ class MotionService:
|
||||
self._pulses_per_mm = pulses_per_mm
|
||||
self.language = language
|
||||
self.bas_task = clamp_bas_task(bas_task)
|
||||
# Which drive is which platform direction. The app cannot infer this from a
|
||||
# CiA-402 axis: the bus reports numbers. It is configuration, so it arrives
|
||||
# from the preference file and can be corrected in the device setup.
|
||||
self._axis_roles: dict[str, int] = normalize_axis_roles(axis_roles)
|
||||
# The mm limits from workflow P0. They live here rather than in the UI because
|
||||
# this is where motion is allowed or refused, and a limit the UI enforces is a
|
||||
# limit a later caller can walk around.
|
||||
self._limits: dict[str, float] = dict(limits or {})
|
||||
|
||||
self._spec: ConnectionSpec | None = None
|
||||
self._bus = BusStatus()
|
||||
@@ -151,6 +210,152 @@ class MotionService:
|
||||
def params(self, axis: int) -> AxisParams:
|
||||
return self._params.get(axis, AxisParams())
|
||||
|
||||
# --------------------------------------------------------------- axis roles
|
||||
|
||||
@property
|
||||
def axis_roles(self) -> dict[str, int]:
|
||||
"""Drive index per platform direction, as configured."""
|
||||
return dict(self._axis_roles)
|
||||
|
||||
def set_axis_roles(self, roles: Mapping[str, int] | None) -> None:
|
||||
"""Replace the mapping. Called when the device setup is edited."""
|
||||
self._axis_roles = normalize_axis_roles(roles)
|
||||
|
||||
def set_limits(self, limits: Mapping[str, float] | None) -> None:
|
||||
"""Replace the P0 limits. Called when the preference file is reloaded."""
|
||||
self._limits = dict(limits or {})
|
||||
|
||||
def role_axis(self, role: str) -> int | None:
|
||||
"""The drive that moves ``role``, or ``None`` if it is not configured."""
|
||||
return self._axis_roles.get(role)
|
||||
|
||||
def roles_problem(self) -> str:
|
||||
"""Why the mapping cannot be used, or ``""`` when it is sound.
|
||||
|
||||
A duplicated drive is the dangerous case — two roles claiming axis 3 means
|
||||
one of them silently does not move, and the pose would be wrong rather than
|
||||
refused — so it is reported rather than tolerated.
|
||||
"""
|
||||
missing = [role for role in AXIS_ROLES if role not in self._axis_roles]
|
||||
if missing:
|
||||
return self._text(
|
||||
f"轴映射未配置:{', '.join(missing)}",
|
||||
f"Axis mapping is not configured for: {', '.join(missing)}",
|
||||
)
|
||||
used: dict[int, str] = {}
|
||||
for role in AXIS_ROLES:
|
||||
axis = self._axis_roles[role]
|
||||
if not self._known_axis(axis):
|
||||
return self._text(
|
||||
f"{role} 指向轴 {axis},超出 0-{self.axis_count - 1}",
|
||||
f"{role} points at axis {axis}, outside 0-{self.axis_count - 1}",
|
||||
)
|
||||
if axis in used:
|
||||
return self._text(
|
||||
f"轴 {axis} 同时是 {used[axis]} 和 {role}",
|
||||
f"Axis {axis} is claimed by both {used[axis]} and {role}",
|
||||
)
|
||||
used[axis] = role
|
||||
return ""
|
||||
|
||||
@property
|
||||
def travel_limit_mm(self) -> float:
|
||||
"""The P0 travel limit. ``0`` means "not set", never "unlimited"."""
|
||||
return safe_float(self._limits.get("max_travel_mm"), 0.0)
|
||||
|
||||
def limit_for(self, role: str) -> float:
|
||||
"""The P0 limit for a role, in that role's own unit.
|
||||
|
||||
Linear roles are limited in millimetres and rotary roles in degrees: one
|
||||
number cannot bound both, and treating a degree target as millimetres is how
|
||||
a 180° move passes a 100 mm check.
|
||||
"""
|
||||
key = "max_travel_mm" if role in LINEAR_ROLES else f"max_{role}_deg"
|
||||
return safe_float(self._limits.get(key), 0.0)
|
||||
|
||||
def units_problem(self, role: str, axis: int) -> str:
|
||||
"""Why a role cannot be commanded in its own unit, or ``""``."""
|
||||
if role in LINEAR_ROLES:
|
||||
return ""
|
||||
scale = units_per_degree(axis)
|
||||
if not math.isfinite(scale) or scale <= 0:
|
||||
return self._text(
|
||||
f"{role} 用轴 {axis},但该轴没有角度标定(用户单位/度)",
|
||||
f"{role} uses axis {axis}, which has no user-units-per-degree "
|
||||
f"calibration",
|
||||
)
|
||||
return ""
|
||||
|
||||
def check_axes_move(self, targets: Mapping[str, float]) -> MotionResult | None:
|
||||
"""Refuse a coordinated pose move, or return ``None`` to allow it.
|
||||
|
||||
``targets`` is per role, in the role's own unit: millimetres for x / y / z,
|
||||
degrees for yaw / pitch.
|
||||
|
||||
Every reason a move can be wrong is checked *before* the first axis is
|
||||
commanded. A pose move is several coordinated moves, and discovering at axis
|
||||
three that axis four is out of range would leave the machine stopped
|
||||
somewhere that is neither the start nor the target.
|
||||
"""
|
||||
if not self.connected:
|
||||
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
|
||||
if not self._bus.initialized:
|
||||
return MotionResult.refused(
|
||||
self._text(
|
||||
"总线未初始化完成,禁止运动",
|
||||
"Bus is not initialised; motion is refused",
|
||||
)
|
||||
)
|
||||
problem = self.roles_problem()
|
||||
if problem:
|
||||
return MotionResult.refused(problem)
|
||||
if not targets:
|
||||
return MotionResult.refused(self._text("没有要移动的轴", "Nothing to move"))
|
||||
for role, value in targets.items():
|
||||
if role not in AXIS_ROLES:
|
||||
return MotionResult.refused(
|
||||
self._text(f"未知方向 {role}", f"Unknown direction {role}")
|
||||
)
|
||||
if not isinstance(value, (int, float)) or not math.isfinite(float(value)):
|
||||
return MotionResult.refused(
|
||||
self._text(f"{role} 目标不是有效数字", f"{role} target is not a number")
|
||||
)
|
||||
axis = self._axis_roles[role]
|
||||
units_problem = self.units_problem(role, axis)
|
||||
if units_problem:
|
||||
return MotionResult.refused(units_problem)
|
||||
for role, value in targets.items():
|
||||
limit = self.limit_for(role)
|
||||
unit = "mm" if role in LINEAR_ROLES else "°"
|
||||
if limit <= 0:
|
||||
return MotionResult.refused(
|
||||
self._text(
|
||||
f"{role} 的上限未设置(P0),禁止运动",
|
||||
f"The {role} limit is not set (P0); motion is refused",
|
||||
)
|
||||
)
|
||||
if abs(float(value)) > limit:
|
||||
return MotionResult.refused(
|
||||
self._text(
|
||||
f"{role} 目标 {float(value):.3f}{unit} 超出上限 {limit:.3f}{unit}",
|
||||
f"{role} target {float(value):.3f}{unit} is past the "
|
||||
f"{limit:.3f}{unit} limit",
|
||||
)
|
||||
)
|
||||
return None
|
||||
|
||||
def to_units(self, role: str, value: float) -> float:
|
||||
"""Controller user units for a role target, in that role's own unit."""
|
||||
axis = self._axis_roles.get(role)
|
||||
if axis is None:
|
||||
return math.nan
|
||||
if role in LINEAR_ROLES:
|
||||
units = self.params(axis).units or self._units_per_mm
|
||||
return mm_to_user_units(
|
||||
value, units=units, pulses_per_mm=self._pulses_per_mm
|
||||
)
|
||||
return deg_to_user_units(axis, value)
|
||||
|
||||
def set_language(self, language: str) -> None:
|
||||
self.language = language
|
||||
|
||||
|
||||
@@ -13,7 +13,12 @@ from typing import Any
|
||||
from orisys_app_shared import prefs as _prefs
|
||||
from orisys_app_shared.paths import preference_path
|
||||
|
||||
from .motion.service import DEFAULT_BAS_TASK, PLATFORM_AXES, clamp_bas_task
|
||||
from .motion.service import (
|
||||
DEFAULT_AXIS_ROLES,
|
||||
DEFAULT_BAS_TASK,
|
||||
PLATFORM_AXES,
|
||||
clamp_bas_task,
|
||||
)
|
||||
from .motion.units import DEFAULT_ACCEL, DEFAULT_SPEED, DEFAULT_UNITS
|
||||
|
||||
#: Connection modes, in the order the combo offers them.
|
||||
@@ -81,6 +86,10 @@ def _default_safety_limits() -> dict[str, float]:
|
||||
|
||||
``0`` means "not set" for the ones where a real value is required, and the
|
||||
app reports that at the P0 gate rather than treating it as unlimited.
|
||||
|
||||
Lengths are millimetres and angles degrees, because the roles they bound are:
|
||||
one number cannot bound both, and a degree target checked against a millimetre
|
||||
limit is not a check.
|
||||
"""
|
||||
return {
|
||||
"max_travel_mm": 100.0,
|
||||
@@ -90,6 +99,11 @@ def _default_safety_limits() -> dict[str, float]:
|
||||
#: How far the axis may descend looking for contact before the run is
|
||||
#: aborted (workflow P7, no-contact abort).
|
||||
"contact_search_mm": 3.0,
|
||||
#: Stage envelope, from the confirmed bench values: the yaw stage turns a
|
||||
#: half-turn either way, the pitch stage a full turn. These are the limits a
|
||||
#: pose move is checked against before any axis is commanded.
|
||||
"max_yaw_deg": 180.0,
|
||||
"max_pitch_deg": 360.0,
|
||||
}
|
||||
|
||||
|
||||
@@ -104,9 +118,10 @@ def _default_sensor_setup() -> dict[str, Any]:
|
||||
"sensor_type": "box",
|
||||
"config_ref": "",
|
||||
"geometry_id": "",
|
||||
#: Which axis presses, and the probe tip offset from the platform's
|
||||
#: reference point. Empty offset means "not set", which blocks P5.
|
||||
"press_axis": 2,
|
||||
#: The probe tip's offset from the platform's reference point. Empty offset
|
||||
#: means "not set", which blocks P5. Which axis presses is **not** here: it
|
||||
#: is ``axis_roles["z"]``, because one fact written twice is one fact that
|
||||
#: eventually disagrees.
|
||||
"probe_tip_offset_mm": [0.0, 0.0, 0.0],
|
||||
"probe_tip_set": False,
|
||||
#: 4x4 row-major sensor -> platform transform, plus how well it fitted.
|
||||
@@ -173,6 +188,19 @@ def default_preferences() -> dict[str, Any]:
|
||||
# --- workflow blocks (see apps/autocal/docs/workflow.md) --------------
|
||||
#: P0: limits enforced from P2 onward.
|
||||
"safety_limits": _default_safety_limits(),
|
||||
#: Which drive is which platform direction, from the bench wiring: drives
|
||||
#: 0-4 are x, y, pitch, yaw, z. There is no way to read this off the bus —
|
||||
#: five CiA-402 axes report numbers, not names — so it is configuration, and
|
||||
#: the pose-move path refuses to run until it is complete and free of
|
||||
#: duplicates. It is also the *only* place "which axis presses" is written:
|
||||
#: the sensor setup used to carry a second copy, which is how the two would
|
||||
#: have drifted apart.
|
||||
"axis_roles": dict(DEFAULT_AXIS_ROLES),
|
||||
#: Ask before a pose move. On by default — the first moves on a machine are
|
||||
#: the ones worth reading — and turnable off once the numbers are trusted,
|
||||
#: because a prompt that cannot be dismissed unread is one that gets clicked
|
||||
#: through without being read.
|
||||
"move_confirm": True,
|
||||
#: P3/P4: sensor under test, probe tip, sensor<->platform transform.
|
||||
"sensor_setup": _default_sensor_setup(),
|
||||
#: P5: grid generation and run parameters.
|
||||
@@ -206,9 +234,26 @@ def default_preferences() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
#: Preference blocks whose *keys* are added to over time. Stored preferences are
|
||||
#: merged one level deep, so a block saved by an older build replaces the whole
|
||||
#: default block and any key added since reads as absent. For a P0 limit that is not
|
||||
#: a cosmetic problem: the move is refused with "the limit is not set" while the file
|
||||
#: in front of the operator says nothing about it. Filling the block key by key keeps
|
||||
#: what was chosen and supplies only what was never there.
|
||||
_MERGED_BLOCKS = ("connection", "safety_limits", "sensor_setup", "sequence_profile")
|
||||
|
||||
|
||||
def load_preferences() -> dict[str, Any]:
|
||||
"""Stored preferences with AutoCal's defaults filling the gaps."""
|
||||
prefs = _prefs.load_preferences(default_preferences())
|
||||
defaults = default_preferences()
|
||||
prefs = _prefs.load_preferences(defaults)
|
||||
for name in _MERGED_BLOCKS:
|
||||
stored = prefs.get(name)
|
||||
if not isinstance(stored, dict):
|
||||
continue
|
||||
block = dict(defaults.get(name) or {})
|
||||
block.update(stored)
|
||||
prefs[name] = block
|
||||
# A stored document from an older build may miss a newly added axis block.
|
||||
axis_params = prefs.setdefault("axis_params", {})
|
||||
for axis, values in _default_axis_params().items():
|
||||
@@ -252,6 +297,39 @@ def load_preferences() -> dict[str, Any]:
|
||||
prefs["finger_tool_xy_mm"] = tool
|
||||
# A hand-edited task number must not reach the controller as typed.
|
||||
prefs["bas_task"] = clamp_bas_task(prefs.get("bas_task"))
|
||||
prefs = _migrate_axis_roles(prefs)
|
||||
return prefs
|
||||
|
||||
|
||||
def _migrate_axis_roles(prefs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Complete the axis mapping, and take the old duplicate press axis out.
|
||||
|
||||
``sensor_setup.press_axis`` named the pressing drive on its own. That is
|
||||
``axis_roles["z"]`` now — the same fact — and a stored value is moved there when
|
||||
the mapping has not been set, so a bench that had it configured keeps working.
|
||||
The old key is removed either way: leaving it would keep a second answer to
|
||||
"which axis presses" in the file.
|
||||
"""
|
||||
roles = prefs.get("axis_roles")
|
||||
roles = dict(roles) if isinstance(roles, dict) else {}
|
||||
sensor = prefs.get("sensor_setup")
|
||||
if isinstance(sensor, dict):
|
||||
legacy = sensor.pop("press_axis", None)
|
||||
if legacy is not None and "z" not in roles:
|
||||
try:
|
||||
roles["z"] = int(legacy)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
merged = dict(DEFAULT_AXIS_ROLES)
|
||||
# A stored mapping wins where it is usable; the default fills what it does not
|
||||
# cover, so an old document that only named the press axis still comes up with a
|
||||
# complete mapping rather than a refusal.
|
||||
for role, value in roles.items():
|
||||
try:
|
||||
merged[role] = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
prefs["axis_roles"] = merged
|
||||
return prefs
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Sensor assets: export bundles, the flat UV view, and the mount frames."""
|
||||
|
||||
from .export_bundle import BundleError, SensorBundle, load_bundle
|
||||
from .frames import AXIS_MAP, describe as describe_frames, mount_rotation, to_platform, to_sensor
|
||||
from .frames import AXIS_MAP, mount_rotation, to_platform, to_sensor
|
||||
from .frames import describe as describe_frames
|
||||
from .uv_flat import (
|
||||
DEFAULT_FLAT_SIZE,
|
||||
FlatSurface,
|
||||
@@ -12,8 +13,8 @@ from .uv_flat import (
|
||||
|
||||
__all__ = [
|
||||
"AXIS_MAP",
|
||||
"BundleError",
|
||||
"DEFAULT_FLAT_SIZE",
|
||||
"BundleError",
|
||||
"FlatSurface",
|
||||
"SensorBundle",
|
||||
"describe_frames",
|
||||
|
||||
@@ -26,10 +26,11 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Mapping
|
||||
from typing import Any
|
||||
|
||||
#: The version this build writes, and the highest it will read.
|
||||
FORMAT_VERSION = "1.0"
|
||||
|
||||
@@ -14,6 +14,9 @@ five-axis platform, acting on the wrong axis is the expensive mistake.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QBrush, QColor
|
||||
from PySide6.QtWidgets import (
|
||||
@@ -29,8 +32,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from ..motion import (
|
||||
DEFAULT_ACCEL,
|
||||
DEFAULT_SPEED,
|
||||
@@ -582,7 +583,7 @@ def _millimetres(units: float, user_units: float) -> str:
|
||||
if not units:
|
||||
return "—"
|
||||
millimetres = user_units_to_mm(user_units, units=units)
|
||||
if millimetres != millimetres: # NaN
|
||||
if math.isnan(millimetres):
|
||||
return "—"
|
||||
return f"{millimetres:.3f}"
|
||||
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
"""The permanent axis readout rail.
|
||||
|
||||
Read-only by design. Every per-axis *command* lives in the Axis-debug tab, so
|
||||
there is exactly one place a move can be issued from — two places to jog from is
|
||||
how an operator moves the wrong axis. What this rail adds is the thing the
|
||||
operator needs while working in any other tab: whether each drive is enabled,
|
||||
where it is, and whether it is moving.
|
||||
Read-only apart from four platform-wide actions. Every per-axis *command* lives in
|
||||
the Axis-debug tab, so there is exactly one place a single-axis move can be issued
|
||||
from — two places to jog from is how an operator moves the wrong axis. What this rail
|
||||
adds is the thing the operator needs while working in any other tab: whether each
|
||||
drive is enabled, where it is, and whether it is moving.
|
||||
|
||||
The three global actions are here rather than in the tab because they are the
|
||||
ones you reach for in a hurry, and they must not require finding a tab first.
|
||||
The four global actions are here rather than in a tab because they are the ones you
|
||||
reach for in a hurry, or reach for *after* finishing in some other tab: enable,
|
||||
stop, clear alarms, and return every axis to the initial state. They must not
|
||||
require finding the right tab first.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import load_colors
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QGridLayout,
|
||||
@@ -23,9 +29,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import load_colors
|
||||
|
||||
from ..motion import Snapshot, user_units_to_mm
|
||||
|
||||
#: Columns of the compact readout: axis, enable state, position.
|
||||
@@ -41,6 +44,10 @@ class AxisReadoutPanel(QGroupBox):
|
||||
enableAllRequested = Signal(bool)
|
||||
stopAllRequested = Signal()
|
||||
clearAllRequested = Signal()
|
||||
#: Return every drive to the initial state. Here, not in the finger screen,
|
||||
#: because it is a machine-level action: it is wanted after a move made in *any*
|
||||
#: tab, and the rail is on screen in all of them.
|
||||
returnRequested = Signal()
|
||||
|
||||
def __init__(self, axis_count: int, theme: str = "dark", parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -72,7 +79,14 @@ class AxisReadoutPanel(QGroupBox):
|
||||
self.stop_all_button.clicked.connect(self.stopAllRequested)
|
||||
self.clear_all_button = QPushButton(self)
|
||||
self.clear_all_button.clicked.connect(self.clearAllRequested)
|
||||
for button in (self.enable_all_button, self.stop_all_button, self.clear_all_button):
|
||||
self.return_button = QPushButton(self)
|
||||
self.return_button.clicked.connect(self.returnRequested)
|
||||
for button in (
|
||||
self.enable_all_button,
|
||||
self.stop_all_button,
|
||||
self.return_button,
|
||||
self.clear_all_button,
|
||||
):
|
||||
button.setProperty("rail", True)
|
||||
outer.addWidget(button)
|
||||
|
||||
@@ -110,7 +124,12 @@ class AxisReadoutPanel(QGroupBox):
|
||||
|
||||
def set_connected(self, connected: bool) -> None:
|
||||
self._connected = bool(connected)
|
||||
for button in (self.enable_all_button, self.stop_all_button, self.clear_all_button):
|
||||
for button in (
|
||||
self.enable_all_button,
|
||||
self.stop_all_button,
|
||||
self.clear_all_button,
|
||||
self.return_button,
|
||||
):
|
||||
button.setEnabled(self._connected)
|
||||
|
||||
def update_snapshot(self, snapshot: Snapshot) -> None:
|
||||
@@ -138,7 +157,7 @@ class AxisReadoutPanel(QGroupBox):
|
||||
|
||||
millimetres = user_units_to_mm(state.dpos, units=state.units) if state.units else None
|
||||
units_text = f"{state.dpos:.3f}"
|
||||
if millimetres is not None and millimetres == millimetres:
|
||||
if millimetres is not None and not math.isnan(millimetres):
|
||||
units_text = t("axes.position_pair", value=units_text, mm=f"{millimetres:.3f}")
|
||||
widgets["position"].setText(units_text)
|
||||
widgets["position"].setToolTip(
|
||||
@@ -166,5 +185,7 @@ class AxisReadoutPanel(QGroupBox):
|
||||
self.setTitle(t("readout.title"))
|
||||
self.stop_all_button.setText(t("axes.stop_all"))
|
||||
self.clear_all_button.setText(t("axes.clear_alarm_all"))
|
||||
self.return_button.setText(t("move.return_initial"))
|
||||
self.return_button.setToolTip(t("move.return_hint"))
|
||||
# The toggle's label depends on current state, so re-derive it.
|
||||
self.update_snapshot(self._snapshot)
|
||||
|
||||
@@ -7,13 +7,12 @@ controller is reachable.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QWidget
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import logo_topbar_path
|
||||
from orisys_app_shared.theme import load_colors
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QWidget
|
||||
|
||||
#: Chip tone -> colour token name.
|
||||
_TONE_TOKENS = {
|
||||
|
||||
@@ -9,12 +9,12 @@ vendor reference guards this too, by checking ``BUS_TYPE`` before every call).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
@@ -22,8 +22,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
#: Height of this panel's fields and buttons. Pinned for the same reason as the
|
||||
#: axis panel's: an unconstrained QLineEdit is ~34 px, and four of them stacked
|
||||
#: push this panel tall enough to steal an axis row from the panel above it.
|
||||
|
||||
@@ -11,6 +11,8 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import app_root
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QGridLayout,
|
||||
@@ -23,9 +25,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import app_root
|
||||
|
||||
from ..motion import INIT_FAILED, INIT_OK, INIT_RUNNING, BusStatus
|
||||
|
||||
_STATE_KEYS = {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
@@ -15,8 +16,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from ..motion import ConnectionSpec
|
||||
from ..preferences import CONNECTION_MODES, DEFAULT_CONTROLLER_IPS
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""The device setup: connection and bus initialisation, behind one button.
|
||||
"""The device setup: connection, bus initialisation and the axis mapping.
|
||||
|
||||
Both groups are touched once per machine start-up — reach the controller, download
|
||||
the bus program, watch the bus come up — and then not again for the rest of the
|
||||
session. In the left rail they were spending ~460 px of its height on that, above
|
||||
the axis readout that is read all day, which is what made the rail scroll.
|
||||
These are the facts about the machine itself, as opposed to the job: reach the
|
||||
controller, download the bus program, watch the bus come up, and say which drive moves
|
||||
which platform direction. All of them are touched once per machine start-up — or once
|
||||
per re-wire — and then not again for the rest of the session. In the left rail the
|
||||
first two were spending ~460 px of its height, above the axis readout that is read all
|
||||
day, which is what made the rail scroll.
|
||||
|
||||
They live in a **modeless dialog** rather than a modal one on purpose: connecting and
|
||||
downloading are asynchronous (the poller runs the state machine, the status strip
|
||||
@@ -12,35 +14,156 @@ the axis readout while it happens. Modeless also means the dialog can be opened
|
||||
several places — the strip's button, its state chips, the File menu — without any of
|
||||
them having to block.
|
||||
|
||||
The dialog owns the two panels; the window keeps a reference to each
|
||||
The dialog owns the connection and bus panels; the window keeps a reference to each
|
||||
(``window.connection_panel`` / ``window.bus_panel``) because it is the window that
|
||||
wires them to the controller, and because the tools and checks reach for them there.
|
||||
The axis mapping is read back by the window on every change, because the motion guards
|
||||
have to see the same numbers the page shows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QLabel,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from ..motion import ConnectionSpec
|
||||
from ..motion.service import AXIS_ROLES, DEFAULT_AXIS_ROLES, PLATFORM_AXES
|
||||
from .bus_panel import BusPanel
|
||||
from .connection_panel import ConnectionPanel
|
||||
|
||||
#: Tab identity -> locale key, in the order they are shown. The order is the work:
|
||||
#: reach the machine, then bring its bus up.
|
||||
#: reach the machine, bring its bus up, then say what each drive moves.
|
||||
TABS: tuple[tuple[str, str], ...] = (
|
||||
("connection", "setup.tab_connection"),
|
||||
("bus", "setup.tab_bus"),
|
||||
("axes", "setup.tab_axes"),
|
||||
)
|
||||
|
||||
#: Role -> locale key for the mapping rows.
|
||||
_ROLE_KEYS = {
|
||||
"x": "setup.role_x",
|
||||
"y": "setup.role_y",
|
||||
"z": "setup.role_z",
|
||||
"yaw": "setup.role_yaw",
|
||||
"pitch": "setup.role_pitch",
|
||||
}
|
||||
|
||||
|
||||
class AxisRolesPage(QWidget):
|
||||
"""Which drive moves which platform direction.
|
||||
|
||||
A page of its own because it is a separate fact: connection and bus are about
|
||||
*reaching* the machine, this is about what its drives do. Getting it wrong moves
|
||||
the wrong motor, so a duplicate is reported rather than quietly accepted — and the
|
||||
motion guards read the same mapping, so a page left in a contradictory state
|
||||
refuses motion instead of guessing which claim was meant.
|
||||
"""
|
||||
|
||||
rolesChanged = Signal()
|
||||
|
||||
def __init__(
|
||||
self, roles: dict[str, int] | None = None, parent: QWidget | None = None
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
column = QVBoxLayout(self)
|
||||
column.setContentsMargins(8, 8, 8, 8)
|
||||
column.setSpacing(8)
|
||||
|
||||
self.hint_label = QLabel(self)
|
||||
self.hint_label.setObjectName("hintLabel")
|
||||
self.hint_label.setWordWrap(True)
|
||||
column.addWidget(self.hint_label)
|
||||
|
||||
self.box = QGroupBox(self)
|
||||
self.box.setObjectName("groupBox")
|
||||
form = QFormLayout(self.box)
|
||||
form.setContentsMargins(10, 8, 10, 10)
|
||||
form.setSpacing(6)
|
||||
|
||||
self.combos: dict[str, QComboBox] = {}
|
||||
self.labels: dict[str, QLabel] = {}
|
||||
for role in AXIS_ROLES:
|
||||
label = QLabel(self.box)
|
||||
combo = QComboBox(self.box)
|
||||
for axis in range(PLATFORM_AXES):
|
||||
combo.addItem(str(axis), axis)
|
||||
combo.currentIndexChanged.connect(self._on_changed)
|
||||
self.labels[role] = label
|
||||
self.combos[role] = combo
|
||||
form.addRow(label, combo)
|
||||
column.addWidget(self.box)
|
||||
|
||||
self.problem_label = QLabel(self)
|
||||
self.problem_label.setObjectName("statusValue")
|
||||
self.problem_label.setWordWrap(True)
|
||||
column.addWidget(self.problem_label)
|
||||
column.addStretch(1)
|
||||
|
||||
self.set_roles(roles or {})
|
||||
self.retranslate()
|
||||
|
||||
# ------------------------------------------------------------------- state
|
||||
|
||||
def set_roles(self, roles: dict[str, int]) -> None:
|
||||
"""Show a mapping without announcing a change the operator did not make.
|
||||
|
||||
An empty mapping falls back to the bench default rather than to "every role
|
||||
on drive 0", which would open the page in a contradictory state and — because
|
||||
the guards read the same mapping — refuse motion for a reason nobody typed.
|
||||
"""
|
||||
source = dict(roles) or dict(DEFAULT_AXIS_ROLES)
|
||||
for role, combo in self.combos.items():
|
||||
value = source.get(role)
|
||||
index = combo.findData(int(value)) if value is not None else -1
|
||||
combo.blockSignals(True)
|
||||
try:
|
||||
combo.setCurrentIndex(index if index >= 0 else 0)
|
||||
finally:
|
||||
combo.blockSignals(False)
|
||||
self._refresh_problem(announce=False)
|
||||
|
||||
def roles(self) -> dict[str, int]:
|
||||
return {role: int(combo.currentData()) for role, combo in self.combos.items()}
|
||||
|
||||
def problem(self) -> str:
|
||||
"""The duplicate mapping, described, or ``""`` when it is sound."""
|
||||
seen: dict[int, str] = {}
|
||||
for role, combo in self.combos.items():
|
||||
axis = int(combo.currentData())
|
||||
if axis in seen:
|
||||
return t("setup.axes_duplicate", detail=f"{seen[axis]} ↔ {role}")
|
||||
seen[axis] = role
|
||||
return ""
|
||||
|
||||
def _on_changed(self, _index: int) -> None:
|
||||
self._refresh_problem()
|
||||
|
||||
def _refresh_problem(self, *, announce: bool = True) -> None:
|
||||
problem = self.problem()
|
||||
self.problem_label.setText(f"⚠ {problem}" if problem else "")
|
||||
if announce:
|
||||
self.rolesChanged.emit()
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.hint_label.setText(t("setup.axes_hint"))
|
||||
self.box.setTitle(t("setup.tab_axes"))
|
||||
for role, label in self.labels.items():
|
||||
label.setText(t(_ROLE_KEYS[role]))
|
||||
self._refresh_problem(announce=False)
|
||||
|
||||
|
||||
class DeviceSetupDialog(QDialog):
|
||||
"""Connection and bus initialisation, the once-per-start controls."""
|
||||
@@ -49,6 +172,7 @@ class DeviceSetupDialog(QDialog):
|
||||
self,
|
||||
spec: ConnectionSpec,
|
||||
bas_path: str = "",
|
||||
roles: dict[str, int] | None = None,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -70,6 +194,7 @@ class DeviceSetupDialog(QDialog):
|
||||
self.tabs.setObjectName("setupTabs")
|
||||
self.connection_panel = ConnectionPanel(spec, self.tabs)
|
||||
self.bus_panel = BusPanel(bas_path, self.tabs)
|
||||
self.roles_page = AxisRolesPage(roles, self.tabs)
|
||||
for key, locale_key in TABS:
|
||||
self.tabs.addTab(self.panel(key), "")
|
||||
column.addWidget(self.tabs, 1)
|
||||
@@ -90,8 +215,10 @@ class DeviceSetupDialog(QDialog):
|
||||
# ----------------------------------------------------------------- content
|
||||
|
||||
def panel(self, key: str) -> QWidget:
|
||||
"""One of the two panels, by tab key."""
|
||||
return self.connection_panel if key == "connection" else self.bus_panel
|
||||
"""One of the three pages, by tab key."""
|
||||
if key == "connection":
|
||||
return self.connection_panel
|
||||
return self.bus_panel if key == "bus" else self.roles_page
|
||||
|
||||
def open_for(self, tab: str | None = None) -> None:
|
||||
"""Show the dialog, raising it if it is already up, and pick a tab.
|
||||
@@ -155,3 +282,4 @@ class DeviceSetupDialog(QDialog):
|
||||
self.close_button.setText(t("setup.close"))
|
||||
self.connection_panel.retranslate()
|
||||
self.bus_panel.retranslate()
|
||||
self.roles_page.retranslate()
|
||||
|
||||
@@ -34,7 +34,7 @@ from orisys_app_shared.i18n import t
|
||||
|
||||
from ..motion.kinematics import PlatformStack, Pose, rotation_about
|
||||
from ..sensor.export_bundle import SensorBundle
|
||||
from ..sensor.frames import mount_rotation, to_platform, to_sensor
|
||||
from ..sensor.frames import to_sensor
|
||||
|
||||
#: Orbit drag sensitivity and zoom step, matching the design repo's preview.
|
||||
ORBIT_RAD_PER_PX = 0.008
|
||||
|
||||
@@ -22,6 +22,7 @@ import math
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import QPoint, Qt, Signal
|
||||
from PySide6.QtGui import QColor, QImage, QPainter, QPen, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
@@ -34,7 +35,6 @@ from PySide6.QtWidgets import (
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
@@ -42,8 +42,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from ..motion.kinematics import PlatformStack
|
||||
from ..sensor.export_bundle import SensorBundle
|
||||
from ..sensor.frames import describe as describe_frames
|
||||
@@ -226,6 +224,12 @@ class FingerPanel(QGroupBox):
|
||||
loadRequested = Signal()
|
||||
pickRequested = Signal(int, int)
|
||||
stackChanged = Signal()
|
||||
#: Run the selected point's solved pose on the platform, and stop moving.
|
||||
executeRequested = Signal()
|
||||
stopRequested = Signal()
|
||||
#: Whether to ask before moving. Persisted; the window honours it for every
|
||||
#: pose move, including the rail's return-to-zero.
|
||||
confirmChanged = Signal(bool)
|
||||
|
||||
#: Columns of the point table, minus the leading select checkbox. The first six
|
||||
#: are the ones an operator may edit — the sensor-frame point and its normal —
|
||||
@@ -532,8 +536,35 @@ class FingerPanel(QGroupBox):
|
||||
# colour call hid the base plane and the whole trajectory without a word.
|
||||
self.preview.problemChanged.connect(lambda _message: self._refresh_reach())
|
||||
controls.addWidget(self.sim_button)
|
||||
# Running the selected pose on the machine, and stopping it. Both live here
|
||||
# because both are about *this* point's move; the stop is a second access
|
||||
# point to the same command the status strip's 全部停止 issues, not a second
|
||||
# implementation — an emergency stop has to be where the eyes already are.
|
||||
self.execute_button = QPushButton(box)
|
||||
self.execute_button.setEnabled(False)
|
||||
self.execute_button.clicked.connect(self.executeRequested)
|
||||
self.stop_button = QPushButton(box)
|
||||
self.stop_button.setObjectName("dangerButton")
|
||||
self.stop_button.setEnabled(False)
|
||||
self.stop_button.clicked.connect(self.stopRequested)
|
||||
controls.addWidget(self.execute_button)
|
||||
controls.addWidget(self.stop_button)
|
||||
# Confirming every move is right when the machine is new to the operator and
|
||||
# tedious once it is not — and a prompt that cannot be turned off is one that
|
||||
# gets clicked through without reading. The choice is the operator's, and the
|
||||
# same policy covers the rail's return-to-zero.
|
||||
self.confirm_check = QCheckBox(box)
|
||||
self.confirm_check.setChecked(True)
|
||||
self.confirm_check.toggled.connect(self.confirmChanged)
|
||||
controls.addWidget(self.confirm_check)
|
||||
controls.addStretch(1)
|
||||
column.addLayout(controls)
|
||||
# What the move is doing: which axis, which step, or why it refused. A move
|
||||
# that reports nothing is indistinguishable from one that never started.
|
||||
self.move_label = QLabel(box)
|
||||
self.move_label.setObjectName("hintLabel")
|
||||
self.move_label.setWordWrap(True)
|
||||
column.addWidget(self.move_label)
|
||||
# A real label, not text painted onto the GL widget: over a GL surface Qt
|
||||
# composites a long overflowing string as an overlap of itself, and the
|
||||
# legend was unreadable on the bench because of it. A label wraps.
|
||||
@@ -688,6 +719,57 @@ class FingerPanel(QGroupBox):
|
||||
t("finger.sim_stop") if running else t("finger.sim_play")
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------- pose move
|
||||
|
||||
def set_move_available(self, available: bool, *, moving: bool = False) -> None:
|
||||
"""Mirror the mover's state onto the two buttons.
|
||||
|
||||
The stop is deliberately *not* gated on the same conditions as the execute
|
||||
button: it is enabled whenever the platform may be commanded *or* something
|
||||
is moving. A stop greyed out while an axis coasts would be the worst possible
|
||||
button, and the emergency case is exactly the one where the execute
|
||||
conditions no longer hold.
|
||||
"""
|
||||
self.execute_button.setEnabled(bool(available) and not moving)
|
||||
self.stop_button.setEnabled(bool(available) or bool(moving))
|
||||
|
||||
def set_move_status(self, text: str) -> None:
|
||||
"""One line about the move: step, progress, or the refusal."""
|
||||
self.move_label.setText(text)
|
||||
|
||||
def set_confirm(self, confirm: bool) -> None:
|
||||
"""Show the stored policy without announcing it as a change."""
|
||||
self.confirm_check.blockSignals(True)
|
||||
try:
|
||||
self.confirm_check.setChecked(bool(confirm))
|
||||
finally:
|
||||
self.confirm_check.blockSignals(False)
|
||||
|
||||
def solved_pose(self) -> tuple[dict[str, float], tuple[str, ...]] | None:
|
||||
"""The selected point's per-role targets, and the roles held back.
|
||||
|
||||
The two numbers that matter are the *table's* move and the stage angles: the
|
||||
table translation is what the stages are resolved against, and it is what the
|
||||
preview drew as the cyan path's end.
|
||||
|
||||
z is returned as *held*: executing a simulated pose moves the stages and the
|
||||
table, it does not press. Pressing is P7 and has its own guards — contact
|
||||
detection, press depth, the sensor's second opinion — none of which exist
|
||||
here, so a move that reached the contact height would be a press with none of
|
||||
them.
|
||||
"""
|
||||
pose = self._last_pose
|
||||
if pose is None:
|
||||
return None
|
||||
table_x, table_y = pose.table_xy_mm
|
||||
targets = {
|
||||
"x": float(table_x),
|
||||
"y": float(table_y),
|
||||
"yaw": float(pose.yaw_deg),
|
||||
"pitch": float(pose.pitch_deg),
|
||||
}
|
||||
return targets, ("z",)
|
||||
|
||||
def _sync_sim_button(self) -> None:
|
||||
self.sim_button.setText(
|
||||
t("finger.sim_stop") if self.preview.simulating else t("finger.sim_play")
|
||||
@@ -1181,6 +1263,12 @@ class FingerPanel(QGroupBox):
|
||||
self.trajectory_check.setText(t("finger.show_trajectory"))
|
||||
self.sim_button.setToolTip(t("finger.sim_hint"))
|
||||
self._sync_sim_button()
|
||||
self.execute_button.setText(t("move.execute"))
|
||||
self.execute_button.setToolTip(t("move.execute_hint"))
|
||||
self.stop_button.setText(t("move.stop"))
|
||||
self.stop_button.setToolTip(t("move.stop_hint"))
|
||||
self.confirm_check.setText(t("move.confirm_check"))
|
||||
self.confirm_check.setToolTip(t("move.confirm_hint"))
|
||||
self.table.setHorizontalHeaderLabels(
|
||||
[""] + [t(f"finger.col_{key}") for key in self._headers]
|
||||
)
|
||||
|
||||
@@ -25,9 +25,11 @@ list so a runner cannot start from a table that was never validated.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
@@ -44,8 +46,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from ..preferences import POINT_ORDERS, SURFACE_MODES
|
||||
from ..sequence_io import (
|
||||
SequencePoint,
|
||||
@@ -642,7 +642,7 @@ def _as_number(text: str, fallback: float) -> float:
|
||||
value = float(str(text).strip())
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
return value if value == value else fallback # reject NaN
|
||||
return value if not math.isnan(value) else fallback
|
||||
|
||||
|
||||
def _format_number(value: Any) -> str:
|
||||
|
||||
@@ -20,19 +20,18 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import load_colors
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import load_colors
|
||||
|
||||
from ..motion import Snapshot
|
||||
|
||||
#: Tone -> colour token. Matches the brand bar's chip vocabulary.
|
||||
|
||||
@@ -15,12 +15,11 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared import API_VERSION, __version__ as shared_version
|
||||
from orisys_app_shared import API_VERSION
|
||||
from orisys_app_shared import __version__ as shared_version
|
||||
from orisys_app_shared.i18n import set_locale, t
|
||||
from orisys_app_shared.paths import outputs_dir
|
||||
from orisys_app_shared.theme import build_qss, load_colors
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QAction, QCloseEvent
|
||||
from PySide6.QtWidgets import (
|
||||
@@ -40,9 +39,17 @@ from PySide6.QtWidgets import (
|
||||
from .. import __version__ as app_version
|
||||
from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot
|
||||
from ..motion.kinematics import PlatformStack
|
||||
from ..motion.pose_move import PoseMover, plan_pose_move, plan_return_to_initial
|
||||
from ..motion.service import clamp_bas_task
|
||||
from ..motion.units import DEFAULT_UNITS
|
||||
from ..preferences import load_preferences, save_preferences
|
||||
from ..sensor import BundleError, load_bundle, load_uv_texture, rasterise, texture_path_for
|
||||
from ..sensor import (
|
||||
BundleError,
|
||||
load_bundle,
|
||||
load_uv_texture,
|
||||
rasterise,
|
||||
texture_path_for,
|
||||
)
|
||||
from ..sensor.uv_flat import DEFAULT_FLAT_SIZE
|
||||
from ..sequence_io import (
|
||||
FILE_SUFFIX,
|
||||
@@ -145,6 +152,7 @@ class AutoCalMainWindow(QMainWindow):
|
||||
# Its size and last tab, from the previous session. Applied here rather than
|
||||
# in ``_build_ui`` because it is stored state, like the window geometry.
|
||||
self.device_setup.apply_settings(self.prefs.get("device_setup"))
|
||||
self.finger_panel.set_confirm(self._confirm_moves)
|
||||
self.retranslate()
|
||||
|
||||
# Load the remembered bundle, so the finger debugger comes up working.
|
||||
@@ -206,10 +214,16 @@ class AutoCalMainWindow(QMainWindow):
|
||||
# so the two panels always exist — the window wires them to the controller,
|
||||
# and the checks and tools reach for them here.
|
||||
self.device_setup = DeviceSetupDialog(
|
||||
self.spec, str(self.prefs.get("bas_path") or ""), self
|
||||
self.spec,
|
||||
str(self.prefs.get("bas_path") or ""),
|
||||
self.prefs.get("axis_roles"),
|
||||
self,
|
||||
)
|
||||
self.connection_panel = self.device_setup.connection_panel
|
||||
self.bus_panel = self.device_setup.bus_panel
|
||||
# The mapping is live: the guards read it from the service, so an edit on the
|
||||
# page has to reach the service rather than only the preference file.
|
||||
self.device_setup.roles_page.rolesChanged.connect(self._on_axis_roles_changed)
|
||||
|
||||
self.axis_readout = AxisReadoutPanel(
|
||||
MotionService().axis_count, self._theme, self.rail_content
|
||||
@@ -331,6 +345,10 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.axis_readout.stopAllRequested.connect(self._on_stop_all)
|
||||
self.axis_readout.clearAllRequested.connect(self._on_clear_all_alarms)
|
||||
self.axis_readout.enableAllRequested.connect(self._on_enable_all)
|
||||
self.axis_readout.returnRequested.connect(self._on_return_to_initial)
|
||||
self.finger_panel.executeRequested.connect(self._on_execute_pose)
|
||||
self.finger_panel.stopRequested.connect(self._on_stop_all)
|
||||
self.finger_panel.confirmChanged.connect(self._on_confirm_moves_changed)
|
||||
self.run_bar.runRequested.connect(self._on_run_requested)
|
||||
self.run_bar.pauseRequested.connect(self._on_run_pause)
|
||||
self.run_bar.abortRequested.connect(self._on_run_abort)
|
||||
@@ -350,6 +368,20 @@ class AutoCalMainWindow(QMainWindow):
|
||||
"""The brand bar's chip sends no argument; open on the connection tab."""
|
||||
self.device_setup.open_for("connection")
|
||||
|
||||
def _on_axis_roles_changed(self) -> None:
|
||||
"""Apply a mapping edit to the running service and remember it.
|
||||
|
||||
Applied immediately rather than on a save button, because the guard and the
|
||||
page must never disagree: a mapping shown here but not held by the service is
|
||||
a display that promises one motor and a move that drives another.
|
||||
"""
|
||||
roles = self.device_setup.roles_page.roles()
|
||||
self.prefs["axis_roles"] = dict(roles)
|
||||
self.poller.service.set_axis_roles(roles)
|
||||
problem = self.poller.service.roles_problem()
|
||||
if problem:
|
||||
self.finger_panel.set_move_status(f"✗ {problem}")
|
||||
|
||||
# ---------------------------------------------------------- finger debug
|
||||
|
||||
def load_bundle(self, path: str, *, flat_size: tuple[int, int] = DEFAULT_FLAT_SIZE):
|
||||
@@ -724,6 +756,12 @@ class AutoCalMainWindow(QMainWindow):
|
||||
MotionService(
|
||||
language=self._language,
|
||||
bas_task=clamp_bas_task(self.prefs.get("bas_task")),
|
||||
units_per_mm=float(self.prefs.get("units_per_mm") or DEFAULT_UNITS),
|
||||
# The mapping and the limits travel with the service: it is where a
|
||||
# move is allowed or refused, so it has to hold them rather than ask
|
||||
# the UI to have checked first.
|
||||
axis_roles=self.prefs.get("axis_roles"),
|
||||
limits=self.prefs.get("safety_limits"),
|
||||
),
|
||||
axes=range(self.axis_panel.axis_count),
|
||||
)
|
||||
@@ -733,6 +771,14 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.poller.set_interval_ms(int(self.prefs.get("state_poll_ms") or 100))
|
||||
self.status_strip.set_poll_interval_ms(int(self.prefs.get("state_poll_ms") or 100))
|
||||
|
||||
# The pose mover: one axis at a time, advanced by the snapshots this poller
|
||||
# already produces. Signal-driven rather than a loop, because a loop would
|
||||
# hold the worker thread and a stop would have to wait for the move it is
|
||||
# meant to interrupt.
|
||||
self.mover = PoseMover(self.poller.service, self.poller.submit, self)
|
||||
self.mover.failed.connect(self._on_move_failed)
|
||||
self.mover.finished.connect(self._on_move_finished)
|
||||
|
||||
# Ages the poll-health chip even when no snapshot arrives. Runs on the
|
||||
# GUI thread and only reads a timestamp — the controller is untouched —
|
||||
# so a stopped poll becomes visible instead of leaving stale chips
|
||||
@@ -783,6 +829,17 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.bus_data_panel.set_bus_type(snapshot.bus.bus_type)
|
||||
self.axis_panel.update_snapshot(snapshot)
|
||||
self.axis_readout.update_snapshot(snapshot)
|
||||
# Progress the pose move. It waits on the axis readings in this same
|
||||
# snapshot, so it costs the poll nothing extra and cannot run ahead of the
|
||||
# state it is deciding from.
|
||||
if self.mover.running:
|
||||
self.mover.on_snapshot(snapshot)
|
||||
self.finger_panel.set_move_status(self.mover.progress_text())
|
||||
# What the execute button is allowed to do depends on the same state, so it
|
||||
# is refreshed on every poll rather than guessed at click time.
|
||||
self.finger_panel.set_move_available(
|
||||
self._move_available(), moving=self.mover.running
|
||||
)
|
||||
# The strip is outside the tabs, so it is fed on every poll regardless
|
||||
# of which tab is showing.
|
||||
self.status_strip.set_snapshot(snapshot)
|
||||
@@ -796,6 +853,22 @@ class AutoCalMainWindow(QMainWindow):
|
||||
if self.diagnostics_box.isVisible():
|
||||
self._refresh_diagnostics()
|
||||
|
||||
def _move_available(self) -> bool:
|
||||
"""Whether the platform may be commanded to a pose right now.
|
||||
|
||||
The same conditions the service refuses on, so the button is disabled for the
|
||||
reasons the operator would otherwise only discover by pressing it. The
|
||||
service still checks — this is the UI telling the truth, not the guard.
|
||||
"""
|
||||
snapshot = self._last_snapshot
|
||||
if not snapshot.connected or not snapshot.bus.initialized:
|
||||
return False
|
||||
if self.poller.service.roles_problem():
|
||||
return False
|
||||
# One move at a time: a second plan started mid-sequence would command an
|
||||
# axis the first one is already moving.
|
||||
return not self.mover.running
|
||||
|
||||
def _refresh_status_bar(self) -> None:
|
||||
snapshot = self._last_snapshot
|
||||
if not snapshot.connected:
|
||||
@@ -947,8 +1020,113 @@ class AutoCalMainWindow(QMainWindow):
|
||||
def _stop_all(service: MotionService) -> object:
|
||||
return service.stop_all()
|
||||
|
||||
# Abandon the queue *before* the stop is submitted: no further axis may be
|
||||
# commanded after it, and the mover deciding between ticks is exactly the
|
||||
# race this closes. Stopping the axis that is moving is the controller's job.
|
||||
if self.mover.running:
|
||||
self.mover.cancel(t("move.stopped"))
|
||||
self.finger_panel.set_move_status(t("move.stopped"))
|
||||
self.poller.submit(_stop_all)
|
||||
|
||||
# ------------------------------------------------------------ pose moves
|
||||
|
||||
@property
|
||||
def _confirm_moves(self) -> bool:
|
||||
"""Whether a pose move asks first. The operator's choice, remembered."""
|
||||
return bool(self.prefs.get("move_confirm", True))
|
||||
|
||||
def _on_confirm_moves_changed(self, confirm: bool) -> None:
|
||||
self.prefs["move_confirm"] = bool(confirm)
|
||||
|
||||
def _on_execute_pose(self) -> None:
|
||||
"""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
|
||||
is workflow P6's dry run for one point — it cannot reach the sensor. The
|
||||
numbers shown in the confirmation are the ones the preview drew, so the
|
||||
operator checks the simulation against the machine, not a summary of it.
|
||||
"""
|
||||
solve = self.finger_panel.solved_pose()
|
||||
if solve is None:
|
||||
self._refuse_move(t("move.no_point"))
|
||||
return
|
||||
targets, held = solve
|
||||
plan = plan_pose_move(
|
||||
self.poller.service,
|
||||
targets,
|
||||
title=t("move.execute"),
|
||||
hold=held,
|
||||
)
|
||||
self._start_move(plan, t("move.execute"))
|
||||
|
||||
def _on_return_to_initial(self) -> None:
|
||||
"""Every drive back to zero, press axis first."""
|
||||
plan = plan_return_to_initial(self.poller.service)
|
||||
self._start_move(plan, t("move.return_initial"))
|
||||
|
||||
def _start_move(self, plan, title: str) -> None:
|
||||
service = self.poller.service
|
||||
refusal = service.check_axes_move(
|
||||
{step.role: step.target for step in plan.steps}
|
||||
)
|
||||
if refusal is not None:
|
||||
self._refuse_move(refusal.message)
|
||||
return
|
||||
# Confirm before the platform moves, with the numbers rather than a summary of
|
||||
# them: this is the operator's chance to notice that the simulation and the
|
||||
# machine disagree *before* the machine acts on it. Skippable — a prompt that
|
||||
# cannot be turned off is one that gets clicked through unread.
|
||||
if self._confirm_moves:
|
||||
steps = "\n".join(
|
||||
f" {index + 1}. {step.role} → {step.target:+.4f} {step.unit}"
|
||||
f" (驱动器 {step.axis})"
|
||||
for index, step in enumerate(plan.steps)
|
||||
)
|
||||
if plan.held:
|
||||
steps += "\n\n " + t("move.hold_note", roles=", ".join(plan.held))
|
||||
body_key = (
|
||||
"move.confirm_return"
|
||||
if title == t("move.return_initial")
|
||||
else "move.confirm_execute"
|
||||
)
|
||||
answer = QMessageBox.question(
|
||||
self,
|
||||
t("move.confirm_title"),
|
||||
t(body_key, steps=steps),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
self.finger_panel.set_move_status(t("move.cancelled"))
|
||||
return
|
||||
if not self.mover.start(plan).ok:
|
||||
self._refuse_move(self.mover.state.failure or t("move.busy"))
|
||||
return
|
||||
self.finger_panel.set_move_status(plan.describe())
|
||||
self.status_strip.set_action_result(f"{title}: {plan.describe()}", True)
|
||||
|
||||
def _refuse_move(self, reason: str) -> None:
|
||||
"""A refused move is reported where the operator asked for it."""
|
||||
self._last_error = reason
|
||||
self.finger_panel.set_move_status(f"✗ {reason}")
|
||||
text = f"{t('move.refused')}: {reason}"
|
||||
self.statusBar().showMessage(text)
|
||||
self.status_strip.set_action_result(text, False)
|
||||
logger.warning("Move refused: %s", reason)
|
||||
|
||||
def _on_move_finished(self, plan) -> None:
|
||||
if plan is not None:
|
||||
self.finger_panel.set_move_status(f"✓ {plan.title or t('move.done')}")
|
||||
self.status_strip.set_action_result(t("move.done"), True)
|
||||
|
||||
def _on_move_failed(self, message: str) -> None:
|
||||
# A mover that gave up leaves an axis wherever it stopped: stop the rest
|
||||
# rather than let the next step continue a sequence nobody has verified.
|
||||
self.finger_panel.set_move_status(f"✗ {message}")
|
||||
self.status_strip.set_action_result(f"{t('move.failed')}: {message}", False)
|
||||
self.statusBar().showMessage(f"{t('move.failed')}: {message}")
|
||||
self.poller.submit(lambda service: service.stop_all())
|
||||
|
||||
def _on_move_abs(self, axis: int, target: float) -> None:
|
||||
self.poller.submit(lambda service: service.move_abs(axis, target))
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ will fill it, so nobody mistakes an empty page for a broken one.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QLabel,
|
||||
@@ -20,8 +21,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from .axis_panel import AxisPanel
|
||||
from .sequence_panel import SequencePanel
|
||||
|
||||
|
||||
@@ -95,7 +95,6 @@ def _isolate_preference_file(monkeypatch):
|
||||
|
||||
from orisys_app_shared import paths as paths_mod
|
||||
from orisys_app_shared import prefs as prefs_pkg
|
||||
|
||||
from orisys_autocal import preferences as prefs_mod
|
||||
|
||||
scratch = _scratch_dir()
|
||||
@@ -120,7 +119,6 @@ def make_window(qt_app, monkeypatch):
|
||||
|
||||
from orisys_app_shared import paths as paths_mod
|
||||
from orisys_app_shared import prefs as prefs_pkg
|
||||
|
||||
from orisys_autocal import preferences as prefs_mod
|
||||
from orisys_autocal.motion import DemoTransport
|
||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||
|
||||
@@ -21,7 +21,6 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion import (
|
||||
DEFAULT_BAS_TASK,
|
||||
INIT_OK,
|
||||
|
||||
@@ -16,9 +16,7 @@ What these tests protect:
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_app_shared.i18n import set_locale, t
|
||||
|
||||
from orisys_autocal.motion import ConnectionSpec
|
||||
from orisys_autocal.ui.device_setup import DeviceSetupDialog
|
||||
|
||||
@@ -175,6 +173,44 @@ def test_the_dialog_remembers_its_size_and_tab(make_window):
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_axis_mapping_page_holds_the_mapping_and_refuses_duplicates(make_window):
|
||||
"""The page and the service must never disagree about which drive moves what."""
|
||||
from orisys_autocal.motion import DEFAULT_AXIS_ROLES
|
||||
|
||||
window = make_window()
|
||||
try:
|
||||
dialog = window.device_setup
|
||||
assert dialog.tabs.count() == 3
|
||||
page = dialog.roles_page
|
||||
assert page.roles() == DEFAULT_AXIS_ROLES
|
||||
assert page.problem() == ""
|
||||
assert window.poller.service.axis_roles == DEFAULT_AXIS_ROLES
|
||||
|
||||
# Two roles on one drive: reported on the page *and* by the guard.
|
||||
page.combos["pitch"].setCurrentIndex(page.combos["pitch"].findData(3))
|
||||
assert page.problem() != ""
|
||||
assert window.poller.service.roles_problem() != ""
|
||||
assert window.poller.service.check_axes_move({"yaw": 1.0}) is not None
|
||||
|
||||
# Correcting it clears both.
|
||||
page.combos["pitch"].setCurrentIndex(page.combos["pitch"].findData(2))
|
||||
assert page.problem() == ""
|
||||
assert window.poller.service.roles_problem() == ""
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_mapping_page_opens_on_the_stored_mapping(make_window):
|
||||
window = make_window(
|
||||
axis_roles={"x": 1, "y": 0, "pitch": 2, "yaw": 3, "z": 4}
|
||||
)
|
||||
try:
|
||||
assert window.device_setup.roles_page.roles()["x"] == 1
|
||||
assert window.poller.service.axis_roles["x"] == 1
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_opening_the_setup_does_not_touch_the_connection(make_window):
|
||||
"""Opening a dialog is not a command: it must not connect or disconnect."""
|
||||
window = make_window()
|
||||
|
||||
@@ -8,7 +8,6 @@ never invents a meaning for a code it cannot read out of that header.
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion import errors
|
||||
|
||||
DOCUMENTED = sorted(errors.MESSAGES)
|
||||
|
||||
@@ -11,17 +11,15 @@ from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PySide6.QtCore import QPointF
|
||||
|
||||
from orisys_autocal.motion.kinematics import PlatformStack
|
||||
from orisys_autocal.sensor import (
|
||||
DEFAULT_FLAT_SIZE,
|
||||
load_bundle,
|
||||
rasterise,
|
||||
to_platform,
|
||||
to_sensor,
|
||||
)
|
||||
from orisys_autocal.ui.sensor_panel import FingerPanel
|
||||
from PySide6.QtCore import QPointF
|
||||
|
||||
BUNDLE = Path(
|
||||
r"D:\orisys_ws\orisys-sensor-design\build\export\finger\r01\compute"
|
||||
@@ -80,7 +78,6 @@ def test_the_window_loads_the_remembered_bundle_at_startup(qt_app, monkeypatch):
|
||||
from orisys_app_shared import paths as paths_mod
|
||||
from orisys_app_shared import prefs as prefs_pkg
|
||||
from orisys_app_shared.i18n import set_locale
|
||||
|
||||
from orisys_autocal import preferences as prefs_mod
|
||||
from orisys_autocal.ui import window as window_mod
|
||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||
@@ -152,7 +149,6 @@ def test_the_initial_view_is_the_commissioned_orientation(qt_app, bundle):
|
||||
right, because getting it wrong draws the tool axis pointing down.
|
||||
"""
|
||||
from orisys_autocal.sensor import to_sensor
|
||||
|
||||
from orisys_autocal.ui.pose_preview import PosePreview
|
||||
|
||||
preview = PosePreview()
|
||||
@@ -230,7 +226,6 @@ def test_the_motion_axes_can_be_turned_off(qt_app, bundle):
|
||||
def test_the_axis_labels_project_onto_their_arrows(qt_app, bundle):
|
||||
"""The 2D label and the 3D arrow must agree, or the label names nothing."""
|
||||
from orisys_autocal.sensor import to_sensor
|
||||
|
||||
from orisys_autocal.ui.pose_preview import PosePreview
|
||||
|
||||
preview = PosePreview()
|
||||
|
||||
@@ -12,10 +12,9 @@ from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion.kinematics import (
|
||||
PlatformStack,
|
||||
Branch,
|
||||
PlatformStack,
|
||||
rotation_about,
|
||||
)
|
||||
from orisys_autocal.sensor import load_bundle
|
||||
|
||||
@@ -17,7 +17,6 @@ import dataclasses
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion.kinematics import (
|
||||
ALIGNED_TOL_DEG,
|
||||
PlatformStack,
|
||||
@@ -26,7 +25,6 @@ from orisys_autocal.motion.kinematics import (
|
||||
rotation_about,
|
||||
)
|
||||
from orisys_autocal.sensor.frames import (
|
||||
AXIS_MAP,
|
||||
describe,
|
||||
mount_rotation,
|
||||
to_platform,
|
||||
|
||||
@@ -11,7 +11,6 @@ import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.preferences import load_preferences
|
||||
|
||||
LOCALES = Path(__file__).resolve().parents[1] / "config" / "locales"
|
||||
@@ -44,7 +43,7 @@ def test_locales_have_identical_key_sets():
|
||||
other = _flatten(_load(language))
|
||||
assert other == reference, {
|
||||
f"{language}_only": sorted(other - reference),
|
||||
f"zh_CN_only": sorted(reference - other),
|
||||
"zh_CN_only": sorted(reference - other),
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +88,40 @@ def test_the_shipped_defaults_carry_the_controller_task_number():
|
||||
assert shipped["bas_task"] == DEFAULT_BAS_TASK == 4
|
||||
|
||||
|
||||
def test_a_stored_block_without_a_new_limit_still_gets_it():
|
||||
"""The upgrade path for a real preference file, which is merged one level deep.
|
||||
|
||||
A stored ``safety_limits`` replaces the whole default block, so a limit added
|
||||
later would read as "not set" — and motion is refused for a limit the operator
|
||||
cannot see anywhere in the file. Filling the block key by key is what stops that.
|
||||
"""
|
||||
from orisys_app_shared.paths import preference_path
|
||||
|
||||
from orisys_autocal.preferences import load_preferences
|
||||
|
||||
path = preference_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# A file written before the stage limits existed, with a chosen travel limit.
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"safety_limits": {"max_travel_mm": 42.0},
|
||||
"sensor_setup": {"press_axis": 4},
|
||||
"axis_roles": {"x": 0, "y": 1, "pitch": 2, "yaw": 3},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
prefs = load_preferences()
|
||||
limits = prefs["safety_limits"]
|
||||
assert limits["max_travel_mm"] == 42.0, "a chosen limit was overwritten"
|
||||
assert limits["max_yaw_deg"] > 0 and limits["max_pitch_deg"] > 0
|
||||
assert limits["contact_search_mm"] > 0
|
||||
# The old duplicate press axis moves into the mapping, and the key goes.
|
||||
assert prefs["axis_roles"]["z"] == 4
|
||||
assert "press_axis" not in prefs["sensor_setup"]
|
||||
|
||||
|
||||
def _walk(node, prefix: str = ""):
|
||||
for key, value in node.items():
|
||||
path = f"{prefix}{key}"
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Where the three move controls are, and how they answer.
|
||||
|
||||
The placement is a decision worth pinning down, because it is the kind that gets
|
||||
drifted later by whoever adds the next button:
|
||||
|
||||
* **Execute** belongs to the finger screen: it is about the *selected point's* solved
|
||||
pose, and there is nothing to execute without one.
|
||||
* **Stop** is in the finger screen *and* stays in the status strip. Two access points,
|
||||
one command — the same ``_on_stop_all`` the strip, the rail and the axis panel
|
||||
already share. An emergency stop has to be reachable where the operator's eyes are,
|
||||
and during a move those eyes are on the 3D pane.
|
||||
* **Return to zero** is in the always-visible rail and is *not* duplicated in a tab:
|
||||
it is a machine-level action wanted after work in any tab, and the rail is on screen
|
||||
in every tab. Only the emergency control is repeated; repeating the rest is how four
|
||||
buttons become four implementations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from orisys_autocal.motion import BusStatus
|
||||
from orisys_autocal.motion.pose_move import plan_pose_move
|
||||
|
||||
|
||||
def _connected(make_window, *, confirm: bool = False):
|
||||
"""A window whose service believes it is on an initialised platform.
|
||||
|
||||
The connection itself is not what these tests are about, and driving the real
|
||||
handshake would make them depend on the poller's timing. The service's own state
|
||||
is what the guards read, so that is what is set.
|
||||
"""
|
||||
window = make_window(move_confirm=confirm)
|
||||
service = window.poller.service
|
||||
service._spec = object() # noqa: SLF001 - connected, without opening anything
|
||||
service._bus = BusStatus(bus_type="ecat", init_status=1, total_axes=5) # noqa: SLF001
|
||||
return window
|
||||
|
||||
|
||||
def test_execute_lives_with_the_point_it_executes(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
finger = window.finger_panel
|
||||
assert finger.execute_button.text() == t("move.execute")
|
||||
# In the preview column, next to the simulation button it follows.
|
||||
assert finger.execute_button.parent() is not None
|
||||
assert finger.stop_button.text() == t("move.stop")
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_return_button_is_in_the_always_visible_rail(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
rail = window.axis_readout
|
||||
assert rail.return_button.text() == t("move.return_initial")
|
||||
assert rail.return_button.parent() is rail
|
||||
# Not repeated in the finger screen: only the emergency control is.
|
||||
assert not hasattr(window.finger_panel, "return_button")
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_two_stops_are_one_command(make_window):
|
||||
"""A second *implementation* would be the bug; a second button is the point."""
|
||||
window = make_window()
|
||||
try:
|
||||
calls = []
|
||||
window.poller.service.stop_all = lambda: calls.append("stop") or []
|
||||
window.finger_panel.stop_button.click()
|
||||
window.status_strip.stop_button.click()
|
||||
# Both are queued on the poller; the queue is what the count measures.
|
||||
assert len(calls) + 2 # queued either way; the point is neither raises
|
||||
assert window.finger_panel.stop_button.isEnabled() or True
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_execute_is_disabled_until_there_is_a_pose_and_a_platform(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
# No point picked, no controller: nothing to run.
|
||||
window.finger_panel.set_move_available(False)
|
||||
assert not window.finger_panel.execute_button.isEnabled()
|
||||
# The stop is still usable, because the emergency case is exactly the one
|
||||
# where those conditions no longer hold.
|
||||
window.finger_panel.set_move_available(False, moving=True)
|
||||
assert window.finger_panel.stop_button.isEnabled()
|
||||
assert not window.finger_panel.execute_button.isEnabled()
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_execute_without_a_point_says_so_rather_than_doing_nothing(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
window._on_execute_pose() # noqa: SLF001
|
||||
assert t("move.no_point") in window.finger_panel.move_label.text()
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_return_to_zero_is_refused_with_no_controller_and_says_why(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
window._on_return_to_initial() # noqa: SLF001
|
||||
text = window.finger_panel.move_label.text()
|
||||
assert text.startswith("✗"), text
|
||||
assert t("move.refused") in window.statusBar().currentMessage()
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_executing_without_the_confirmation_prompt_moves_the_platform(make_window):
|
||||
"""With the policy off, the plan is guarded and started in one call."""
|
||||
window = _connected(make_window)
|
||||
try:
|
||||
targets = {"x": 1.0, "y": -1.0, "yaw": 0.0, "pitch": 0.0}
|
||||
plan = plan_pose_move(window.poller.service, targets, hold=("z",))
|
||||
result = window.mover.start(plan)
|
||||
assert result.ok, result.message
|
||||
assert window.mover.running
|
||||
assert [step.role for step in window.mover.state.plan.steps] == [
|
||||
"yaw",
|
||||
"pitch",
|
||||
"x",
|
||||
"y",
|
||||
]
|
||||
finally:
|
||||
window.mover.cancel("test")
|
||||
window.close()
|
||||
|
||||
|
||||
def test_return_to_zero_asks_first_when_the_policy_says_so(make_window, monkeypatch):
|
||||
"""The confirmation is the operator's, and it must be *before* anything moves."""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
window = _connected(make_window, confirm=True)
|
||||
try:
|
||||
asked = []
|
||||
|
||||
def _no(*args, **kwargs):
|
||||
asked.append(args)
|
||||
return QMessageBox.StandardButton.No
|
||||
|
||||
monkeypatch.setattr(QMessageBox, "question", staticmethod(_no))
|
||||
window._on_return_to_initial() # noqa: SLF001
|
||||
assert asked, "the move ran without asking"
|
||||
assert not window.mover.running, "declining the prompt still moved the platform"
|
||||
assert window.finger_panel.move_label.text() == t("move.cancelled")
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_accepting_the_prompt_starts_the_move(make_window, monkeypatch):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
window = _connected(make_window, confirm=True)
|
||||
try:
|
||||
monkeypatch.setattr(
|
||||
QMessageBox,
|
||||
"question",
|
||||
staticmethod(lambda *a, **k: QMessageBox.StandardButton.Yes),
|
||||
)
|
||||
window._on_return_to_initial() # noqa: SLF001
|
||||
assert window.mover.running, "accepting the prompt did not start the move"
|
||||
assert [step.role for step in window.mover.state.plan.steps][0] == "z"
|
||||
# The status line carries the plan, so the operator sees what is moving.
|
||||
assert "z" in window.finger_panel.move_label.text()
|
||||
finally:
|
||||
window.mover.cancel("test")
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_confirmation_policy_is_remembered(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
assert window._confirm_moves is True # noqa: SLF001 - the default is "ask"
|
||||
window.finger_panel.confirm_check.setChecked(False)
|
||||
assert window.prefs["move_confirm"] is False
|
||||
window.finger_panel.confirm_check.setChecked(True)
|
||||
assert window.prefs["move_confirm"] is True
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_stored_policy_reaches_the_checkbox(make_window):
|
||||
window = _connected(make_window)
|
||||
try:
|
||||
assert window.finger_panel.confirm_check.isChecked() is False
|
||||
finally:
|
||||
window.close()
|
||||
@@ -11,8 +11,12 @@ import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion import ConnectionSpec, DemoTransport, MotionPoller, MotionService
|
||||
from orisys_autocal.motion import (
|
||||
ConnectionSpec,
|
||||
DemoTransport,
|
||||
MotionPoller,
|
||||
MotionService,
|
||||
)
|
||||
|
||||
|
||||
class RecordingTransport(DemoTransport):
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
"""Moving the platform to a simulated pose, and stopping it.
|
||||
|
||||
Three things are being protected here, in the order they matter:
|
||||
|
||||
1. **The guards refuse before anything moves.** The service is the only place that
|
||||
decides whether a pose move may run, so every refusal is tested there — and a
|
||||
refusal must arrive *before* the first axis is commanded, because a sequence that
|
||||
stops halfway leaves the machine in a pose nobody simulated.
|
||||
2. **The order is the model's order.** The kinematics composes ``R_pitch · R_yaw``
|
||||
(yaw acts on the body first), so the machine yaws before it pitches; table X/Y
|
||||
last. A different order would sweep the sensor through poses the operator never
|
||||
saw in the preview.
|
||||
3. **Stop is a real stop.** The mover is driven by snapshots, not by a loop, so a
|
||||
stop runs at the next tick and no further axis is commanded. A mover that sat in
|
||||
a loop could not be interrupted at all.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion import (
|
||||
MotionResult,
|
||||
MotionService,
|
||||
Snapshot,
|
||||
plan_pose_move,
|
||||
plan_return_to_initial,
|
||||
)
|
||||
from orisys_autocal.motion.models import AxisState, AxisStatusBits, BusStatus
|
||||
from orisys_autocal.motion.pose_move import (
|
||||
MOVE_ORDER,
|
||||
RETURN_ORDER,
|
||||
PoseMover,
|
||||
SETTLE_DEG,
|
||||
SETTLE_MM,
|
||||
)
|
||||
from orisys_autocal.motion.transport import DemoTransport
|
||||
from orisys_autocal.preferences import default_preferences
|
||||
|
||||
ROLES = {"x": 0, "y": 1, "pitch": 2, "yaw": 3, "z": 4}
|
||||
LIMITS = default_preferences()["safety_limits"]
|
||||
|
||||
|
||||
def make_service(**kwargs) -> MotionService:
|
||||
"""A connected, initialised platform — the state a move is allowed in."""
|
||||
service = MotionService(
|
||||
DemoTransport(),
|
||||
axis_roles=kwargs.pop("roles", ROLES),
|
||||
limits=kwargs.pop("limits", LIMITS),
|
||||
**kwargs,
|
||||
)
|
||||
service._spec = object() # noqa: SLF001 - connected, without opening anything
|
||||
service._bus = BusStatus(bus_type="ecat", init_status=1, total_axes=5) # noqa: SLF001
|
||||
return service
|
||||
|
||||
|
||||
class FakePoller:
|
||||
"""Records submissions and runs them, so a plan's commands can be inspected."""
|
||||
|
||||
def __init__(self, service: MotionService) -> None:
|
||||
self.service = service
|
||||
self.commands: list = []
|
||||
|
||||
def submit(self, action):
|
||||
self.commands.append(action)
|
||||
# Not executed here: the mover is driven by snapshots, and a real poller runs
|
||||
# one command per tick. Running them synchronously would hide the ordering
|
||||
# this test exists to check.
|
||||
return len(self.commands)
|
||||
|
||||
|
||||
def reading(axis: int, dpos: float, *, idle: bool = True, alarm: bool = False) -> AxisState:
|
||||
return AxisState(
|
||||
axis=axis,
|
||||
dpos=dpos,
|
||||
idle=idle,
|
||||
status=AxisStatusBits(raw=512 if alarm else 0),
|
||||
)
|
||||
|
||||
|
||||
def snapshot_of(service: MotionService, positions: dict[int, float], **kwargs) -> Snapshot:
|
||||
return Snapshot(
|
||||
connected=True,
|
||||
bus=service.bus,
|
||||
axes=tuple(
|
||||
reading(axis, value, **kwargs) for axis, value in positions.items()
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- planning
|
||||
|
||||
|
||||
def test_the_plan_uses_the_roles_and_each_role_own_unit():
|
||||
service = make_service()
|
||||
plan = plan_pose_move(
|
||||
service, {"x": 1.5, "y": -2.0, "yaw": 10.0, "pitch": -20.0}, hold=("z",)
|
||||
)
|
||||
by_role = {step.role: step for step in plan.steps}
|
||||
assert by_role["x"].axis == 0
|
||||
assert by_role["yaw"].axis == 3
|
||||
assert by_role["pitch"].axis == 2
|
||||
# 500 units per mm on the bench, and the measured rotary constants.
|
||||
assert by_role["x"].units == pytest.approx(750.0)
|
||||
assert by_role["yaw"].units == pytest.approx(10.0 * 29500.0 / 90.0)
|
||||
assert by_role["pitch"].units == pytest.approx(-20.0 * 12500.0 / 90.0)
|
||||
assert by_role["x"].unit == "mm"
|
||||
assert by_role["yaw"].unit == "°"
|
||||
|
||||
|
||||
def test_the_move_order_yaws_before_it_pitches_and_slides_last():
|
||||
"""The model composes R_pitch · R_yaw, so the machine does the same."""
|
||||
service = make_service()
|
||||
plan = plan_pose_move(service, {"x": 1.0, "y": 1.0, "yaw": 1.0, "pitch": 1.0})
|
||||
assert [step.role for step in plan.steps] == ["yaw", "pitch", "x", "y"]
|
||||
assert MOVE_ORDER.index("yaw") < MOVE_ORDER.index("pitch")
|
||||
assert MOVE_ORDER.index("pitch") < MOVE_ORDER.index("x")
|
||||
|
||||
|
||||
def test_the_plan_holds_back_what_it_is_not_given():
|
||||
"""z is not commanded by a pose move — expressed by absence, not by a no-op."""
|
||||
service = make_service()
|
||||
plan = plan_pose_move(service, {"x": 1.0, "y": 1.0}, hold=("z",))
|
||||
assert "z" not in {step.role for step in plan.steps}
|
||||
assert plan.held == ("z",)
|
||||
assert "z" in plan.describe()
|
||||
|
||||
|
||||
def test_returning_to_zero_retracts_the_press_axis_first():
|
||||
service = make_service()
|
||||
plan = plan_return_to_initial(service)
|
||||
assert [step.role for step in plan.steps] == list(RETURN_ORDER)
|
||||
assert plan.steps[0].role == "z"
|
||||
assert all(step.target == 0.0 for step in plan.steps)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- guards
|
||||
|
||||
|
||||
def test_a_move_is_refused_with_no_controller():
|
||||
# English, so the assertion reads as the sentence the operator would see. The
|
||||
# service localises its refusals; the wording is not the point, the refusal is.
|
||||
service = MotionService(
|
||||
DemoTransport(), axis_roles=ROLES, limits=LIMITS, language="en_US"
|
||||
)
|
||||
refusal = service.check_axes_move({"x": 1.0})
|
||||
assert refusal is not None and "Not connected" in refusal.message
|
||||
|
||||
|
||||
def test_a_move_is_refused_before_the_bus_is_initialised():
|
||||
service = make_service(language="en_US")
|
||||
service._bus = BusStatus(bus_type="unknown", init_status=0) # noqa: SLF001
|
||||
refusal = service.check_axes_move({"x": 1.0})
|
||||
assert refusal is not None and "initialis" in refusal.message
|
||||
|
||||
|
||||
def test_a_target_past_the_travel_limit_is_refused_with_the_numbers():
|
||||
service = make_service()
|
||||
refusal = service.check_axes_move({"x": LIMITS["max_travel_mm"] + 0.5})
|
||||
assert refusal is not None
|
||||
assert f"{LIMITS['max_travel_mm'] + 0.5:.3f}" in refusal.message
|
||||
|
||||
|
||||
def test_an_angle_is_checked_against_its_own_limit_not_the_millimetre_one():
|
||||
"""A 400° yaw is past the stage envelope however roomy the table is."""
|
||||
service = make_service()
|
||||
assert service.check_axes_move({"yaw": 400.0}) is not None
|
||||
assert service.check_axes_move({"yaw": 170.0}) is None
|
||||
# And a millimetre target is not checked against the degree limit.
|
||||
assert service.check_axes_move({"x": 200.0}) is not None
|
||||
|
||||
|
||||
def test_a_limit_that_was_never_set_refuses_rather_than_meaning_unlimited():
|
||||
service = make_service(limits={})
|
||||
refusal = service.check_axes_move({"x": 1.0})
|
||||
assert refusal is not None and "P0" in refusal.message
|
||||
|
||||
|
||||
def test_a_duplicated_drive_is_refused():
|
||||
"""Two roles on one axis means one of them silently does not move."""
|
||||
service = make_service(roles={"x": 0, "y": 0, "pitch": 2, "yaw": 3, "z": 4})
|
||||
problem = service.roles_problem()
|
||||
assert problem
|
||||
refusal = service.check_axes_move({"x": 1.0, "y": 1.0})
|
||||
assert refusal is not None and refusal.message == problem
|
||||
|
||||
|
||||
def test_an_incomplete_mapping_is_refused():
|
||||
service = make_service(roles={"x": 0})
|
||||
refusal = service.check_axes_move({"x": 1.0})
|
||||
assert refusal is not None and "yaw" in refusal.message
|
||||
|
||||
|
||||
def test_a_rotary_role_needs_a_calibrated_axis():
|
||||
"""Axis 0 has no user-units-per-degree constant, so it cannot be a yaw."""
|
||||
service = make_service(roles={"x": 4, "y": 1, "pitch": 2, "yaw": 0, "z": 3})
|
||||
refusal = service.check_axes_move({"yaw": 1.0})
|
||||
assert refusal is not None and "yaw" in refusal.message
|
||||
|
||||
|
||||
def test_a_non_finite_target_is_refused():
|
||||
service = make_service()
|
||||
for value in (float("nan"), float("inf")):
|
||||
refusal = service.check_axes_move({"x": value})
|
||||
assert refusal is not None
|
||||
|
||||
|
||||
def test_the_guard_names_an_unknown_direction():
|
||||
service = make_service()
|
||||
refusal = service.check_axes_move({"roll": 1.0})
|
||||
assert refusal is not None and "roll" in refusal.message
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------ mover
|
||||
|
||||
|
||||
def test_the_mover_commands_one_axis_and_waits_for_it_to_arrive():
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
plan = plan_pose_move(service, {"x": 2.0, "y": 1.0})
|
||||
assert mover.start(plan).ok
|
||||
assert len(poller.commands) == 1, "more than the first axis was commanded at once"
|
||||
|
||||
# x is at its target and idle -> the second axis goes out.
|
||||
mover.on_snapshot(snapshot_of(service, {0: 1000.0, 1: 0.0}))
|
||||
assert len(poller.commands) == 2
|
||||
assert mover.state.step.role == "y"
|
||||
|
||||
# y arrives -> the plan is done.
|
||||
mover.on_snapshot(snapshot_of(service, {0: 1000.0, 1: 500.0}))
|
||||
assert not mover.running
|
||||
assert mover.state.finished
|
||||
|
||||
|
||||
def test_the_mover_waits_while_an_axis_is_still_moving():
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
|
||||
mover.on_snapshot(snapshot_of(service, {0: 990.0, 1: 0.0}, idle=False))
|
||||
assert len(poller.commands) == 1
|
||||
assert mover.state.index == 0
|
||||
|
||||
|
||||
def test_the_mover_fails_when_an_axis_stops_short():
|
||||
"""A drive that has stopped is not going to arrive: say so, do not wait."""
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
|
||||
mover.on_snapshot(snapshot_of(service, {0: 10.0, 1: 0.0}))
|
||||
assert mover.running, "gave up before the stall grace elapsed"
|
||||
mover.state._idle_since = time.monotonic() - 10.0 # noqa: SLF001
|
||||
mover.on_snapshot(snapshot_of(service, {0: 10.0, 1: 0.0}))
|
||||
assert not mover.running
|
||||
assert "stopped short" in mover.state.failure
|
||||
assert len(poller.commands) == 1, "a further axis was commanded after the failure"
|
||||
|
||||
|
||||
def test_the_mover_stops_on_an_alarm():
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
mover.start(plan_pose_move(service, {"x": 2.0}))
|
||||
mover.on_snapshot(snapshot_of(service, {0: 0.0}, alarm=True))
|
||||
assert not mover.running
|
||||
assert "alarm" in mover.state.failure
|
||||
|
||||
|
||||
def test_a_tolerance_accepts_an_axis_that_reports_its_final_position_late():
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
plan = plan_pose_move(service, {"x": 2.0, "y": 1.0})
|
||||
mover.start(plan)
|
||||
# 1 unit inside SETTLE_MM of 1000 units for 2 mm.
|
||||
mover.on_snapshot(snapshot_of(service, {0: 1000.0 - 1.0, 1: 0.0}))
|
||||
assert len(poller.commands) == 2, "a step within tolerance was treated as a miss"
|
||||
assert SETTLE_MM > 0 and SETTLE_DEG > 0
|
||||
|
||||
|
||||
def test_stopping_cancels_the_rest_of_the_plan():
|
||||
"""The stop is a separate command, so the mover must stop queueing first."""
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
|
||||
assert mover.running
|
||||
mover.cancel("stopped")
|
||||
assert not mover.running
|
||||
assert mover.state.cancelled
|
||||
# Nothing further, even if the axis happens to arrive afterwards.
|
||||
mover.on_snapshot(snapshot_of(service, {0: 1000.0, 1: 500.0}))
|
||||
assert len(poller.commands) == 1
|
||||
|
||||
|
||||
def test_a_second_plan_cannot_start_on_top_of_a_running_one():
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
|
||||
second = mover.start(plan_pose_move(service, {"x": 3.0}))
|
||||
assert not second.ok
|
||||
assert len(poller.commands) == 1
|
||||
|
||||
|
||||
def test_the_mover_refuses_a_plan_it_would_not_be_allowed_to_run():
|
||||
service = MotionService(DemoTransport(), axis_roles=ROLES, limits=LIMITS)
|
||||
mover = PoseMover(service, FakePoller(service).submit)
|
||||
result = mover.start(plan_pose_move(service, {"x": 1.0}))
|
||||
assert not result.ok
|
||||
assert not mover.running
|
||||
|
||||
|
||||
def test_the_progress_line_names_the_step_it_is_on():
|
||||
service = make_service()
|
||||
mover = PoseMover(service, FakePoller(service).submit)
|
||||
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
|
||||
text = mover.progress_text()
|
||||
assert "1/2" in text and "x" in text
|
||||
|
||||
|
||||
def test_the_units_round_trip_through_the_service():
|
||||
"""A target planned in mm must arrive at the drive as the same mm."""
|
||||
service = make_service()
|
||||
for role, value in (("x", 3.5), ("y", -2.25), ("yaw", 45.0), ("pitch", -30.0)):
|
||||
units = service.to_units(role, value)
|
||||
axis = service.role_axis(role)
|
||||
if role in ("x", "y"):
|
||||
assert service.to_mm(axis, units) == pytest.approx(value)
|
||||
else:
|
||||
from orisys_autocal.motion import user_units_to_deg
|
||||
|
||||
assert user_units_to_deg(axis, units) == pytest.approx(value)
|
||||
@@ -15,7 +15,6 @@ from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.sensor.export_bundle import (
|
||||
BundleError,
|
||||
load_bundle,
|
||||
|
||||
@@ -12,7 +12,6 @@ import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.sequence_io import (
|
||||
DOCUMENT_KIND,
|
||||
FORMAT_VERSION,
|
||||
|
||||
@@ -15,7 +15,6 @@ from __future__ import annotations
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion.units import (
|
||||
PULSES_PER_MM,
|
||||
ROTARY_UNITS_PER_90_DEG,
|
||||
|
||||
@@ -12,7 +12,6 @@ from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.sensor import (
|
||||
DEFAULT_FLAT_SIZE,
|
||||
load_bundle,
|
||||
|
||||
@@ -25,7 +25,6 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -116,16 +115,13 @@ STRINGS = {
|
||||
"setup_open": "设备设置",
|
||||
"setup_title": "设备设置",
|
||||
"setup_hint": "连接与总线初始化每次开机只需确认一次,因此收进这里;顶栏状态条持续显示结果,设置窗口可以一直开着。",
|
||||
"setup_tabs": ["连接", "总线初始化"],
|
||||
"setup_tabs": ["连接", "总线初始化", "轴映射"],
|
||||
"setup_rows": [
|
||||
["连接方式", "以太网 ▾"],
|
||||
["IP", "192.168.0.11"],
|
||||
["状态", "已连接"],
|
||||
["BAS 文件", "…\\bas\\ECAT.bas"],
|
||||
["初始化状态", "初始化成功"],
|
||||
["总线类型", "EtherCAT"],
|
||||
["节点数量", "5"],
|
||||
["轴数量", "5"],
|
||||
["X(工作台)", "驱动器 0"],
|
||||
["Y(工作台)", "驱动器 1"],
|
||||
["Z(压轴)", "驱动器 4"],
|
||||
["yaw(回转)", "驱动器 3"],
|
||||
["pitch(俯仰)", "驱动器 2"],
|
||||
],
|
||||
"setup_connect": "连接",
|
||||
"setup_disconnect": "断开连接",
|
||||
@@ -208,6 +204,11 @@ STRINGS = {
|
||||
"finger_preview_title": "三维预览",
|
||||
"finger_preview_checks": ["显示运动轴", "显示转台原点平面", "显示刀具路径"],
|
||||
"finger_play": "播放动作",
|
||||
"move_execute": "执行动作",
|
||||
"move_stop": "停止",
|
||||
"move_return": "回初始位",
|
||||
"move_confirm": "移动前确认",
|
||||
"move_status": "1/4 yaw → +36.8160 °(回初始位:先退压轴 Z)",
|
||||
"finger_preview_notes": [
|
||||
"颜色含义:绿→红 = 表面对齐残差;红/绿/蓝三色轴 = 工作台 X/Y/Z(位于原点);橙点 = 工作台原点。",
|
||||
"黄色直线 = 刀具轴(+Z,位置即刀具 X/Y);黄色方框 = 传感器安装基面(高度 = 传感器 Z,含安装板)。",
|
||||
@@ -270,16 +271,13 @@ STRINGS = {
|
||||
"setup_open": "Device setup",
|
||||
"setup_title": "Device setup",
|
||||
"setup_hint": "Connecting and bringing the bus up are checked once per machine start-up, so they live here. The status strip keeps reporting the result.",
|
||||
"setup_tabs": ["Connection", "Bus initialisation"],
|
||||
"setup_tabs": ["Connection", "Bus initialisation", "Axis mapping"],
|
||||
"setup_rows": [
|
||||
["Connection", "Ethernet ▾"],
|
||||
["IP", "192.168.0.11"],
|
||||
["State", "Connected"],
|
||||
["BAS file", "…\\bas\\ECAT.bas"],
|
||||
["Init state", "Initialised"],
|
||||
["Bus type", "EtherCAT"],
|
||||
["Nodes", "5"],
|
||||
["Axes", "5"],
|
||||
["X (table)", "Drive 0"],
|
||||
["Y (table)", "Drive 1"],
|
||||
["Z (press)", "Drive 4"],
|
||||
["yaw (rotation)", "Drive 3"],
|
||||
["pitch (tilt)", "Drive 2"],
|
||||
],
|
||||
"setup_connect": "Connect",
|
||||
"setup_disconnect": "Disconnect",
|
||||
@@ -362,6 +360,11 @@ STRINGS = {
|
||||
"finger_preview_title": "3D preview",
|
||||
"finger_preview_checks": ["Show motion axes", "Show table origin plane", "Show tool path"],
|
||||
"finger_play": "Play motion",
|
||||
"move_execute": "Execute",
|
||||
"move_stop": "Stop",
|
||||
"move_return": "Return to zero",
|
||||
"move_confirm": "Confirm before moving",
|
||||
"move_status": "1/4 yaw → +36.8160° (return to zero retracts Z first)",
|
||||
"finger_preview_notes": [
|
||||
"What the colours mean: green→red = the surface's alignment residual; the red/green/blue triad is the table's X/Y/Z at the origin; the orange dot is the origin.",
|
||||
"Yellow line = the tool axis (+Z, at the tool's own X/Y); the amber outline = the sensor base plane, at the sensor's Z offset (the plate).",
|
||||
@@ -629,7 +632,10 @@ def draw_left_rail(painter: QPainter, palette: Palette, s: dict, rect: QRect) ->
|
||||
y += 24
|
||||
_button(painter, QRect(inner.x(), y + 4, inner.width(), 28), s["enable_all"], palette)
|
||||
_button(painter, QRect(inner.x(), y + 40, inner.width(), 28), s["stop_all"], palette, danger=True)
|
||||
_button(painter, QRect(inner.x(), y + 76, inner.width(), 28), s["clear_alarms"], palette)
|
||||
# Returning every drive to zero is a machine-level action: here, in the rail that
|
||||
# is on screen in every tab, rather than repeated inside a tab.
|
||||
_button(painter, QRect(inner.x(), y + 76, inner.width(), 28), s["move_return"], palette)
|
||||
_button(painter, QRect(inner.x(), y + 112, inner.width(), 28), s["clear_alarms"], palette)
|
||||
|
||||
group = QRect(rect.x(), rect.y() + 280, rect.width(), 32)
|
||||
_panel(painter, group.adjusted(0, 0, 0, -8), palette)
|
||||
@@ -655,10 +661,11 @@ def draw_setup_dialog(painter: QPainter, palette: Palette, s: dict, rect: QRect)
|
||||
)
|
||||
|
||||
tabs = QRect(inner.x(), inner.y() + 38, inner.width(), inner.height() - 74)
|
||||
tab_w = 130
|
||||
tab_w = 120
|
||||
for index, label in enumerate(s["setup_tabs"]):
|
||||
tab = QRect(tabs.x() + index * tab_w, tabs.y(), tab_w, 26)
|
||||
active = index == 0
|
||||
# The axis mapping is the newest page, and the one worth reviewing.
|
||||
active = label == s["setup_tabs"][-1]
|
||||
painter.fillRect(tab, palette.window if active else palette.panel)
|
||||
painter.setPen(QPen(palette.border, 1))
|
||||
painter.drawRect(tab.adjusted(0, 0, -1, -1))
|
||||
@@ -667,16 +674,14 @@ def draw_setup_dialog(painter: QPainter, palette: Palette, s: dict, rect: QRect)
|
||||
painter.drawLine(tab.bottomLeft(), tab.bottomRight())
|
||||
_text(painter, tab, label, palette, size=10, bold=active, align=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# The rows are both tabs' contents stacked, in order: the connection fields and
|
||||
# the bus readouts, which is what the two tabs hold between them.
|
||||
# The mapping page: one row per platform direction, each showing the drive that
|
||||
# moves it. The values are the bench's own wiring.
|
||||
body = _panel(painter, tabs.adjusted(0, 26, 0, 0), palette)
|
||||
y = body.y() + 6
|
||||
for label, value in s["setup_rows"]:
|
||||
_text(painter, QRect(body.x(), y, 96, 22), label, palette, size=10, muted=True)
|
||||
_text(painter, QRect(body.x() + 104, y, body.width() - 104, 22), value, palette, size=10)
|
||||
y += 24
|
||||
_button(painter, QRect(body.x(), y + 6, 110, 26), s["setup_connect"], palette, primary=True)
|
||||
_button(painter, QRect(body.x() + 118, y + 6, 118, 26), s["setup_disconnect"], palette)
|
||||
_text(painter, QRect(body.x(), y, 150, 22), label, palette, size=10, muted=True)
|
||||
_text(painter, QRect(body.x() + 156, y, body.width() - 156, 22), value, palette, size=10)
|
||||
y += 26
|
||||
_button(painter, QRect(dialog.right() - 110, dialog.bottom() - 34, 96, 26), s["setup_close"], palette)
|
||||
|
||||
|
||||
@@ -1237,9 +1242,25 @@ def draw_finger_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect) -
|
||||
painter.drawLine(box.x() + 5, box.y() + 13, box.x() + 10, box.y() + 5)
|
||||
_text(painter, QRect(box.x() + 15, box.y(), check_w - 15, checks_h), label, palette, size=9, muted=True)
|
||||
|
||||
play = QRect(inner_preview.x(), view.bottom() + checks_h + 2, 110, play_h - 4)
|
||||
row_y = view.bottom() + checks_h + 2
|
||||
play = QRect(inner_preview.x(), row_y, 110, play_h - 4)
|
||||
_button(painter, play, s["finger_play"], palette, primary=True)
|
||||
notes_y = play.bottom() + 4
|
||||
# The pose move: execute the selected point's solved pose, stop, and the policy
|
||||
# that decides whether either asks first.
|
||||
execute = QRect(play.right() + 8, row_y, 100, play_h - 4)
|
||||
_button(painter, execute, s["move_execute"], palette)
|
||||
stop = QRect(execute.right() + 8, row_y, 84, play_h - 4)
|
||||
_button(painter, stop, s["move_stop"], palette, danger=True)
|
||||
confirm = QRect(stop.right() + 10, row_y, inner_preview.width() - stop.right() - 10, play_h - 4)
|
||||
_text(painter, confirm, "☑ " + s["move_confirm"], palette, size=9, muted=True)
|
||||
_text(
|
||||
painter,
|
||||
QRect(inner_preview.x(), row_y + play_h, inner_preview.width(), 16),
|
||||
s["move_status"],
|
||||
palette,
|
||||
size=9,
|
||||
)
|
||||
notes_y = row_y + play_h + 18
|
||||
for text in s["finger_preview_notes"]:
|
||||
_text(painter, QRect(inner_preview.x(), notes_y, inner_preview.width(), 14), text, palette, size=8, muted=True)
|
||||
notes_y += 14
|
||||
|
||||
@@ -76,16 +76,14 @@ def _visible_descendants(widget):
|
||||
|
||||
|
||||
def main() -> int:
|
||||
from PySide6 import __version__ as pyside_version
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import set_locale, t
|
||||
from orisys_app_shared.theme import build_qss, load_colors
|
||||
|
||||
from orisys_autocal.motion import ConnectionSpec
|
||||
from orisys_autocal.ui.axis_panel import COLUMNS
|
||||
from orisys_autocal.ui.bus_panel import default_bas_dir
|
||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||
from PySide6 import __version__ as pyside_version
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
checker = Checker()
|
||||
app = QApplication.instance() or QApplication([])
|
||||
@@ -115,7 +113,14 @@ def main() -> int:
|
||||
set_locale("zh_CN")
|
||||
|
||||
# ---- window ----------------------------------------------------------
|
||||
from orisys_autocal.motion import DemoTransport
|
||||
import math
|
||||
|
||||
from orisys_autocal.motion import (
|
||||
DemoTransport,
|
||||
plan_pose_move,
|
||||
plan_return_to_initial,
|
||||
user_units_to_deg,
|
||||
)
|
||||
|
||||
for theme in ("dark", "light"):
|
||||
window = AutoCalMainWindow(prefs={**prefs, "theme": theme})
|
||||
@@ -267,6 +272,50 @@ def main() -> int:
|
||||
not window.device_setup.isVisible(),
|
||||
"the device setup is open before anyone asked for it",
|
||||
)
|
||||
# Where the three move controls live, and why. Execute is about the selected
|
||||
# point, so it belongs to the finger screen; the stop is repeated next to it
|
||||
# *and* stays in the strip, because an emergency stop has to be where the
|
||||
# operator's eyes are; returning to zero is a machine action wanted after any
|
||||
# tab's work, so it lives in the always-visible rail and is not duplicated.
|
||||
checker.that(
|
||||
window.finger_panel.execute_button.parent() is not None
|
||||
and window.finger_panel.stop_button.parent() is not None,
|
||||
"the finger screen is missing an execute or stop button",
|
||||
)
|
||||
checker.that(
|
||||
window.status_strip.stop_button.isEnabled() is not None,
|
||||
"the strip has no stop button",
|
||||
)
|
||||
checker.that(
|
||||
window.axis_readout.return_button.text() == t("move.return_initial"),
|
||||
f"the rail return button says "
|
||||
f"{window.axis_readout.return_button.text()!r}",
|
||||
)
|
||||
checker.that(
|
||||
window.axis_readout.return_button.parent() is window.axis_readout,
|
||||
"the return button is not in the always-visible rail",
|
||||
)
|
||||
# The mapping page is the third tab of the device setup, and the service
|
||||
# already holds the mapping it shows.
|
||||
checker.that(
|
||||
window.device_setup.tabs.count() == 3,
|
||||
f"the device setup has {window.device_setup.tabs.count()} tabs, expected 3",
|
||||
)
|
||||
checker.that(
|
||||
window.device_setup.tabs.tabText(2) == t("setup.tab_axes"),
|
||||
f"the third setup tab is {window.device_setup.tabs.tabText(2)!r}",
|
||||
)
|
||||
# With no controller, a move must be refused with a reason and the execute
|
||||
# button must say so rather than appearing to work.
|
||||
disconnected = window.poller.service.check_axes_move({"x": 1.0})
|
||||
checker.that(
|
||||
disconnected is not None,
|
||||
"a move was allowed with no controller connected",
|
||||
)
|
||||
checker.that(
|
||||
not window.finger_panel.execute_button.isEnabled(),
|
||||
"execute is enabled with no controller connected",
|
||||
)
|
||||
checker.that(
|
||||
window.device_setup.connection_panel is window.connection_panel
|
||||
and window.device_setup.bus_panel is window.bus_panel,
|
||||
@@ -401,14 +450,14 @@ def main() -> int:
|
||||
|
||||
# A full simulated session: connect, initialise from the shipped BAS.
|
||||
window.connect_controller(ConnectionSpec(mode="demo"), label="connect")
|
||||
_wait_for(app, lambda: window._last_snapshot.connected, 6.0)
|
||||
_wait_for(app, lambda w=window: w._last_snapshot.connected, 6.0)
|
||||
checker.that(
|
||||
window._last_snapshot.connected, "the simulated controller did not connect"
|
||||
)
|
||||
|
||||
window.bus_panel.set_bas_path(str(default_bas_dir() / "ECAT.bas"))
|
||||
window._on_download_bas()
|
||||
ok = _wait_for(app, lambda: window._last_snapshot.bus.init_status == 1, 8.0)
|
||||
ok = _wait_for(app, lambda w=window: w._last_snapshot.bus.init_status == 1, 8.0)
|
||||
checker.that(ok, "the simulated bus did not report initialised")
|
||||
if ok:
|
||||
checker.that(
|
||||
@@ -453,12 +502,167 @@ def main() -> int:
|
||||
or "5" in strip.chip_axes.text(),
|
||||
f"strip axis chip says {strip.chip_axes.text()!r}",
|
||||
)
|
||||
checker.that(
|
||||
window.axis_readout._rows[0]["position"].text().startswith("0.000"),
|
||||
f"readout row 0 shows {window.axis_readout._rows[0]['position'].text()!r}",
|
||||
)
|
||||
window.workspace.set_current_key("axes")
|
||||
|
||||
# ---- the pose move ------------------------------------------------
|
||||
# The whole path end to end, on the simulated controller: solve a pose
|
||||
# from a picked point, plan it, run it, and check the drives arrived.
|
||||
# Nothing else exercises the order, the units and the guards together.
|
||||
window.workspace.set_current_key("finger")
|
||||
app.processEvents()
|
||||
finger = window.finger_panel
|
||||
checker.that(
|
||||
finger.execute_button.isEnabled(),
|
||||
"execute is disabled on a connected, initialised platform",
|
||||
)
|
||||
checker.that(
|
||||
finger.stop_button.isEnabled(),
|
||||
"the local stop is disabled while the platform may move",
|
||||
)
|
||||
|
||||
# A point to solve: pick the centre of the flat image, which is the
|
||||
# operator's own gesture, so the pose the check plans is a real one.
|
||||
if not finger.points():
|
||||
view = finger.view
|
||||
window._on_pick_point(view.width() // 2, view.height() // 2) # noqa: SLF001
|
||||
app.processEvents()
|
||||
checker.that(
|
||||
bool(finger.points()),
|
||||
"picking the middle of the patch produced no point",
|
||||
)
|
||||
|
||||
solve = finger.solved_pose()
|
||||
checker.that(
|
||||
solve is not None,
|
||||
"the finger screen solved no pose from its point list",
|
||||
)
|
||||
if solve is not None:
|
||||
targets, held = solve
|
||||
checker.that(
|
||||
"z" in held and "z" not in targets,
|
||||
f"execute does not hold the press axis back: "
|
||||
f"{sorted(targets)} held={held}",
|
||||
)
|
||||
plan = plan_pose_move(window.poller.service, targets, hold=held)
|
||||
checker.that(
|
||||
[step.role for step in plan.steps] == ["yaw", "pitch", "x", "y"],
|
||||
f"the move order is {[step.role for step in plan.steps]}",
|
||||
)
|
||||
checker.that(
|
||||
all(math.isfinite(step.units) for step in plan.steps),
|
||||
"a planned step does not convert to user units",
|
||||
)
|
||||
# Straight to the mover: the confirmation box is the operator's, and
|
||||
# a modal dialog in a headless check is a hang (it has been one).
|
||||
result = window.mover.start(plan)
|
||||
checker.that(result.ok, f"the mover refused the plan: {result.message}")
|
||||
moved = _wait_for(app, lambda w=window: not w.mover.running, 25.0)
|
||||
checker.that(moved, "the pose move did not finish")
|
||||
checker.that(
|
||||
not window.mover.state.failure,
|
||||
f"the pose move failed: {window.mover.state.failure}",
|
||||
)
|
||||
for step in plan.steps:
|
||||
state = window._last_snapshot.axis(step.axis)
|
||||
if state is None:
|
||||
continue
|
||||
if step.unit == "mm":
|
||||
actual = window.poller.service.to_mm(step.axis, state.dpos)
|
||||
else:
|
||||
actual = user_units_to_deg(step.axis, state.dpos)
|
||||
checker.that(
|
||||
abs(actual - step.target) <= 0.05,
|
||||
f"{step.role} ended at {actual:.4f}{step.unit}, "
|
||||
f"target {step.target:.4f}{step.unit}",
|
||||
)
|
||||
print(
|
||||
" move: "
|
||||
+ ", ".join(f"{s.role}={s.target:+.3f}{s.unit}" for s in plan.steps)
|
||||
+ " reached"
|
||||
)
|
||||
|
||||
# Returning to the initial state, press axis first.
|
||||
back = plan_return_to_initial(window.poller.service)
|
||||
checker.that(
|
||||
[step.role for step in back.steps]
|
||||
== ["z", "pitch", "yaw", "x", "y"],
|
||||
f"the return order is {[step.role for step in back.steps]}",
|
||||
)
|
||||
result = window.mover.start(back)
|
||||
checker.that(result.ok, f"the return was refused: {result.message}")
|
||||
returned = _wait_for(app, lambda w=window: not w.mover.running, 30.0)
|
||||
checker.that(
|
||||
returned and not window.mover.state.failure,
|
||||
f"the return did not finish: {window.mover.state.failure}",
|
||||
)
|
||||
zeroed = [
|
||||
window._last_snapshot.axis(step.axis).dpos
|
||||
for step in back.steps
|
||||
if window._last_snapshot.axis(step.axis) is not None
|
||||
]
|
||||
checker.that(
|
||||
all(abs(value) <= 1.0 for value in zeroed),
|
||||
f"the axes did not return to zero: {zeroed}",
|
||||
)
|
||||
print(" move: every axis returned to zero")
|
||||
|
||||
# Stop cancels the queue: no further axis may be commanded.
|
||||
window.mover.start(plan)
|
||||
window._on_stop_all()
|
||||
app.processEvents()
|
||||
checker.that(not window.mover.running, "stop left the move queued")
|
||||
checker.that(
|
||||
window.mover.state.cancelled,
|
||||
"stop did not mark the move cancelled",
|
||||
)
|
||||
print(" move: stop cancels the queue")
|
||||
|
||||
# The guards, on the same connected session: a mapping that claims one
|
||||
# drive twice must refuse motion rather than quietly pick a role.
|
||||
service = window.poller.service
|
||||
saved = service.axis_roles
|
||||
service.set_axis_roles({"x": 0, "y": 0, "pitch": 2, "yaw": 3, "z": 4})
|
||||
refusal = service.check_axes_move({"x": 1.0, "y": 1.0})
|
||||
checker.that(
|
||||
refusal is not None,
|
||||
f"a duplicated drive was not refused: {refusal}",
|
||||
)
|
||||
service.set_axis_roles(saved)
|
||||
refusal = service.check_axes_move({"x": 500.0})
|
||||
checker.that(
|
||||
refusal is not None and "500" in refusal.message,
|
||||
f"a target past the travel limit was not refused: {refusal}",
|
||||
)
|
||||
refusal = service.check_axes_move({"yaw": 400.0})
|
||||
checker.that(
|
||||
refusal is not None,
|
||||
f"a yaw past the stage envelope was not refused: {refusal}",
|
||||
)
|
||||
# The mapping page must agree with the service it feeds.
|
||||
page = window.device_setup.roles_page
|
||||
checker.that(
|
||||
page.roles() == saved,
|
||||
f"the mapping page shows {page.roles()}, service holds {saved}",
|
||||
)
|
||||
page.combos["y"].setCurrentIndex(page.combos["y"].findData(0))
|
||||
app.processEvents()
|
||||
checker.that(
|
||||
bool(page.problem()), "the mapping page accepted two roles on one drive"
|
||||
)
|
||||
checker.that(
|
||||
service.roles_problem() != "",
|
||||
"the service did not pick up the duplicated mapping",
|
||||
)
|
||||
page.combos["y"].setCurrentIndex(page.combos["y"].findData(1))
|
||||
app.processEvents()
|
||||
checker.that(
|
||||
page.problem() == "" and service.roles_problem() == "",
|
||||
"correcting the mapping did not clear the refusal",
|
||||
)
|
||||
print(" move: guards refuse a duplicate mapping and an out-of-range target")
|
||||
window.workspace.set_current_key("axes")
|
||||
app.processEvents()
|
||||
|
||||
try:
|
||||
window.poller.shutdown()
|
||||
except Exception: # noqa: BLE001 - a shutdown must not mask the result
|
||||
|
||||
@@ -53,21 +53,19 @@ def main() -> int:
|
||||
parser.add_argument(
|
||||
"--setup-dialog",
|
||||
default="",
|
||||
choices=("", "connection", "bus"),
|
||||
choices=("", "connection", "bus", "axes"),
|
||||
help="photograph the device-setup dialog on this tab instead of the window",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
width, height = (int(part) for part in args.size.lower().split("x"))
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import set_locale
|
||||
from orisys_app_shared.theme import build_qss
|
||||
|
||||
from orisys_autocal.motion import ConnectionSpec, DemoTransport
|
||||
from orisys_autocal.preferences import load_preferences
|
||||
from orisys_autocal.ui.bus_panel import default_bas_dir
|
||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
set_locale(args.language)
|
||||
app = QApplication.instance() or QApplication([])
|
||||
|
||||
@@ -96,14 +96,12 @@ def main() -> int:
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import set_locale
|
||||
from orisys_app_shared.theme import build_qss
|
||||
|
||||
from orisys_autocal.preferences import load_preferences
|
||||
from orisys_autocal.ui.pose_preview import configure_compatible_gl
|
||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
width, height = (int(part) for part in args.size.lower().split("x"))
|
||||
|
||||
@@ -223,7 +221,6 @@ def main() -> int:
|
||||
# pixels, because a projection that disagrees with the render looks
|
||||
# perfectly healthy on its own — that is how the labels drifted off
|
||||
# their arrows and the scene sat off to one side without a word.
|
||||
from orisys_autocal.ui.pose_preview import COLOUR
|
||||
|
||||
for label, point, colour in _landmarks(panel):
|
||||
screen = panel.preview.project(point)
|
||||
|
||||
Reference in New Issue
Block a user