added ruff test

This commit is contained in:
ykcheungab
2026-09-27 19:20:43 +08:00
parent bcab2790bf
commit fa2703968e
178 changed files with 3277 additions and 1034 deletions
+11
View File
@@ -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
+30
View File
@@ -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",
+30
View File
@@ -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": "连接方式",
+24
View File
@@ -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
+11
View File
@@ -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
+4 -2
View File
@@ -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
+83 -5
View File
@@ -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.
+183 -5
View File
@@ -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
-2
View File
@@ -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,
+38 -2
View File
@@ -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()
-1
View File
@@ -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)
+1 -6
View File
@@ -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()
+1 -2
View File
@@ -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
-2
View File
@@ -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,
+35 -2
View File
@@ -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}"
+192
View File
@@ -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()
+6 -2
View File
@@ -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):
+337
View File
@@ -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)
-1
View File
@@ -15,7 +15,6 @@ from __future__ import annotations
import numpy as np
import pytest
from orisys_autocal.sensor.export_bundle import (
BundleError,
load_bundle,
-1
View File
@@ -12,7 +12,6 @@ import shutil
from pathlib import Path
import pytest
from orisys_autocal.sequence_io import (
DOCUMENT_KIND,
FORMAT_VERSION,
-1
View File
@@ -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,
-1
View File
@@ -12,7 +12,6 @@ from pathlib import Path
import numpy as np
import pytest
from orisys_autocal.sensor import (
DEFAULT_FLAT_SIZE,
load_bundle,
+52 -31
View File
@@ -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
+215 -11
View File
@@ -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
+2 -4
View File
@@ -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([])
+1 -4
View File
@@ -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)