added ruff test
This commit is contained in:
@@ -112,6 +112,17 @@ calls them:
|
||||
|
||||
- **Motion is refused** while the bus is not initialised, while the axis is
|
||||
unknown, or on a non-finite target — with a reason, never a silent no-op.
|
||||
- **A pose move is refused before the first axis moves** if any part of it is wrong:
|
||||
a target past the P0 limit — millimetres for x/y/z, **degrees** for yaw/pitch, so a
|
||||
400° stage turn cannot pass a 100 mm check — an axis mapping that is incomplete,
|
||||
points outside the drive range, or claims one drive twice, or a rotary role on an
|
||||
axis with no user-units-per-degree calibration. A sequence that stopped halfway
|
||||
would leave the machine in a pose nobody simulated, so every check runs first.
|
||||
- **A move is stepped, not looped.** The drives are commanded one at a time from the
|
||||
poll snapshots; a step that stalls (idle but short of its target), times out or
|
||||
raises an alarm aborts the rest. That is also what makes **stop** work — it is an
|
||||
ordinary command, so it runs at the next poll tick, and the queue is cancelled
|
||||
before it is sent so no later axis is commanded.
|
||||
- **Homing needs a configured mode.** The vendor example pre-fills `17`, which
|
||||
is one specific drive's mode; AutoCal starts at `0` and refuses, so the
|
||||
operator chooses for the drive in front of them instead of inheriting a number
|
||||
|
||||
@@ -34,9 +34,39 @@
|
||||
"hint": "Connecting and bringing the bus up are checked once per machine start-up, so they live here. The status strip keeps reporting the result, and this window can stay open while you work.",
|
||||
"tab_connection": "Connection",
|
||||
"tab_bus": "Bus initialisation",
|
||||
"tab_axes": "Axis mapping",
|
||||
"axes_hint": "The bus reports axis numbers, not what each one moves. The mapping below comes from the bench wiring, and it must be complete and free of duplicates before anything moves.",
|
||||
"role_x": "X (table)",
|
||||
"role_y": "Y (table)",
|
||||
"role_z": "Z (press)",
|
||||
"role_yaw": "yaw (rotation)",
|
||||
"role_pitch": "pitch (tilt)",
|
||||
"role_axis": "Drive",
|
||||
"axes_duplicate": "One drive is claimed by more than one direction: {detail}",
|
||||
"close": "Close",
|
||||
"chip_hint": "Open device setup — {name}"
|
||||
},
|
||||
"move": {
|
||||
"execute": "Execute",
|
||||
"execute_hint": "Send the selected point's solved pose to the platform: yaw, then pitch, then table X/Y. The press axis Z is not moved.",
|
||||
"stop": "Stop",
|
||||
"stop_hint": "Stop every axis now (the same command as 全部停止 in the strip).",
|
||||
"return_initial": "Return to zero",
|
||||
"return_hint": "Every axis back to the initial state, press axis Z first.",
|
||||
"confirm_title": "Confirm motion",
|
||||
"confirm_execute": "Will move, in this order:\n\n{steps}\n\nCheck this against the machine before continuing.",
|
||||
"confirm_return": "Will move every axis back to zero (press axis Z first):\n\n{steps}",
|
||||
"hold_note": "Not moved: {roles} (the press axis is P7's business — contact detection and press depth)",
|
||||
"cancelled": "Cancelled; nothing was commanded",
|
||||
"confirm_check": "Confirm before moving",
|
||||
"confirm_hint": "Unchecked, Execute and Return to zero are sent directly. Stop is never affected.",
|
||||
"no_point": "No calibration point is selected",
|
||||
"busy": "A move is already running",
|
||||
"refused": "Motion refused",
|
||||
"done": "Move finished",
|
||||
"failed": "Move failed",
|
||||
"stopped": "Stopped; the remaining steps were cancelled"
|
||||
},
|
||||
"connection": {
|
||||
"title": "Controller connection",
|
||||
"mode": "Connection",
|
||||
|
||||
@@ -34,9 +34,39 @@
|
||||
"hint": "连接与总线初始化每次开机只需确认一次,因此收进这里;顶栏状态条持续显示结果,设置窗口可以一直开着。",
|
||||
"tab_connection": "连接",
|
||||
"tab_bus": "总线初始化",
|
||||
"tab_axes": "轴映射",
|
||||
"axes_hint": "总线只报告轴号,不报告它推的是哪个方向。这里的对应关系来自实际接线,运动前必须完整且不重复。",
|
||||
"role_x": "X(工作台)",
|
||||
"role_y": "Y(工作台)",
|
||||
"role_z": "Z(压轴)",
|
||||
"role_yaw": "yaw(回转)",
|
||||
"role_pitch": "pitch(俯仰)",
|
||||
"role_axis": "驱动器",
|
||||
"axes_duplicate": "同一驱动器被分配给多个方向:{detail}",
|
||||
"close": "关闭",
|
||||
"chip_hint": "点击打开设备设置 — {name}"
|
||||
},
|
||||
"move": {
|
||||
"execute": "执行动作",
|
||||
"execute_hint": "把选中点解算出的位姿送到平台:先回转、再俯仰,最后工作台 X/Y。压轴 Z 不动作。",
|
||||
"stop": "停止",
|
||||
"stop_hint": "立即停止所有轴(与顶栏「全部停止」相同)。",
|
||||
"return_initial": "回初始位",
|
||||
"return_hint": "五个轴全部回到零点,先退压轴 Z。",
|
||||
"confirm_title": "确认运动",
|
||||
"confirm_execute": "将按以下顺序移动:\n\n{steps}\n\n请确认仿真与机床一致。",
|
||||
"confirm_return": "将把五个轴全部移回零点(先退压轴 Z):\n\n{steps}",
|
||||
"hold_note": "不动作:{roles}(压轴由 P7 的接触检测与压深另外管)",
|
||||
"cancelled": "已取消,未发出任何运动",
|
||||
"confirm_check": "移动前确认",
|
||||
"confirm_hint": "取消勾选后,执行动作与回初始位将直接发出(停止不受影响)。",
|
||||
"no_point": "未选中标定点,无法执行",
|
||||
"busy": "已有运动在执行",
|
||||
"refused": "运动被拒绝",
|
||||
"done": "运动完成",
|
||||
"failed": "运动失败",
|
||||
"stopped": "已停止,后续动作已取消"
|
||||
},
|
||||
"connection": {
|
||||
"title": "连接控制器",
|
||||
"mode": "连接方式",
|
||||
|
||||
@@ -61,6 +61,30 @@ which can put the face on the far side of the body from the default camera. The
|
||||
axis is drawn with depth testing **off** for that reason — it is a reference line,
|
||||
and hiding it behind the body it is meant to align with is the opposite of useful.
|
||||
|
||||
## Running the pose on the machine
|
||||
|
||||
Three buttons, and where each one lives is deliberate:
|
||||
|
||||
| Button | Where | What it does |
|
||||
| --- | --- | --- |
|
||||
| **执行动作** Execute | this screen, next to 播放动作 | Sends the selected point's solved pose to the platform: **yaw, then pitch, then table X/Y**. The press axis **z is not commanded** — pressing is P7, with contact detection and press depth, neither of which exists here, so a move to the contact height would be a press with none of its guards. |
|
||||
| **停止** Stop | here *and* the strip's 全部停止 | The same command. Two access points, one implementation: during a move the operator's eyes are on this pane, and an emergency stop has to be where they are. |
|
||||
| **回初始位** Return to zero | the axis rail, on every tab | Every drive to 0 — press axis first, then the stages, then the table. A machine-level action, wanted after work in any tab, so it is not repeated inside one. |
|
||||
|
||||
**移动前确认** decides whether the moving buttons ask first. The prompt lists the steps
|
||||
with their numbers and the drive each one uses, and says what is *not* being moved.
|
||||
|
||||
The order is the model's own: the kinematics composes `R_pitch · R_yaw`, so yaw acts on
|
||||
the body first and the machine yaws first — every intermediate pose is one the preview
|
||||
drew. Table X/Y last, because the translation is what the rotations are resolved
|
||||
against.
|
||||
|
||||
Each drive is commanded on its own and the next goes out only once the previous has
|
||||
arrived, which is what keeps the move interruptible; a step that stops short, times out
|
||||
or alarms aborts the rest rather than finishing a sequence nobody has verified. What is
|
||||
checked *before* the first axis moves — the mapping, the P0 limits, the rotary
|
||||
calibration — is in `../README.md` under **Safety model**.
|
||||
|
||||
The chain the solver and the drawing share is, in order: **mount** (sensor axes into
|
||||
table axes) → **setup** (the mounting pose above) → **stages** (the solved yaw and
|
||||
pitch, about the table origin) → **table** (the XY slide). A point of the sensor is
|
||||
|
||||
@@ -236,6 +236,17 @@ measured time per point.
|
||||
**Gate:** the operator has seen the path and a dry run has completed without a
|
||||
guard violation.
|
||||
|
||||
**The single-point dry run is built**, in the finger debug screen: 执行动作 sends the
|
||||
selected point's *solved pose* to the platform — yaw, then pitch, then table X/Y — and
|
||||
does **not** command the press axis, so it cannot reach the sensor. The order is the
|
||||
model's own (`R_pitch · R_yaw`), so every intermediate pose is one the operator
|
||||
watched in the preview. Before it moves it checks the mapping, the target against the
|
||||
P0 limits (millimetres for x/y/z, degrees for yaw/pitch) and the rotary calibration —
|
||||
all in `motion/service.py`, so a later caller cannot walk around them — and the drives
|
||||
are commanded one at a time, each waiting for the previous to arrive, with a stall or
|
||||
an alarm aborting the rest. 停止 next to it is the same command as the strip's
|
||||
全部停止; 回初始位 in the rail sends every axis to zero with the press axis first.
|
||||
|
||||
---
|
||||
|
||||
## P7 — Run + acquire
|
||||
|
||||
@@ -66,14 +66,16 @@ def main(argv: list[str] | None = None) -> int:
|
||||
args = _parse_args(list(argv if argv is not None else sys.argv[1:]))
|
||||
_force_utf8_streams()
|
||||
|
||||
from orisys_app_shared import API_VERSION, __version__ as shared_version
|
||||
from orisys_app_shared import API_VERSION
|
||||
from orisys_app_shared import __version__ as shared_version
|
||||
from orisys_app_shared.compat import check_shared_compat
|
||||
from orisys_app_shared.i18n import set_locale
|
||||
from orisys_app_shared.logging import init_logging, install_exception_hooks
|
||||
from orisys_app_shared.theme import build_qss
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from . import REQUIRES_SHARED_API, __version__ as app_version
|
||||
from . import REQUIRES_SHARED_API
|
||||
from . import __version__ as app_version
|
||||
from .motion import ConnectionSpec, DemoTransport
|
||||
from .preferences import load_preferences
|
||||
from .ui.window import AutoCalMainWindow
|
||||
|
||||
@@ -16,7 +16,22 @@ from .models import (
|
||||
Snapshot,
|
||||
)
|
||||
from .poller import MotionPoller
|
||||
from .service import DEFAULT_BAS_TASK, PLATFORM_AXES, MotionService, clamp_bas_task
|
||||
from .pose_move import (
|
||||
MovePlan,
|
||||
MoveStep,
|
||||
PoseMover,
|
||||
plan_pose_move,
|
||||
plan_return_to_initial,
|
||||
)
|
||||
from .service import (
|
||||
AXIS_ROLES,
|
||||
DEFAULT_AXIS_ROLES,
|
||||
DEFAULT_BAS_TASK,
|
||||
PLATFORM_AXES,
|
||||
MotionService,
|
||||
clamp_bas_task,
|
||||
normalize_axis_roles,
|
||||
)
|
||||
from .transport import DemoTransport, NativeTransport, ZmcTransport
|
||||
from .units import (
|
||||
DEFAULT_ACCEL,
|
||||
@@ -33,7 +48,9 @@ from .units import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AXIS_ROLES",
|
||||
"DEFAULT_ACCEL",
|
||||
"DEFAULT_AXIS_ROLES",
|
||||
"DEFAULT_BAS_TASK",
|
||||
"DEFAULT_SPEED",
|
||||
"DEFAULT_UNITS",
|
||||
@@ -55,12 +72,18 @@ __all__ = [
|
||||
"MotionPoller",
|
||||
"MotionResult",
|
||||
"MotionService",
|
||||
"MovePlan",
|
||||
"MoveStep",
|
||||
"NativeTransport",
|
||||
"PoseMover",
|
||||
"Snapshot",
|
||||
"ZmcTransport",
|
||||
"clamp_bas_task",
|
||||
"deg_to_user_units",
|
||||
"mm_to_user_units",
|
||||
"normalize_axis_roles",
|
||||
"plan_pose_move",
|
||||
"plan_return_to_initial",
|
||||
"safe_float",
|
||||
"units_per_degree",
|
||||
"user_units_to_deg",
|
||||
|
||||
@@ -438,7 +438,7 @@ class PlatformStack:
|
||||
|
||||
def alignment_branches(
|
||||
self, unit: np.ndarray, tool: np.ndarray
|
||||
) -> list["Branch"]:
|
||||
) -> list[Branch]:
|
||||
"""Every exact pose that puts ``unit`` on ``tool``, in closed form.
|
||||
|
||||
Two ``atan2`` calls, no search. The chain is yaw-then-pitch, so:
|
||||
@@ -583,7 +583,7 @@ class PlatformStack:
|
||||
return math.degrees(math.atan2(sine_term, cosine_term))
|
||||
|
||||
@staticmethod
|
||||
def select_branch(branches: list["Branch"]) -> "Branch":
|
||||
def select_branch(branches: list[Branch]) -> Branch:
|
||||
"""Pick from :meth:`alignment_branches`: reachable, then upright, then best.
|
||||
|
||||
Every branch aligns to within floating point, so alignment itself rarely
|
||||
@@ -596,7 +596,7 @@ class PlatformStack:
|
||||
stages is to align. The tie only decides between poses that are the same
|
||||
to any tolerance the machine can hold.
|
||||
"""
|
||||
def rank(branch: "Branch") -> tuple:
|
||||
def rank(branch: Branch) -> tuple:
|
||||
# Alignment first: if one branch genuinely aligns better, that is the
|
||||
# answer, because aligning is what the stages are for. Reachability and
|
||||
# orientation decide between branches that align the same, which is the
|
||||
@@ -1051,7 +1051,7 @@ class PlatformStack:
|
||||
def _better_pose(
|
||||
candidate: tuple[float, float, float],
|
||||
best: tuple[float, float, float],
|
||||
stack: "PlatformStack",
|
||||
stack: PlatformStack,
|
||||
) -> bool:
|
||||
"""Rank two ``(residual, yaw, pitch)`` candidates.
|
||||
|
||||
|
||||
@@ -173,14 +173,14 @@ class MotionResult:
|
||||
message: str = ""
|
||||
|
||||
@classmethod
|
||||
def success(cls, message: str = "") -> "MotionResult":
|
||||
def success(cls, message: str = "") -> MotionResult:
|
||||
return cls(ok=True, code=0, message=message)
|
||||
|
||||
@classmethod
|
||||
def refused(cls, message: str) -> "MotionResult":
|
||||
def refused(cls, message: str) -> MotionResult:
|
||||
"""The command was not sent: a guard rejected it before any motion."""
|
||||
return cls(ok=False, code=0, message=message)
|
||||
|
||||
@classmethod
|
||||
def failed(cls, code: int, message: str) -> "MotionResult":
|
||||
def failed(cls, code: int, message: str) -> MotionResult:
|
||||
return cls(ok=False, code=int(code), message=message)
|
||||
|
||||
@@ -21,7 +21,8 @@ Contract:
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable, Iterable
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import (
|
||||
QMetaObject,
|
||||
@@ -288,7 +289,6 @@ class MotionPoller(QObject):
|
||||
# closes immediately and leaves the platform coasting.
|
||||
service.stop_all()
|
||||
service.disconnect()
|
||||
return None
|
||||
|
||||
return self.submit(_disconnect)
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Moving the platform to a simulated pose, one axis at a time.
|
||||
|
||||
The finger screen solves a pose — rotate the stages, slide the table — and shows it.
|
||||
Executing it is a *sequence* of coordinated moves, and where that sequence lives
|
||||
matters:
|
||||
|
||||
* The **service** owns the guards: connected, bus initialised, mapping sane, targets
|
||||
finite, inside the P0 limits, rotary axes calibrated. Nothing here bypasses them.
|
||||
* The **worker thread** owns the controller calls. One command per poll tick is how
|
||||
:class:`~orisys_autocal.motion.poller.MotionPoller` is built, and it is why a stop
|
||||
is possible at all: a mover that sat in a loop inside one command could not be
|
||||
interrupted, because the stop would have to wait for the loop it is meant to abort.
|
||||
* **This module** owns the order and the waiting. It watches the snapshots the poller
|
||||
already produces, issues the next axis when the current one has arrived, and gives
|
||||
up on its own if a step stops making progress.
|
||||
|
||||
That shape is the whole reason for the class: ``stop`` is submitted as an ordinary
|
||||
command, so it runs at the next tick and stops the axis that is moving *now*.
|
||||
|
||||
Why the order is what it is: the model composes ``R_pitch · R_yaw``, i.e. the yaw acts
|
||||
on the body first. Commanding yaw before pitch keeps every intermediate pose on the
|
||||
path the operator watched in the preview. Table X/Y last, because the table
|
||||
translation is what the rotations are resolved against.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from .models import MotionResult, Snapshot
|
||||
from .service import LINEAR_ROLES, MotionService
|
||||
from .units import user_units_to_deg
|
||||
|
||||
#: How close an axis must be to its target before the next one starts, per unit:
|
||||
#: millimetres for the linear roles, degrees for the rotary ones. One user unit is
|
||||
#: 0.2 µm on the bench (``units=10``, 5000 pulses/mm), so 0.01 mm is 50 units —
|
||||
#: loose enough that a drive which reports its final position one tick late does not
|
||||
#: stall the sequence, tight enough that a missed target is caught.
|
||||
SETTLE_MM = 0.01
|
||||
SETTLE_DEG = 0.01
|
||||
|
||||
#: Per-step patience. A step that is neither arriving nor moving this long is
|
||||
#: treated as stuck: reported and abandoned, with no further axes commanded.
|
||||
STEP_TIMEOUT_S = 20.0
|
||||
|
||||
#: How long a step may report itself idle while still short of its target before it
|
||||
#: counts as failed — a drive that has stopped without arriving will never arrive.
|
||||
STALL_GRACE_S = 2.0
|
||||
|
||||
#: The order a role move is issued in, for the reason in the module docstring.
|
||||
MOVE_ORDER: tuple[str, ...] = ("yaw", "pitch", "x", "y", "z")
|
||||
|
||||
#: The order "return to the initial state" retracts in: the press axis first, so the
|
||||
#: tool is clear of the sensor before anything rotates, then the stages, then the
|
||||
#: table. Returning in one coordinated swoop would move five axes through poses
|
||||
#: nobody has simulated.
|
||||
RETURN_ORDER: tuple[str, ...] = ("z", "pitch", "yaw", "x", "y")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MoveStep:
|
||||
"""One axis, one target, in that role's own unit."""
|
||||
|
||||
role: str
|
||||
axis: int
|
||||
target: float
|
||||
units: float
|
||||
unit: str
|
||||
|
||||
def label(self) -> str:
|
||||
return f"{self.role} {self.target:+.4f} {self.unit}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MovePlan:
|
||||
"""A sequence of steps, and what it is for."""
|
||||
|
||||
steps: tuple[MoveStep, ...]
|
||||
title: str
|
||||
#: Roles deliberately left alone. Reported, because "z did not move" is
|
||||
#: information the operator needs, not an omission.
|
||||
held: tuple[str, ...] = ()
|
||||
|
||||
def describe(self) -> str:
|
||||
parts = [step.label() for step in self.steps]
|
||||
text = " · ".join(parts) if parts else "-"
|
||||
if self.held:
|
||||
text += f" · {', '.join(self.held)} 不动作"
|
||||
return text
|
||||
|
||||
|
||||
@dataclass
|
||||
class MoveState:
|
||||
"""What the mover is doing, for the panel to show."""
|
||||
|
||||
plan: MovePlan | None = None
|
||||
index: int = 0
|
||||
started_at: float = 0.0
|
||||
finished: bool = False
|
||||
failure: str = ""
|
||||
cancelled: bool = False
|
||||
_step_started_at: float = field(default=0.0)
|
||||
_idle_since: float | None = None
|
||||
|
||||
@property
|
||||
def step(self) -> MoveStep | None:
|
||||
if self.plan is None or not 0 <= self.index < len(self.plan.steps):
|
||||
return None
|
||||
return self.plan.steps[self.index]
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return len(self.plan.steps) if self.plan else 0
|
||||
|
||||
|
||||
def plan_pose_move(
|
||||
service: MotionService,
|
||||
targets: Mapping[str, float],
|
||||
*,
|
||||
title: str = "",
|
||||
hold: Sequence[str] = (),
|
||||
order: Sequence[str] = MOVE_ORDER,
|
||||
) -> MovePlan:
|
||||
"""Turn per-role targets into a stepped plan, in ``order``.
|
||||
|
||||
``targets`` is in each role's own unit. Roles absent from it are not commanded —
|
||||
that is how "z does not move" is expressed: not by a target equal to its current
|
||||
position, which would still command it.
|
||||
"""
|
||||
steps: list[MoveStep] = []
|
||||
for role in order:
|
||||
if role not in targets:
|
||||
continue
|
||||
axis = service.role_axis(role)
|
||||
if axis is None:
|
||||
continue
|
||||
value = float(targets[role])
|
||||
steps.append(
|
||||
MoveStep(
|
||||
role=role,
|
||||
axis=axis,
|
||||
target=value,
|
||||
units=service.to_units(role, value),
|
||||
unit="mm" if role in LINEAR_ROLES else "°",
|
||||
)
|
||||
)
|
||||
return MovePlan(steps=tuple(steps), title=title, held=tuple(hold))
|
||||
|
||||
|
||||
def plan_return_to_initial(service: MotionService) -> MovePlan:
|
||||
"""Every axis to zero, press axis first, in :data:`RETURN_ORDER`."""
|
||||
return plan_pose_move(
|
||||
service,
|
||||
{role: 0.0 for role in RETURN_ORDER},
|
||||
order=RETURN_ORDER,
|
||||
)
|
||||
|
||||
|
||||
class PoseMover(QObject):
|
||||
"""Issues a :class:`MovePlan` one step at a time, off the poller's snapshots."""
|
||||
|
||||
started = Signal(object)
|
||||
stepStarted = Signal(object)
|
||||
finished = Signal(object)
|
||||
failed = Signal(str)
|
||||
cancelled = Signal()
|
||||
|
||||
def __init__(self, service: MotionService, submit, parent: QObject | None = None) -> None:
|
||||
"""
|
||||
Args:
|
||||
service: the motion service, for unit conversion and per-axis guards.
|
||||
submit: the poller's ``submit``, taking a ``service -> result`` callable.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self._service = service
|
||||
self._submit = submit
|
||||
self.state = MoveState()
|
||||
|
||||
# ------------------------------------------------------------------ control
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
plan = self.state.plan
|
||||
return bool(
|
||||
plan is not None
|
||||
and not self.state.finished
|
||||
and not self.state.failure
|
||||
and not self.state.cancelled
|
||||
)
|
||||
|
||||
def start(self, plan: MovePlan, *, now: float | None = None) -> MotionResult:
|
||||
"""Validate and begin. Refusal is returned, not raised."""
|
||||
if self.running:
|
||||
return MotionResult.refused("已有运动在执行 / a move is already running")
|
||||
refusal = self._service.check_axes_move(
|
||||
{step.role: step.target for step in plan.steps}
|
||||
)
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
if not plan.steps:
|
||||
return MotionResult.refused("没有要移动的轴 / nothing to move")
|
||||
|
||||
stamp = time.monotonic() if now is None else now
|
||||
self.state = MoveState(plan=plan, started_at=stamp, _step_started_at=stamp)
|
||||
self.started.emit(plan)
|
||||
self._issue_current()
|
||||
return MotionResult.success(plan.describe())
|
||||
|
||||
def cancel(self, reason: str = "") -> None:
|
||||
"""Abandon the queue. The axis already moving is stopped by the caller.
|
||||
|
||||
Deliberately does not stop anything itself: stopping is a controller command
|
||||
with its own guard, and the window issues it as one. What this guarantees is
|
||||
that no *further* axis is commanded after the stop.
|
||||
"""
|
||||
if self.state.plan is None or self.state.finished:
|
||||
return
|
||||
self.state.cancelled = True
|
||||
self.state.failure = reason
|
||||
self.state.plan = None
|
||||
self.cancelled.emit()
|
||||
|
||||
def _issue_current(self) -> None:
|
||||
step = self.state.step
|
||||
if step is None:
|
||||
self._complete()
|
||||
return
|
||||
self.state._step_started_at = time.monotonic()
|
||||
self.state._idle_since = None
|
||||
if not step.units == step.units: # NaN: no calibration for this role
|
||||
self._fail(f"{step.role} 无法换算成用户单位 / cannot convert {step.role}")
|
||||
return
|
||||
self._submit(lambda service, step=step: service.move_abs(step.axis, step.units))
|
||||
self.stepStarted.emit(step)
|
||||
|
||||
def _complete(self) -> None:
|
||||
self.state.finished = True
|
||||
plan = self.state.plan
|
||||
self.state.plan = None
|
||||
self.finished.emit(plan)
|
||||
|
||||
def _fail(self, message: str) -> None:
|
||||
self.state.failure = message
|
||||
self.state.plan = None
|
||||
self.failed.emit(message)
|
||||
|
||||
# ---------------------------------------------------------------- progress
|
||||
|
||||
def on_snapshot(self, snapshot: Snapshot) -> None:
|
||||
"""Advance the plan from a poll. Called on every refresh."""
|
||||
if not self.running:
|
||||
return
|
||||
step = self.state.step
|
||||
if step is None:
|
||||
self._complete()
|
||||
return
|
||||
now = time.monotonic()
|
||||
if now - self.state._step_started_at > STEP_TIMEOUT_S:
|
||||
self._fail(
|
||||
f"{step.role} 等待超时 / timed out waiting for {step.role}"
|
||||
)
|
||||
return
|
||||
|
||||
state = snapshot.axis(step.axis)
|
||||
if state is None:
|
||||
return
|
||||
if state.error or not state.read_ok:
|
||||
self._fail(f"{step.role}: {state.error or 'read failed'}")
|
||||
return
|
||||
if state.status.alarm:
|
||||
self._fail(f"{step.role} 报警 / alarm on {step.role}")
|
||||
return
|
||||
|
||||
# A linear role reads in millimetres, a rotary one in degrees; the service
|
||||
# converts lengths, the units module converts calibrated angles.
|
||||
if step.unit == "mm":
|
||||
travelled = self._service.to_mm(step.axis, state.dpos)
|
||||
else:
|
||||
travelled = user_units_to_deg(step.axis, state.dpos)
|
||||
tolerance = SETTLE_MM if step.unit == "mm" else SETTLE_DEG
|
||||
|
||||
if not state.idle:
|
||||
self.state._idle_since = None
|
||||
return
|
||||
if abs(travelled - step.target) <= tolerance:
|
||||
self.state.index += 1
|
||||
self._issue_current()
|
||||
return
|
||||
# Idle, but not there. A drive that has stopped short will not resume, so
|
||||
# this is a failure rather than something to keep waiting for.
|
||||
if self.state._idle_since is None:
|
||||
self.state._idle_since = now
|
||||
elif now - self.state._idle_since > STALL_GRACE_S:
|
||||
self._fail(
|
||||
f"{step.role} 停在 {travelled:.4f}{step.unit},目标 "
|
||||
f"{step.target:.4f}{step.unit} / stopped short of target"
|
||||
)
|
||||
|
||||
def progress_text(self) -> str:
|
||||
"""A one-line status for the panel."""
|
||||
if self.state.failure:
|
||||
return f"✗ {self.state.failure}"
|
||||
if self.state.finished:
|
||||
return "✓ 已完成 / done"
|
||||
step = self.state.step
|
||||
if step is None:
|
||||
return ""
|
||||
return (
|
||||
f"{self.state.index + 1}/{self.state.total} "
|
||||
f"{step.role} → {step.target:+.4f} {step.unit}"
|
||||
)
|
||||
@@ -5,18 +5,19 @@ without a GUI and without hardware. :mod:`~orisys_autocal.motion.poller` drives
|
||||
it from a worker thread; the UI only ever calls these methods.
|
||||
|
||||
Guards live here rather than in the UI on purpose. A motion command is refused
|
||||
for a stated reason — bus not initialised, axis not idle, non-numeric input —
|
||||
and :meth:`MotionService.jog` and friends return that reason as a
|
||||
``MotionResult`` instead of silently doing nothing.
|
||||
for a stated reason — bus not initialised, axis not idle, non-numeric input, a
|
||||
target past the P0 travel limit, an axis mapping that does not add up — and
|
||||
:meth:`MotionService.jog` and friends return that reason as a ``MotionResult``
|
||||
instead of silently doing nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
import os
|
||||
from typing import Callable, Iterable
|
||||
import time
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
|
||||
from orisys_app_shared.zmotion import ZAuxError
|
||||
|
||||
@@ -34,13 +35,14 @@ from .models import (
|
||||
MotionResult,
|
||||
Snapshot,
|
||||
)
|
||||
from .transport import DemoTransport, ZmcTransport, NativeTransport
|
||||
from .transport import NativeTransport, ZmcTransport
|
||||
from .units import (
|
||||
DEFAULT_ACCEL,
|
||||
DEFAULT_SPEED,
|
||||
DEFAULT_UNITS,
|
||||
PULSES_PER_MM,
|
||||
deg_to_user_units,
|
||||
mm_to_user_units,
|
||||
safe_float,
|
||||
units_per_degree,
|
||||
user_units_to_mm,
|
||||
)
|
||||
|
||||
@@ -49,6 +51,32 @@ logger = logging.getLogger(__name__)
|
||||
#: The platform has five axes; the bus may report fewer if a drive is missing.
|
||||
PLATFORM_AXES = 5
|
||||
|
||||
#: The platform directions a drive can be, in the order the model's frames are
|
||||
#: named. A drive's *number* says nothing about what it moves: the bus reports five
|
||||
#: identical CiA-402 axes, and whether drive 2 tilts the sensor or lifts the table is
|
||||
#: a property of the bench's wiring. This is the mapping the whole "move the platform
|
||||
#: to the simulated pose" path rests on, so it is configuration with a page in the
|
||||
#: device setup rather than a constant.
|
||||
AXIS_ROLES: tuple[str, ...] = ("x", "y", "z", "yaw", "pitch")
|
||||
|
||||
#: Roles whose target is a length, and roles whose target is an angle. A degree
|
||||
#: target checked against a millimetre limit is not a limit at all.
|
||||
LINEAR_ROLES: tuple[str, ...] = ("x", "y", "z")
|
||||
ROTARY_ROLES: tuple[str, ...] = ("yaw", "pitch")
|
||||
|
||||
#: Default mapping, from the bench: drives 0-4 are x, y, pitch, yaw, z.
|
||||
#:
|
||||
#: Deliberately *not* the same as the model's own order. It is written the way the
|
||||
#: machine is wired, so reading it against the terminal strip is a check rather than
|
||||
#: a translation.
|
||||
DEFAULT_AXIS_ROLES: dict[str, int] = {
|
||||
"x": 0,
|
||||
"y": 1,
|
||||
"pitch": 2,
|
||||
"yaw": 3,
|
||||
"z": 4,
|
||||
}
|
||||
|
||||
#: ``BUS_TYPE`` values written by the vendor ``.bas`` programs.
|
||||
_BUS_TYPE_NAMES: dict[int, BusType] = {0: "ecat", 1: "rtex"}
|
||||
|
||||
@@ -78,6 +106,27 @@ def clamp_bas_task(value: object, fallback: int = DEFAULT_BAS_TASK) -> int:
|
||||
return task if low <= task <= high else fallback
|
||||
|
||||
|
||||
def normalize_axis_roles(roles: Mapping[str, int] | None) -> dict[str, int]:
|
||||
"""A clean role -> drive mapping, falling back to the bench default.
|
||||
|
||||
Unusable entries are dropped rather than coerced: a role mapped to a drive the
|
||||
file did not contain is a configuration the app should report through
|
||||
:meth:`MotionService.roles_problem`, not silently repair.
|
||||
"""
|
||||
if roles is None:
|
||||
return dict(DEFAULT_AXIS_ROLES)
|
||||
clean: dict[str, int] = {}
|
||||
for role in AXIS_ROLES:
|
||||
value = roles.get(role)
|
||||
if value is None:
|
||||
continue
|
||||
try:
|
||||
clean[role] = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return clean
|
||||
|
||||
|
||||
def _online_failure(reply: str) -> str:
|
||||
"""The controller's own refusal text from an online command, or ``""``.
|
||||
|
||||
@@ -107,6 +156,8 @@ class MotionService:
|
||||
pulses_per_mm: float = PULSES_PER_MM,
|
||||
language: str = "zh_CN",
|
||||
bas_task: int = DEFAULT_BAS_TASK,
|
||||
axis_roles: Mapping[str, int] | None = None,
|
||||
limits: Mapping[str, float] | None = None,
|
||||
) -> None:
|
||||
self._transport: ZmcTransport = transport or NativeTransport()
|
||||
self.axis_count = int(axis_count)
|
||||
@@ -114,6 +165,14 @@ class MotionService:
|
||||
self._pulses_per_mm = pulses_per_mm
|
||||
self.language = language
|
||||
self.bas_task = clamp_bas_task(bas_task)
|
||||
# Which drive is which platform direction. The app cannot infer this from a
|
||||
# CiA-402 axis: the bus reports numbers. It is configuration, so it arrives
|
||||
# from the preference file and can be corrected in the device setup.
|
||||
self._axis_roles: dict[str, int] = normalize_axis_roles(axis_roles)
|
||||
# The mm limits from workflow P0. They live here rather than in the UI because
|
||||
# this is where motion is allowed or refused, and a limit the UI enforces is a
|
||||
# limit a later caller can walk around.
|
||||
self._limits: dict[str, float] = dict(limits or {})
|
||||
|
||||
self._spec: ConnectionSpec | None = None
|
||||
self._bus = BusStatus()
|
||||
@@ -151,6 +210,152 @@ class MotionService:
|
||||
def params(self, axis: int) -> AxisParams:
|
||||
return self._params.get(axis, AxisParams())
|
||||
|
||||
# --------------------------------------------------------------- axis roles
|
||||
|
||||
@property
|
||||
def axis_roles(self) -> dict[str, int]:
|
||||
"""Drive index per platform direction, as configured."""
|
||||
return dict(self._axis_roles)
|
||||
|
||||
def set_axis_roles(self, roles: Mapping[str, int] | None) -> None:
|
||||
"""Replace the mapping. Called when the device setup is edited."""
|
||||
self._axis_roles = normalize_axis_roles(roles)
|
||||
|
||||
def set_limits(self, limits: Mapping[str, float] | None) -> None:
|
||||
"""Replace the P0 limits. Called when the preference file is reloaded."""
|
||||
self._limits = dict(limits or {})
|
||||
|
||||
def role_axis(self, role: str) -> int | None:
|
||||
"""The drive that moves ``role``, or ``None`` if it is not configured."""
|
||||
return self._axis_roles.get(role)
|
||||
|
||||
def roles_problem(self) -> str:
|
||||
"""Why the mapping cannot be used, or ``""`` when it is sound.
|
||||
|
||||
A duplicated drive is the dangerous case — two roles claiming axis 3 means
|
||||
one of them silently does not move, and the pose would be wrong rather than
|
||||
refused — so it is reported rather than tolerated.
|
||||
"""
|
||||
missing = [role for role in AXIS_ROLES if role not in self._axis_roles]
|
||||
if missing:
|
||||
return self._text(
|
||||
f"轴映射未配置:{', '.join(missing)}",
|
||||
f"Axis mapping is not configured for: {', '.join(missing)}",
|
||||
)
|
||||
used: dict[int, str] = {}
|
||||
for role in AXIS_ROLES:
|
||||
axis = self._axis_roles[role]
|
||||
if not self._known_axis(axis):
|
||||
return self._text(
|
||||
f"{role} 指向轴 {axis},超出 0-{self.axis_count - 1}",
|
||||
f"{role} points at axis {axis}, outside 0-{self.axis_count - 1}",
|
||||
)
|
||||
if axis in used:
|
||||
return self._text(
|
||||
f"轴 {axis} 同时是 {used[axis]} 和 {role}",
|
||||
f"Axis {axis} is claimed by both {used[axis]} and {role}",
|
||||
)
|
||||
used[axis] = role
|
||||
return ""
|
||||
|
||||
@property
|
||||
def travel_limit_mm(self) -> float:
|
||||
"""The P0 travel limit. ``0`` means "not set", never "unlimited"."""
|
||||
return safe_float(self._limits.get("max_travel_mm"), 0.0)
|
||||
|
||||
def limit_for(self, role: str) -> float:
|
||||
"""The P0 limit for a role, in that role's own unit.
|
||||
|
||||
Linear roles are limited in millimetres and rotary roles in degrees: one
|
||||
number cannot bound both, and treating a degree target as millimetres is how
|
||||
a 180° move passes a 100 mm check.
|
||||
"""
|
||||
key = "max_travel_mm" if role in LINEAR_ROLES else f"max_{role}_deg"
|
||||
return safe_float(self._limits.get(key), 0.0)
|
||||
|
||||
def units_problem(self, role: str, axis: int) -> str:
|
||||
"""Why a role cannot be commanded in its own unit, or ``""``."""
|
||||
if role in LINEAR_ROLES:
|
||||
return ""
|
||||
scale = units_per_degree(axis)
|
||||
if not math.isfinite(scale) or scale <= 0:
|
||||
return self._text(
|
||||
f"{role} 用轴 {axis},但该轴没有角度标定(用户单位/度)",
|
||||
f"{role} uses axis {axis}, which has no user-units-per-degree "
|
||||
f"calibration",
|
||||
)
|
||||
return ""
|
||||
|
||||
def check_axes_move(self, targets: Mapping[str, float]) -> MotionResult | None:
|
||||
"""Refuse a coordinated pose move, or return ``None`` to allow it.
|
||||
|
||||
``targets`` is per role, in the role's own unit: millimetres for x / y / z,
|
||||
degrees for yaw / pitch.
|
||||
|
||||
Every reason a move can be wrong is checked *before* the first axis is
|
||||
commanded. A pose move is several coordinated moves, and discovering at axis
|
||||
three that axis four is out of range would leave the machine stopped
|
||||
somewhere that is neither the start nor the target.
|
||||
"""
|
||||
if not self.connected:
|
||||
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
|
||||
if not self._bus.initialized:
|
||||
return MotionResult.refused(
|
||||
self._text(
|
||||
"总线未初始化完成,禁止运动",
|
||||
"Bus is not initialised; motion is refused",
|
||||
)
|
||||
)
|
||||
problem = self.roles_problem()
|
||||
if problem:
|
||||
return MotionResult.refused(problem)
|
||||
if not targets:
|
||||
return MotionResult.refused(self._text("没有要移动的轴", "Nothing to move"))
|
||||
for role, value in targets.items():
|
||||
if role not in AXIS_ROLES:
|
||||
return MotionResult.refused(
|
||||
self._text(f"未知方向 {role}", f"Unknown direction {role}")
|
||||
)
|
||||
if not isinstance(value, (int, float)) or not math.isfinite(float(value)):
|
||||
return MotionResult.refused(
|
||||
self._text(f"{role} 目标不是有效数字", f"{role} target is not a number")
|
||||
)
|
||||
axis = self._axis_roles[role]
|
||||
units_problem = self.units_problem(role, axis)
|
||||
if units_problem:
|
||||
return MotionResult.refused(units_problem)
|
||||
for role, value in targets.items():
|
||||
limit = self.limit_for(role)
|
||||
unit = "mm" if role in LINEAR_ROLES else "°"
|
||||
if limit <= 0:
|
||||
return MotionResult.refused(
|
||||
self._text(
|
||||
f"{role} 的上限未设置(P0),禁止运动",
|
||||
f"The {role} limit is not set (P0); motion is refused",
|
||||
)
|
||||
)
|
||||
if abs(float(value)) > limit:
|
||||
return MotionResult.refused(
|
||||
self._text(
|
||||
f"{role} 目标 {float(value):.3f}{unit} 超出上限 {limit:.3f}{unit}",
|
||||
f"{role} target {float(value):.3f}{unit} is past the "
|
||||
f"{limit:.3f}{unit} limit",
|
||||
)
|
||||
)
|
||||
return None
|
||||
|
||||
def to_units(self, role: str, value: float) -> float:
|
||||
"""Controller user units for a role target, in that role's own unit."""
|
||||
axis = self._axis_roles.get(role)
|
||||
if axis is None:
|
||||
return math.nan
|
||||
if role in LINEAR_ROLES:
|
||||
units = self.params(axis).units or self._units_per_mm
|
||||
return mm_to_user_units(
|
||||
value, units=units, pulses_per_mm=self._pulses_per_mm
|
||||
)
|
||||
return deg_to_user_units(axis, value)
|
||||
|
||||
def set_language(self, language: str) -> None:
|
||||
self.language = language
|
||||
|
||||
|
||||
@@ -13,7 +13,12 @@ from typing import Any
|
||||
from orisys_app_shared import prefs as _prefs
|
||||
from orisys_app_shared.paths import preference_path
|
||||
|
||||
from .motion.service import DEFAULT_BAS_TASK, PLATFORM_AXES, clamp_bas_task
|
||||
from .motion.service import (
|
||||
DEFAULT_AXIS_ROLES,
|
||||
DEFAULT_BAS_TASK,
|
||||
PLATFORM_AXES,
|
||||
clamp_bas_task,
|
||||
)
|
||||
from .motion.units import DEFAULT_ACCEL, DEFAULT_SPEED, DEFAULT_UNITS
|
||||
|
||||
#: Connection modes, in the order the combo offers them.
|
||||
@@ -81,6 +86,10 @@ def _default_safety_limits() -> dict[str, float]:
|
||||
|
||||
``0`` means "not set" for the ones where a real value is required, and the
|
||||
app reports that at the P0 gate rather than treating it as unlimited.
|
||||
|
||||
Lengths are millimetres and angles degrees, because the roles they bound are:
|
||||
one number cannot bound both, and a degree target checked against a millimetre
|
||||
limit is not a check.
|
||||
"""
|
||||
return {
|
||||
"max_travel_mm": 100.0,
|
||||
@@ -90,6 +99,11 @@ def _default_safety_limits() -> dict[str, float]:
|
||||
#: How far the axis may descend looking for contact before the run is
|
||||
#: aborted (workflow P7, no-contact abort).
|
||||
"contact_search_mm": 3.0,
|
||||
#: Stage envelope, from the confirmed bench values: the yaw stage turns a
|
||||
#: half-turn either way, the pitch stage a full turn. These are the limits a
|
||||
#: pose move is checked against before any axis is commanded.
|
||||
"max_yaw_deg": 180.0,
|
||||
"max_pitch_deg": 360.0,
|
||||
}
|
||||
|
||||
|
||||
@@ -104,9 +118,10 @@ def _default_sensor_setup() -> dict[str, Any]:
|
||||
"sensor_type": "box",
|
||||
"config_ref": "",
|
||||
"geometry_id": "",
|
||||
#: Which axis presses, and the probe tip offset from the platform's
|
||||
#: reference point. Empty offset means "not set", which blocks P5.
|
||||
"press_axis": 2,
|
||||
#: The probe tip's offset from the platform's reference point. Empty offset
|
||||
#: means "not set", which blocks P5. Which axis presses is **not** here: it
|
||||
#: is ``axis_roles["z"]``, because one fact written twice is one fact that
|
||||
#: eventually disagrees.
|
||||
"probe_tip_offset_mm": [0.0, 0.0, 0.0],
|
||||
"probe_tip_set": False,
|
||||
#: 4x4 row-major sensor -> platform transform, plus how well it fitted.
|
||||
@@ -173,6 +188,19 @@ def default_preferences() -> dict[str, Any]:
|
||||
# --- workflow blocks (see apps/autocal/docs/workflow.md) --------------
|
||||
#: P0: limits enforced from P2 onward.
|
||||
"safety_limits": _default_safety_limits(),
|
||||
#: Which drive is which platform direction, from the bench wiring: drives
|
||||
#: 0-4 are x, y, pitch, yaw, z. There is no way to read this off the bus —
|
||||
#: five CiA-402 axes report numbers, not names — so it is configuration, and
|
||||
#: the pose-move path refuses to run until it is complete and free of
|
||||
#: duplicates. It is also the *only* place "which axis presses" is written:
|
||||
#: the sensor setup used to carry a second copy, which is how the two would
|
||||
#: have drifted apart.
|
||||
"axis_roles": dict(DEFAULT_AXIS_ROLES),
|
||||
#: Ask before a pose move. On by default — the first moves on a machine are
|
||||
#: the ones worth reading — and turnable off once the numbers are trusted,
|
||||
#: because a prompt that cannot be dismissed unread is one that gets clicked
|
||||
#: through without being read.
|
||||
"move_confirm": True,
|
||||
#: P3/P4: sensor under test, probe tip, sensor<->platform transform.
|
||||
"sensor_setup": _default_sensor_setup(),
|
||||
#: P5: grid generation and run parameters.
|
||||
@@ -206,9 +234,26 @@ def default_preferences() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
#: Preference blocks whose *keys* are added to over time. Stored preferences are
|
||||
#: merged one level deep, so a block saved by an older build replaces the whole
|
||||
#: default block and any key added since reads as absent. For a P0 limit that is not
|
||||
#: a cosmetic problem: the move is refused with "the limit is not set" while the file
|
||||
#: in front of the operator says nothing about it. Filling the block key by key keeps
|
||||
#: what was chosen and supplies only what was never there.
|
||||
_MERGED_BLOCKS = ("connection", "safety_limits", "sensor_setup", "sequence_profile")
|
||||
|
||||
|
||||
def load_preferences() -> dict[str, Any]:
|
||||
"""Stored preferences with AutoCal's defaults filling the gaps."""
|
||||
prefs = _prefs.load_preferences(default_preferences())
|
||||
defaults = default_preferences()
|
||||
prefs = _prefs.load_preferences(defaults)
|
||||
for name in _MERGED_BLOCKS:
|
||||
stored = prefs.get(name)
|
||||
if not isinstance(stored, dict):
|
||||
continue
|
||||
block = dict(defaults.get(name) or {})
|
||||
block.update(stored)
|
||||
prefs[name] = block
|
||||
# A stored document from an older build may miss a newly added axis block.
|
||||
axis_params = prefs.setdefault("axis_params", {})
|
||||
for axis, values in _default_axis_params().items():
|
||||
@@ -252,6 +297,39 @@ def load_preferences() -> dict[str, Any]:
|
||||
prefs["finger_tool_xy_mm"] = tool
|
||||
# A hand-edited task number must not reach the controller as typed.
|
||||
prefs["bas_task"] = clamp_bas_task(prefs.get("bas_task"))
|
||||
prefs = _migrate_axis_roles(prefs)
|
||||
return prefs
|
||||
|
||||
|
||||
def _migrate_axis_roles(prefs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Complete the axis mapping, and take the old duplicate press axis out.
|
||||
|
||||
``sensor_setup.press_axis`` named the pressing drive on its own. That is
|
||||
``axis_roles["z"]`` now — the same fact — and a stored value is moved there when
|
||||
the mapping has not been set, so a bench that had it configured keeps working.
|
||||
The old key is removed either way: leaving it would keep a second answer to
|
||||
"which axis presses" in the file.
|
||||
"""
|
||||
roles = prefs.get("axis_roles")
|
||||
roles = dict(roles) if isinstance(roles, dict) else {}
|
||||
sensor = prefs.get("sensor_setup")
|
||||
if isinstance(sensor, dict):
|
||||
legacy = sensor.pop("press_axis", None)
|
||||
if legacy is not None and "z" not in roles:
|
||||
try:
|
||||
roles["z"] = int(legacy)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
merged = dict(DEFAULT_AXIS_ROLES)
|
||||
# A stored mapping wins where it is usable; the default fills what it does not
|
||||
# cover, so an old document that only named the press axis still comes up with a
|
||||
# complete mapping rather than a refusal.
|
||||
for role, value in roles.items():
|
||||
try:
|
||||
merged[role] = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
prefs["axis_roles"] = merged
|
||||
return prefs
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Sensor assets: export bundles, the flat UV view, and the mount frames."""
|
||||
|
||||
from .export_bundle import BundleError, SensorBundle, load_bundle
|
||||
from .frames import AXIS_MAP, describe as describe_frames, mount_rotation, to_platform, to_sensor
|
||||
from .frames import AXIS_MAP, mount_rotation, to_platform, to_sensor
|
||||
from .frames import describe as describe_frames
|
||||
from .uv_flat import (
|
||||
DEFAULT_FLAT_SIZE,
|
||||
FlatSurface,
|
||||
@@ -12,8 +13,8 @@ from .uv_flat import (
|
||||
|
||||
__all__ = [
|
||||
"AXIS_MAP",
|
||||
"BundleError",
|
||||
"DEFAULT_FLAT_SIZE",
|
||||
"BundleError",
|
||||
"FlatSurface",
|
||||
"SensorBundle",
|
||||
"describe_frames",
|
||||
|
||||
@@ -26,10 +26,11 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Mapping
|
||||
from typing import Any
|
||||
|
||||
#: The version this build writes, and the highest it will read.
|
||||
FORMAT_VERSION = "1.0"
|
||||
|
||||
@@ -14,6 +14,9 @@ five-axis platform, acting on the wrong axis is the expensive mistake.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QBrush, QColor
|
||||
from PySide6.QtWidgets import (
|
||||
@@ -29,8 +32,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from ..motion import (
|
||||
DEFAULT_ACCEL,
|
||||
DEFAULT_SPEED,
|
||||
@@ -582,7 +583,7 @@ def _millimetres(units: float, user_units: float) -> str:
|
||||
if not units:
|
||||
return "—"
|
||||
millimetres = user_units_to_mm(user_units, units=units)
|
||||
if millimetres != millimetres: # NaN
|
||||
if math.isnan(millimetres):
|
||||
return "—"
|
||||
return f"{millimetres:.3f}"
|
||||
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
"""The permanent axis readout rail.
|
||||
|
||||
Read-only by design. Every per-axis *command* lives in the Axis-debug tab, so
|
||||
there is exactly one place a move can be issued from — two places to jog from is
|
||||
how an operator moves the wrong axis. What this rail adds is the thing the
|
||||
operator needs while working in any other tab: whether each drive is enabled,
|
||||
where it is, and whether it is moving.
|
||||
Read-only apart from four platform-wide actions. Every per-axis *command* lives in
|
||||
the Axis-debug tab, so there is exactly one place a single-axis move can be issued
|
||||
from — two places to jog from is how an operator moves the wrong axis. What this rail
|
||||
adds is the thing the operator needs while working in any other tab: whether each
|
||||
drive is enabled, where it is, and whether it is moving.
|
||||
|
||||
The three global actions are here rather than in the tab because they are the
|
||||
ones you reach for in a hurry, and they must not require finding a tab first.
|
||||
The four global actions are here rather than in a tab because they are the ones you
|
||||
reach for in a hurry, or reach for *after* finishing in some other tab: enable,
|
||||
stop, clear alarms, and return every axis to the initial state. They must not
|
||||
require finding the right tab first.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import load_colors
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QGridLayout,
|
||||
@@ -23,9 +29,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import load_colors
|
||||
|
||||
from ..motion import Snapshot, user_units_to_mm
|
||||
|
||||
#: Columns of the compact readout: axis, enable state, position.
|
||||
@@ -41,6 +44,10 @@ class AxisReadoutPanel(QGroupBox):
|
||||
enableAllRequested = Signal(bool)
|
||||
stopAllRequested = Signal()
|
||||
clearAllRequested = Signal()
|
||||
#: Return every drive to the initial state. Here, not in the finger screen,
|
||||
#: because it is a machine-level action: it is wanted after a move made in *any*
|
||||
#: tab, and the rail is on screen in all of them.
|
||||
returnRequested = Signal()
|
||||
|
||||
def __init__(self, axis_count: int, theme: str = "dark", parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -72,7 +79,14 @@ class AxisReadoutPanel(QGroupBox):
|
||||
self.stop_all_button.clicked.connect(self.stopAllRequested)
|
||||
self.clear_all_button = QPushButton(self)
|
||||
self.clear_all_button.clicked.connect(self.clearAllRequested)
|
||||
for button in (self.enable_all_button, self.stop_all_button, self.clear_all_button):
|
||||
self.return_button = QPushButton(self)
|
||||
self.return_button.clicked.connect(self.returnRequested)
|
||||
for button in (
|
||||
self.enable_all_button,
|
||||
self.stop_all_button,
|
||||
self.return_button,
|
||||
self.clear_all_button,
|
||||
):
|
||||
button.setProperty("rail", True)
|
||||
outer.addWidget(button)
|
||||
|
||||
@@ -110,7 +124,12 @@ class AxisReadoutPanel(QGroupBox):
|
||||
|
||||
def set_connected(self, connected: bool) -> None:
|
||||
self._connected = bool(connected)
|
||||
for button in (self.enable_all_button, self.stop_all_button, self.clear_all_button):
|
||||
for button in (
|
||||
self.enable_all_button,
|
||||
self.stop_all_button,
|
||||
self.clear_all_button,
|
||||
self.return_button,
|
||||
):
|
||||
button.setEnabled(self._connected)
|
||||
|
||||
def update_snapshot(self, snapshot: Snapshot) -> None:
|
||||
@@ -138,7 +157,7 @@ class AxisReadoutPanel(QGroupBox):
|
||||
|
||||
millimetres = user_units_to_mm(state.dpos, units=state.units) if state.units else None
|
||||
units_text = f"{state.dpos:.3f}"
|
||||
if millimetres is not None and millimetres == millimetres:
|
||||
if millimetres is not None and not math.isnan(millimetres):
|
||||
units_text = t("axes.position_pair", value=units_text, mm=f"{millimetres:.3f}")
|
||||
widgets["position"].setText(units_text)
|
||||
widgets["position"].setToolTip(
|
||||
@@ -166,5 +185,7 @@ class AxisReadoutPanel(QGroupBox):
|
||||
self.setTitle(t("readout.title"))
|
||||
self.stop_all_button.setText(t("axes.stop_all"))
|
||||
self.clear_all_button.setText(t("axes.clear_alarm_all"))
|
||||
self.return_button.setText(t("move.return_initial"))
|
||||
self.return_button.setToolTip(t("move.return_hint"))
|
||||
# The toggle's label depends on current state, so re-derive it.
|
||||
self.update_snapshot(self._snapshot)
|
||||
|
||||
@@ -7,13 +7,12 @@ controller is reachable.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QWidget
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import logo_topbar_path
|
||||
from orisys_app_shared.theme import load_colors
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QWidget
|
||||
|
||||
#: Chip tone -> colour token name.
|
||||
_TONE_TOKENS = {
|
||||
|
||||
@@ -9,12 +9,12 @@ vendor reference guards this too, by checking ``BUS_TYPE`` before every call).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
@@ -22,8 +22,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
#: Height of this panel's fields and buttons. Pinned for the same reason as the
|
||||
#: axis panel's: an unconstrained QLineEdit is ~34 px, and four of them stacked
|
||||
#: push this panel tall enough to steal an axis row from the panel above it.
|
||||
|
||||
@@ -11,6 +11,8 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import app_root
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QGridLayout,
|
||||
@@ -23,9 +25,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import app_root
|
||||
|
||||
from ..motion import INIT_FAILED, INIT_OK, INIT_RUNNING, BusStatus
|
||||
|
||||
_STATE_KEYS = {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
@@ -15,8 +16,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from ..motion import ConnectionSpec
|
||||
from ..preferences import CONNECTION_MODES, DEFAULT_CONTROLLER_IPS
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""The device setup: connection and bus initialisation, behind one button.
|
||||
"""The device setup: connection, bus initialisation and the axis mapping.
|
||||
|
||||
Both groups are touched once per machine start-up — reach the controller, download
|
||||
the bus program, watch the bus come up — and then not again for the rest of the
|
||||
session. In the left rail they were spending ~460 px of its height on that, above
|
||||
the axis readout that is read all day, which is what made the rail scroll.
|
||||
These are the facts about the machine itself, as opposed to the job: reach the
|
||||
controller, download the bus program, watch the bus come up, and say which drive moves
|
||||
which platform direction. All of them are touched once per machine start-up — or once
|
||||
per re-wire — and then not again for the rest of the session. In the left rail the
|
||||
first two were spending ~460 px of its height, above the axis readout that is read all
|
||||
day, which is what made the rail scroll.
|
||||
|
||||
They live in a **modeless dialog** rather than a modal one on purpose: connecting and
|
||||
downloading are asynchronous (the poller runs the state machine, the status strip
|
||||
@@ -12,35 +14,156 @@ the axis readout while it happens. Modeless also means the dialog can be opened
|
||||
several places — the strip's button, its state chips, the File menu — without any of
|
||||
them having to block.
|
||||
|
||||
The dialog owns the two panels; the window keeps a reference to each
|
||||
The dialog owns the connection and bus panels; the window keeps a reference to each
|
||||
(``window.connection_panel`` / ``window.bus_panel``) because it is the window that
|
||||
wires them to the controller, and because the tools and checks reach for them there.
|
||||
The axis mapping is read back by the window on every change, because the motion guards
|
||||
have to see the same numbers the page shows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QLabel,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from ..motion import ConnectionSpec
|
||||
from ..motion.service import AXIS_ROLES, DEFAULT_AXIS_ROLES, PLATFORM_AXES
|
||||
from .bus_panel import BusPanel
|
||||
from .connection_panel import ConnectionPanel
|
||||
|
||||
#: Tab identity -> locale key, in the order they are shown. The order is the work:
|
||||
#: reach the machine, then bring its bus up.
|
||||
#: reach the machine, bring its bus up, then say what each drive moves.
|
||||
TABS: tuple[tuple[str, str], ...] = (
|
||||
("connection", "setup.tab_connection"),
|
||||
("bus", "setup.tab_bus"),
|
||||
("axes", "setup.tab_axes"),
|
||||
)
|
||||
|
||||
#: Role -> locale key for the mapping rows.
|
||||
_ROLE_KEYS = {
|
||||
"x": "setup.role_x",
|
||||
"y": "setup.role_y",
|
||||
"z": "setup.role_z",
|
||||
"yaw": "setup.role_yaw",
|
||||
"pitch": "setup.role_pitch",
|
||||
}
|
||||
|
||||
|
||||
class AxisRolesPage(QWidget):
|
||||
"""Which drive moves which platform direction.
|
||||
|
||||
A page of its own because it is a separate fact: connection and bus are about
|
||||
*reaching* the machine, this is about what its drives do. Getting it wrong moves
|
||||
the wrong motor, so a duplicate is reported rather than quietly accepted — and the
|
||||
motion guards read the same mapping, so a page left in a contradictory state
|
||||
refuses motion instead of guessing which claim was meant.
|
||||
"""
|
||||
|
||||
rolesChanged = Signal()
|
||||
|
||||
def __init__(
|
||||
self, roles: dict[str, int] | None = None, parent: QWidget | None = None
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
column = QVBoxLayout(self)
|
||||
column.setContentsMargins(8, 8, 8, 8)
|
||||
column.setSpacing(8)
|
||||
|
||||
self.hint_label = QLabel(self)
|
||||
self.hint_label.setObjectName("hintLabel")
|
||||
self.hint_label.setWordWrap(True)
|
||||
column.addWidget(self.hint_label)
|
||||
|
||||
self.box = QGroupBox(self)
|
||||
self.box.setObjectName("groupBox")
|
||||
form = QFormLayout(self.box)
|
||||
form.setContentsMargins(10, 8, 10, 10)
|
||||
form.setSpacing(6)
|
||||
|
||||
self.combos: dict[str, QComboBox] = {}
|
||||
self.labels: dict[str, QLabel] = {}
|
||||
for role in AXIS_ROLES:
|
||||
label = QLabel(self.box)
|
||||
combo = QComboBox(self.box)
|
||||
for axis in range(PLATFORM_AXES):
|
||||
combo.addItem(str(axis), axis)
|
||||
combo.currentIndexChanged.connect(self._on_changed)
|
||||
self.labels[role] = label
|
||||
self.combos[role] = combo
|
||||
form.addRow(label, combo)
|
||||
column.addWidget(self.box)
|
||||
|
||||
self.problem_label = QLabel(self)
|
||||
self.problem_label.setObjectName("statusValue")
|
||||
self.problem_label.setWordWrap(True)
|
||||
column.addWidget(self.problem_label)
|
||||
column.addStretch(1)
|
||||
|
||||
self.set_roles(roles or {})
|
||||
self.retranslate()
|
||||
|
||||
# ------------------------------------------------------------------- state
|
||||
|
||||
def set_roles(self, roles: dict[str, int]) -> None:
|
||||
"""Show a mapping without announcing a change the operator did not make.
|
||||
|
||||
An empty mapping falls back to the bench default rather than to "every role
|
||||
on drive 0", which would open the page in a contradictory state and — because
|
||||
the guards read the same mapping — refuse motion for a reason nobody typed.
|
||||
"""
|
||||
source = dict(roles) or dict(DEFAULT_AXIS_ROLES)
|
||||
for role, combo in self.combos.items():
|
||||
value = source.get(role)
|
||||
index = combo.findData(int(value)) if value is not None else -1
|
||||
combo.blockSignals(True)
|
||||
try:
|
||||
combo.setCurrentIndex(index if index >= 0 else 0)
|
||||
finally:
|
||||
combo.blockSignals(False)
|
||||
self._refresh_problem(announce=False)
|
||||
|
||||
def roles(self) -> dict[str, int]:
|
||||
return {role: int(combo.currentData()) for role, combo in self.combos.items()}
|
||||
|
||||
def problem(self) -> str:
|
||||
"""The duplicate mapping, described, or ``""`` when it is sound."""
|
||||
seen: dict[int, str] = {}
|
||||
for role, combo in self.combos.items():
|
||||
axis = int(combo.currentData())
|
||||
if axis in seen:
|
||||
return t("setup.axes_duplicate", detail=f"{seen[axis]} ↔ {role}")
|
||||
seen[axis] = role
|
||||
return ""
|
||||
|
||||
def _on_changed(self, _index: int) -> None:
|
||||
self._refresh_problem()
|
||||
|
||||
def _refresh_problem(self, *, announce: bool = True) -> None:
|
||||
problem = self.problem()
|
||||
self.problem_label.setText(f"⚠ {problem}" if problem else "")
|
||||
if announce:
|
||||
self.rolesChanged.emit()
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.hint_label.setText(t("setup.axes_hint"))
|
||||
self.box.setTitle(t("setup.tab_axes"))
|
||||
for role, label in self.labels.items():
|
||||
label.setText(t(_ROLE_KEYS[role]))
|
||||
self._refresh_problem(announce=False)
|
||||
|
||||
|
||||
class DeviceSetupDialog(QDialog):
|
||||
"""Connection and bus initialisation, the once-per-start controls."""
|
||||
@@ -49,6 +172,7 @@ class DeviceSetupDialog(QDialog):
|
||||
self,
|
||||
spec: ConnectionSpec,
|
||||
bas_path: str = "",
|
||||
roles: dict[str, int] | None = None,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -70,6 +194,7 @@ class DeviceSetupDialog(QDialog):
|
||||
self.tabs.setObjectName("setupTabs")
|
||||
self.connection_panel = ConnectionPanel(spec, self.tabs)
|
||||
self.bus_panel = BusPanel(bas_path, self.tabs)
|
||||
self.roles_page = AxisRolesPage(roles, self.tabs)
|
||||
for key, locale_key in TABS:
|
||||
self.tabs.addTab(self.panel(key), "")
|
||||
column.addWidget(self.tabs, 1)
|
||||
@@ -90,8 +215,10 @@ class DeviceSetupDialog(QDialog):
|
||||
# ----------------------------------------------------------------- content
|
||||
|
||||
def panel(self, key: str) -> QWidget:
|
||||
"""One of the two panels, by tab key."""
|
||||
return self.connection_panel if key == "connection" else self.bus_panel
|
||||
"""One of the three pages, by tab key."""
|
||||
if key == "connection":
|
||||
return self.connection_panel
|
||||
return self.bus_panel if key == "bus" else self.roles_page
|
||||
|
||||
def open_for(self, tab: str | None = None) -> None:
|
||||
"""Show the dialog, raising it if it is already up, and pick a tab.
|
||||
@@ -155,3 +282,4 @@ class DeviceSetupDialog(QDialog):
|
||||
self.close_button.setText(t("setup.close"))
|
||||
self.connection_panel.retranslate()
|
||||
self.bus_panel.retranslate()
|
||||
self.roles_page.retranslate()
|
||||
|
||||
@@ -34,7 +34,7 @@ from orisys_app_shared.i18n import t
|
||||
|
||||
from ..motion.kinematics import PlatformStack, Pose, rotation_about
|
||||
from ..sensor.export_bundle import SensorBundle
|
||||
from ..sensor.frames import mount_rotation, to_platform, to_sensor
|
||||
from ..sensor.frames import to_sensor
|
||||
|
||||
#: Orbit drag sensitivity and zoom step, matching the design repo's preview.
|
||||
ORBIT_RAD_PER_PX = 0.008
|
||||
|
||||
@@ -22,6 +22,7 @@ import math
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import QPoint, Qt, Signal
|
||||
from PySide6.QtGui import QColor, QImage, QPainter, QPen, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
@@ -34,7 +35,6 @@ from PySide6.QtWidgets import (
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
@@ -42,8 +42,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from ..motion.kinematics import PlatformStack
|
||||
from ..sensor.export_bundle import SensorBundle
|
||||
from ..sensor.frames import describe as describe_frames
|
||||
@@ -226,6 +224,12 @@ class FingerPanel(QGroupBox):
|
||||
loadRequested = Signal()
|
||||
pickRequested = Signal(int, int)
|
||||
stackChanged = Signal()
|
||||
#: Run the selected point's solved pose on the platform, and stop moving.
|
||||
executeRequested = Signal()
|
||||
stopRequested = Signal()
|
||||
#: Whether to ask before moving. Persisted; the window honours it for every
|
||||
#: pose move, including the rail's return-to-zero.
|
||||
confirmChanged = Signal(bool)
|
||||
|
||||
#: Columns of the point table, minus the leading select checkbox. The first six
|
||||
#: are the ones an operator may edit — the sensor-frame point and its normal —
|
||||
@@ -532,8 +536,35 @@ class FingerPanel(QGroupBox):
|
||||
# colour call hid the base plane and the whole trajectory without a word.
|
||||
self.preview.problemChanged.connect(lambda _message: self._refresh_reach())
|
||||
controls.addWidget(self.sim_button)
|
||||
# Running the selected pose on the machine, and stopping it. Both live here
|
||||
# because both are about *this* point's move; the stop is a second access
|
||||
# point to the same command the status strip's 全部停止 issues, not a second
|
||||
# implementation — an emergency stop has to be where the eyes already are.
|
||||
self.execute_button = QPushButton(box)
|
||||
self.execute_button.setEnabled(False)
|
||||
self.execute_button.clicked.connect(self.executeRequested)
|
||||
self.stop_button = QPushButton(box)
|
||||
self.stop_button.setObjectName("dangerButton")
|
||||
self.stop_button.setEnabled(False)
|
||||
self.stop_button.clicked.connect(self.stopRequested)
|
||||
controls.addWidget(self.execute_button)
|
||||
controls.addWidget(self.stop_button)
|
||||
# Confirming every move is right when the machine is new to the operator and
|
||||
# tedious once it is not — and a prompt that cannot be turned off is one that
|
||||
# gets clicked through without reading. The choice is the operator's, and the
|
||||
# same policy covers the rail's return-to-zero.
|
||||
self.confirm_check = QCheckBox(box)
|
||||
self.confirm_check.setChecked(True)
|
||||
self.confirm_check.toggled.connect(self.confirmChanged)
|
||||
controls.addWidget(self.confirm_check)
|
||||
controls.addStretch(1)
|
||||
column.addLayout(controls)
|
||||
# What the move is doing: which axis, which step, or why it refused. A move
|
||||
# that reports nothing is indistinguishable from one that never started.
|
||||
self.move_label = QLabel(box)
|
||||
self.move_label.setObjectName("hintLabel")
|
||||
self.move_label.setWordWrap(True)
|
||||
column.addWidget(self.move_label)
|
||||
# A real label, not text painted onto the GL widget: over a GL surface Qt
|
||||
# composites a long overflowing string as an overlap of itself, and the
|
||||
# legend was unreadable on the bench because of it. A label wraps.
|
||||
@@ -688,6 +719,57 @@ class FingerPanel(QGroupBox):
|
||||
t("finger.sim_stop") if running else t("finger.sim_play")
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------- pose move
|
||||
|
||||
def set_move_available(self, available: bool, *, moving: bool = False) -> None:
|
||||
"""Mirror the mover's state onto the two buttons.
|
||||
|
||||
The stop is deliberately *not* gated on the same conditions as the execute
|
||||
button: it is enabled whenever the platform may be commanded *or* something
|
||||
is moving. A stop greyed out while an axis coasts would be the worst possible
|
||||
button, and the emergency case is exactly the one where the execute
|
||||
conditions no longer hold.
|
||||
"""
|
||||
self.execute_button.setEnabled(bool(available) and not moving)
|
||||
self.stop_button.setEnabled(bool(available) or bool(moving))
|
||||
|
||||
def set_move_status(self, text: str) -> None:
|
||||
"""One line about the move: step, progress, or the refusal."""
|
||||
self.move_label.setText(text)
|
||||
|
||||
def set_confirm(self, confirm: bool) -> None:
|
||||
"""Show the stored policy without announcing it as a change."""
|
||||
self.confirm_check.blockSignals(True)
|
||||
try:
|
||||
self.confirm_check.setChecked(bool(confirm))
|
||||
finally:
|
||||
self.confirm_check.blockSignals(False)
|
||||
|
||||
def solved_pose(self) -> tuple[dict[str, float], tuple[str, ...]] | None:
|
||||
"""The selected point's per-role targets, and the roles held back.
|
||||
|
||||
The two numbers that matter are the *table's* move and the stage angles: the
|
||||
table translation is what the stages are resolved against, and it is what the
|
||||
preview drew as the cyan path's end.
|
||||
|
||||
z is returned as *held*: executing a simulated pose moves the stages and the
|
||||
table, it does not press. Pressing is P7 and has its own guards — contact
|
||||
detection, press depth, the sensor's second opinion — none of which exist
|
||||
here, so a move that reached the contact height would be a press with none of
|
||||
them.
|
||||
"""
|
||||
pose = self._last_pose
|
||||
if pose is None:
|
||||
return None
|
||||
table_x, table_y = pose.table_xy_mm
|
||||
targets = {
|
||||
"x": float(table_x),
|
||||
"y": float(table_y),
|
||||
"yaw": float(pose.yaw_deg),
|
||||
"pitch": float(pose.pitch_deg),
|
||||
}
|
||||
return targets, ("z",)
|
||||
|
||||
def _sync_sim_button(self) -> None:
|
||||
self.sim_button.setText(
|
||||
t("finger.sim_stop") if self.preview.simulating else t("finger.sim_play")
|
||||
@@ -1181,6 +1263,12 @@ class FingerPanel(QGroupBox):
|
||||
self.trajectory_check.setText(t("finger.show_trajectory"))
|
||||
self.sim_button.setToolTip(t("finger.sim_hint"))
|
||||
self._sync_sim_button()
|
||||
self.execute_button.setText(t("move.execute"))
|
||||
self.execute_button.setToolTip(t("move.execute_hint"))
|
||||
self.stop_button.setText(t("move.stop"))
|
||||
self.stop_button.setToolTip(t("move.stop_hint"))
|
||||
self.confirm_check.setText(t("move.confirm_check"))
|
||||
self.confirm_check.setToolTip(t("move.confirm_hint"))
|
||||
self.table.setHorizontalHeaderLabels(
|
||||
[""] + [t(f"finger.col_{key}") for key in self._headers]
|
||||
)
|
||||
|
||||
@@ -25,9 +25,11 @@ list so a runner cannot start from a table that was never validated.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
@@ -44,8 +46,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from ..preferences import POINT_ORDERS, SURFACE_MODES
|
||||
from ..sequence_io import (
|
||||
SequencePoint,
|
||||
@@ -642,7 +642,7 @@ def _as_number(text: str, fallback: float) -> float:
|
||||
value = float(str(text).strip())
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
return value if value == value else fallback # reject NaN
|
||||
return value if not math.isnan(value) else fallback
|
||||
|
||||
|
||||
def _format_number(value: Any) -> str:
|
||||
|
||||
@@ -20,19 +20,18 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import load_colors
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import load_colors
|
||||
|
||||
from ..motion import Snapshot
|
||||
|
||||
#: Tone -> colour token. Matches the brand bar's chip vocabulary.
|
||||
|
||||
@@ -15,12 +15,11 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared import API_VERSION, __version__ as shared_version
|
||||
from orisys_app_shared import API_VERSION
|
||||
from orisys_app_shared import __version__ as shared_version
|
||||
from orisys_app_shared.i18n import set_locale, t
|
||||
from orisys_app_shared.paths import outputs_dir
|
||||
from orisys_app_shared.theme import build_qss, load_colors
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QAction, QCloseEvent
|
||||
from PySide6.QtWidgets import (
|
||||
@@ -40,9 +39,17 @@ from PySide6.QtWidgets import (
|
||||
from .. import __version__ as app_version
|
||||
from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot
|
||||
from ..motion.kinematics import PlatformStack
|
||||
from ..motion.pose_move import PoseMover, plan_pose_move, plan_return_to_initial
|
||||
from ..motion.service import clamp_bas_task
|
||||
from ..motion.units import DEFAULT_UNITS
|
||||
from ..preferences import load_preferences, save_preferences
|
||||
from ..sensor import BundleError, load_bundle, load_uv_texture, rasterise, texture_path_for
|
||||
from ..sensor import (
|
||||
BundleError,
|
||||
load_bundle,
|
||||
load_uv_texture,
|
||||
rasterise,
|
||||
texture_path_for,
|
||||
)
|
||||
from ..sensor.uv_flat import DEFAULT_FLAT_SIZE
|
||||
from ..sequence_io import (
|
||||
FILE_SUFFIX,
|
||||
@@ -145,6 +152,7 @@ class AutoCalMainWindow(QMainWindow):
|
||||
# Its size and last tab, from the previous session. Applied here rather than
|
||||
# in ``_build_ui`` because it is stored state, like the window geometry.
|
||||
self.device_setup.apply_settings(self.prefs.get("device_setup"))
|
||||
self.finger_panel.set_confirm(self._confirm_moves)
|
||||
self.retranslate()
|
||||
|
||||
# Load the remembered bundle, so the finger debugger comes up working.
|
||||
@@ -206,10 +214,16 @@ class AutoCalMainWindow(QMainWindow):
|
||||
# so the two panels always exist — the window wires them to the controller,
|
||||
# and the checks and tools reach for them here.
|
||||
self.device_setup = DeviceSetupDialog(
|
||||
self.spec, str(self.prefs.get("bas_path") or ""), self
|
||||
self.spec,
|
||||
str(self.prefs.get("bas_path") or ""),
|
||||
self.prefs.get("axis_roles"),
|
||||
self,
|
||||
)
|
||||
self.connection_panel = self.device_setup.connection_panel
|
||||
self.bus_panel = self.device_setup.bus_panel
|
||||
# The mapping is live: the guards read it from the service, so an edit on the
|
||||
# page has to reach the service rather than only the preference file.
|
||||
self.device_setup.roles_page.rolesChanged.connect(self._on_axis_roles_changed)
|
||||
|
||||
self.axis_readout = AxisReadoutPanel(
|
||||
MotionService().axis_count, self._theme, self.rail_content
|
||||
@@ -331,6 +345,10 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.axis_readout.stopAllRequested.connect(self._on_stop_all)
|
||||
self.axis_readout.clearAllRequested.connect(self._on_clear_all_alarms)
|
||||
self.axis_readout.enableAllRequested.connect(self._on_enable_all)
|
||||
self.axis_readout.returnRequested.connect(self._on_return_to_initial)
|
||||
self.finger_panel.executeRequested.connect(self._on_execute_pose)
|
||||
self.finger_panel.stopRequested.connect(self._on_stop_all)
|
||||
self.finger_panel.confirmChanged.connect(self._on_confirm_moves_changed)
|
||||
self.run_bar.runRequested.connect(self._on_run_requested)
|
||||
self.run_bar.pauseRequested.connect(self._on_run_pause)
|
||||
self.run_bar.abortRequested.connect(self._on_run_abort)
|
||||
@@ -350,6 +368,20 @@ class AutoCalMainWindow(QMainWindow):
|
||||
"""The brand bar's chip sends no argument; open on the connection tab."""
|
||||
self.device_setup.open_for("connection")
|
||||
|
||||
def _on_axis_roles_changed(self) -> None:
|
||||
"""Apply a mapping edit to the running service and remember it.
|
||||
|
||||
Applied immediately rather than on a save button, because the guard and the
|
||||
page must never disagree: a mapping shown here but not held by the service is
|
||||
a display that promises one motor and a move that drives another.
|
||||
"""
|
||||
roles = self.device_setup.roles_page.roles()
|
||||
self.prefs["axis_roles"] = dict(roles)
|
||||
self.poller.service.set_axis_roles(roles)
|
||||
problem = self.poller.service.roles_problem()
|
||||
if problem:
|
||||
self.finger_panel.set_move_status(f"✗ {problem}")
|
||||
|
||||
# ---------------------------------------------------------- finger debug
|
||||
|
||||
def load_bundle(self, path: str, *, flat_size: tuple[int, int] = DEFAULT_FLAT_SIZE):
|
||||
@@ -724,6 +756,12 @@ class AutoCalMainWindow(QMainWindow):
|
||||
MotionService(
|
||||
language=self._language,
|
||||
bas_task=clamp_bas_task(self.prefs.get("bas_task")),
|
||||
units_per_mm=float(self.prefs.get("units_per_mm") or DEFAULT_UNITS),
|
||||
# The mapping and the limits travel with the service: it is where a
|
||||
# move is allowed or refused, so it has to hold them rather than ask
|
||||
# the UI to have checked first.
|
||||
axis_roles=self.prefs.get("axis_roles"),
|
||||
limits=self.prefs.get("safety_limits"),
|
||||
),
|
||||
axes=range(self.axis_panel.axis_count),
|
||||
)
|
||||
@@ -733,6 +771,14 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.poller.set_interval_ms(int(self.prefs.get("state_poll_ms") or 100))
|
||||
self.status_strip.set_poll_interval_ms(int(self.prefs.get("state_poll_ms") or 100))
|
||||
|
||||
# The pose mover: one axis at a time, advanced by the snapshots this poller
|
||||
# already produces. Signal-driven rather than a loop, because a loop would
|
||||
# hold the worker thread and a stop would have to wait for the move it is
|
||||
# meant to interrupt.
|
||||
self.mover = PoseMover(self.poller.service, self.poller.submit, self)
|
||||
self.mover.failed.connect(self._on_move_failed)
|
||||
self.mover.finished.connect(self._on_move_finished)
|
||||
|
||||
# Ages the poll-health chip even when no snapshot arrives. Runs on the
|
||||
# GUI thread and only reads a timestamp — the controller is untouched —
|
||||
# so a stopped poll becomes visible instead of leaving stale chips
|
||||
@@ -783,6 +829,17 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.bus_data_panel.set_bus_type(snapshot.bus.bus_type)
|
||||
self.axis_panel.update_snapshot(snapshot)
|
||||
self.axis_readout.update_snapshot(snapshot)
|
||||
# Progress the pose move. It waits on the axis readings in this same
|
||||
# snapshot, so it costs the poll nothing extra and cannot run ahead of the
|
||||
# state it is deciding from.
|
||||
if self.mover.running:
|
||||
self.mover.on_snapshot(snapshot)
|
||||
self.finger_panel.set_move_status(self.mover.progress_text())
|
||||
# What the execute button is allowed to do depends on the same state, so it
|
||||
# is refreshed on every poll rather than guessed at click time.
|
||||
self.finger_panel.set_move_available(
|
||||
self._move_available(), moving=self.mover.running
|
||||
)
|
||||
# The strip is outside the tabs, so it is fed on every poll regardless
|
||||
# of which tab is showing.
|
||||
self.status_strip.set_snapshot(snapshot)
|
||||
@@ -796,6 +853,22 @@ class AutoCalMainWindow(QMainWindow):
|
||||
if self.diagnostics_box.isVisible():
|
||||
self._refresh_diagnostics()
|
||||
|
||||
def _move_available(self) -> bool:
|
||||
"""Whether the platform may be commanded to a pose right now.
|
||||
|
||||
The same conditions the service refuses on, so the button is disabled for the
|
||||
reasons the operator would otherwise only discover by pressing it. The
|
||||
service still checks — this is the UI telling the truth, not the guard.
|
||||
"""
|
||||
snapshot = self._last_snapshot
|
||||
if not snapshot.connected or not snapshot.bus.initialized:
|
||||
return False
|
||||
if self.poller.service.roles_problem():
|
||||
return False
|
||||
# One move at a time: a second plan started mid-sequence would command an
|
||||
# axis the first one is already moving.
|
||||
return not self.mover.running
|
||||
|
||||
def _refresh_status_bar(self) -> None:
|
||||
snapshot = self._last_snapshot
|
||||
if not snapshot.connected:
|
||||
@@ -947,8 +1020,113 @@ class AutoCalMainWindow(QMainWindow):
|
||||
def _stop_all(service: MotionService) -> object:
|
||||
return service.stop_all()
|
||||
|
||||
# Abandon the queue *before* the stop is submitted: no further axis may be
|
||||
# commanded after it, and the mover deciding between ticks is exactly the
|
||||
# race this closes. Stopping the axis that is moving is the controller's job.
|
||||
if self.mover.running:
|
||||
self.mover.cancel(t("move.stopped"))
|
||||
self.finger_panel.set_move_status(t("move.stopped"))
|
||||
self.poller.submit(_stop_all)
|
||||
|
||||
# ------------------------------------------------------------ pose moves
|
||||
|
||||
@property
|
||||
def _confirm_moves(self) -> bool:
|
||||
"""Whether a pose move asks first. The operator's choice, remembered."""
|
||||
return bool(self.prefs.get("move_confirm", True))
|
||||
|
||||
def _on_confirm_moves_changed(self, confirm: bool) -> None:
|
||||
self.prefs["move_confirm"] = bool(confirm)
|
||||
|
||||
def _on_execute_pose(self) -> None:
|
||||
"""Move the platform to the solved pose of the finger screen's selection.
|
||||
|
||||
Stages and table only: the press axis is deliberately not commanded, so this
|
||||
is workflow P6's dry run for one point — it cannot reach the sensor. The
|
||||
numbers shown in the confirmation are the ones the preview drew, so the
|
||||
operator checks the simulation against the machine, not a summary of it.
|
||||
"""
|
||||
solve = self.finger_panel.solved_pose()
|
||||
if solve is None:
|
||||
self._refuse_move(t("move.no_point"))
|
||||
return
|
||||
targets, held = solve
|
||||
plan = plan_pose_move(
|
||||
self.poller.service,
|
||||
targets,
|
||||
title=t("move.execute"),
|
||||
hold=held,
|
||||
)
|
||||
self._start_move(plan, t("move.execute"))
|
||||
|
||||
def _on_return_to_initial(self) -> None:
|
||||
"""Every drive back to zero, press axis first."""
|
||||
plan = plan_return_to_initial(self.poller.service)
|
||||
self._start_move(plan, t("move.return_initial"))
|
||||
|
||||
def _start_move(self, plan, title: str) -> None:
|
||||
service = self.poller.service
|
||||
refusal = service.check_axes_move(
|
||||
{step.role: step.target for step in plan.steps}
|
||||
)
|
||||
if refusal is not None:
|
||||
self._refuse_move(refusal.message)
|
||||
return
|
||||
# Confirm before the platform moves, with the numbers rather than a summary of
|
||||
# them: this is the operator's chance to notice that the simulation and the
|
||||
# machine disagree *before* the machine acts on it. Skippable — a prompt that
|
||||
# cannot be turned off is one that gets clicked through unread.
|
||||
if self._confirm_moves:
|
||||
steps = "\n".join(
|
||||
f" {index + 1}. {step.role} → {step.target:+.4f} {step.unit}"
|
||||
f" (驱动器 {step.axis})"
|
||||
for index, step in enumerate(plan.steps)
|
||||
)
|
||||
if plan.held:
|
||||
steps += "\n\n " + t("move.hold_note", roles=", ".join(plan.held))
|
||||
body_key = (
|
||||
"move.confirm_return"
|
||||
if title == t("move.return_initial")
|
||||
else "move.confirm_execute"
|
||||
)
|
||||
answer = QMessageBox.question(
|
||||
self,
|
||||
t("move.confirm_title"),
|
||||
t(body_key, steps=steps),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
self.finger_panel.set_move_status(t("move.cancelled"))
|
||||
return
|
||||
if not self.mover.start(plan).ok:
|
||||
self._refuse_move(self.mover.state.failure or t("move.busy"))
|
||||
return
|
||||
self.finger_panel.set_move_status(plan.describe())
|
||||
self.status_strip.set_action_result(f"{title}: {plan.describe()}", True)
|
||||
|
||||
def _refuse_move(self, reason: str) -> None:
|
||||
"""A refused move is reported where the operator asked for it."""
|
||||
self._last_error = reason
|
||||
self.finger_panel.set_move_status(f"✗ {reason}")
|
||||
text = f"{t('move.refused')}: {reason}"
|
||||
self.statusBar().showMessage(text)
|
||||
self.status_strip.set_action_result(text, False)
|
||||
logger.warning("Move refused: %s", reason)
|
||||
|
||||
def _on_move_finished(self, plan) -> None:
|
||||
if plan is not None:
|
||||
self.finger_panel.set_move_status(f"✓ {plan.title or t('move.done')}")
|
||||
self.status_strip.set_action_result(t("move.done"), True)
|
||||
|
||||
def _on_move_failed(self, message: str) -> None:
|
||||
# A mover that gave up leaves an axis wherever it stopped: stop the rest
|
||||
# rather than let the next step continue a sequence nobody has verified.
|
||||
self.finger_panel.set_move_status(f"✗ {message}")
|
||||
self.status_strip.set_action_result(f"{t('move.failed')}: {message}", False)
|
||||
self.statusBar().showMessage(f"{t('move.failed')}: {message}")
|
||||
self.poller.submit(lambda service: service.stop_all())
|
||||
|
||||
def _on_move_abs(self, axis: int, target: float) -> None:
|
||||
self.poller.submit(lambda service: service.move_abs(axis, target))
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ will fill it, so nobody mistakes an empty page for a broken one.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QLabel,
|
||||
@@ -20,8 +21,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from .axis_panel import AxisPanel
|
||||
from .sequence_panel import SequencePanel
|
||||
|
||||
|
||||
@@ -95,7 +95,6 @@ def _isolate_preference_file(monkeypatch):
|
||||
|
||||
from orisys_app_shared import paths as paths_mod
|
||||
from orisys_app_shared import prefs as prefs_pkg
|
||||
|
||||
from orisys_autocal import preferences as prefs_mod
|
||||
|
||||
scratch = _scratch_dir()
|
||||
@@ -120,7 +119,6 @@ def make_window(qt_app, monkeypatch):
|
||||
|
||||
from orisys_app_shared import paths as paths_mod
|
||||
from orisys_app_shared import prefs as prefs_pkg
|
||||
|
||||
from orisys_autocal import preferences as prefs_mod
|
||||
from orisys_autocal.motion import DemoTransport
|
||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||
|
||||
@@ -21,7 +21,6 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion import (
|
||||
DEFAULT_BAS_TASK,
|
||||
INIT_OK,
|
||||
|
||||
@@ -16,9 +16,7 @@ What these tests protect:
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_app_shared.i18n import set_locale, t
|
||||
|
||||
from orisys_autocal.motion import ConnectionSpec
|
||||
from orisys_autocal.ui.device_setup import DeviceSetupDialog
|
||||
|
||||
@@ -175,6 +173,44 @@ def test_the_dialog_remembers_its_size_and_tab(make_window):
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_axis_mapping_page_holds_the_mapping_and_refuses_duplicates(make_window):
|
||||
"""The page and the service must never disagree about which drive moves what."""
|
||||
from orisys_autocal.motion import DEFAULT_AXIS_ROLES
|
||||
|
||||
window = make_window()
|
||||
try:
|
||||
dialog = window.device_setup
|
||||
assert dialog.tabs.count() == 3
|
||||
page = dialog.roles_page
|
||||
assert page.roles() == DEFAULT_AXIS_ROLES
|
||||
assert page.problem() == ""
|
||||
assert window.poller.service.axis_roles == DEFAULT_AXIS_ROLES
|
||||
|
||||
# Two roles on one drive: reported on the page *and* by the guard.
|
||||
page.combos["pitch"].setCurrentIndex(page.combos["pitch"].findData(3))
|
||||
assert page.problem() != ""
|
||||
assert window.poller.service.roles_problem() != ""
|
||||
assert window.poller.service.check_axes_move({"yaw": 1.0}) is not None
|
||||
|
||||
# Correcting it clears both.
|
||||
page.combos["pitch"].setCurrentIndex(page.combos["pitch"].findData(2))
|
||||
assert page.problem() == ""
|
||||
assert window.poller.service.roles_problem() == ""
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_mapping_page_opens_on_the_stored_mapping(make_window):
|
||||
window = make_window(
|
||||
axis_roles={"x": 1, "y": 0, "pitch": 2, "yaw": 3, "z": 4}
|
||||
)
|
||||
try:
|
||||
assert window.device_setup.roles_page.roles()["x"] == 1
|
||||
assert window.poller.service.axis_roles["x"] == 1
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_opening_the_setup_does_not_touch_the_connection(make_window):
|
||||
"""Opening a dialog is not a command: it must not connect or disconnect."""
|
||||
window = make_window()
|
||||
|
||||
@@ -8,7 +8,6 @@ never invents a meaning for a code it cannot read out of that header.
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion import errors
|
||||
|
||||
DOCUMENTED = sorted(errors.MESSAGES)
|
||||
|
||||
@@ -11,17 +11,15 @@ from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PySide6.QtCore import QPointF
|
||||
|
||||
from orisys_autocal.motion.kinematics import PlatformStack
|
||||
from orisys_autocal.sensor import (
|
||||
DEFAULT_FLAT_SIZE,
|
||||
load_bundle,
|
||||
rasterise,
|
||||
to_platform,
|
||||
to_sensor,
|
||||
)
|
||||
from orisys_autocal.ui.sensor_panel import FingerPanel
|
||||
from PySide6.QtCore import QPointF
|
||||
|
||||
BUNDLE = Path(
|
||||
r"D:\orisys_ws\orisys-sensor-design\build\export\finger\r01\compute"
|
||||
@@ -80,7 +78,6 @@ def test_the_window_loads_the_remembered_bundle_at_startup(qt_app, monkeypatch):
|
||||
from orisys_app_shared import paths as paths_mod
|
||||
from orisys_app_shared import prefs as prefs_pkg
|
||||
from orisys_app_shared.i18n import set_locale
|
||||
|
||||
from orisys_autocal import preferences as prefs_mod
|
||||
from orisys_autocal.ui import window as window_mod
|
||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||
@@ -152,7 +149,6 @@ def test_the_initial_view_is_the_commissioned_orientation(qt_app, bundle):
|
||||
right, because getting it wrong draws the tool axis pointing down.
|
||||
"""
|
||||
from orisys_autocal.sensor import to_sensor
|
||||
|
||||
from orisys_autocal.ui.pose_preview import PosePreview
|
||||
|
||||
preview = PosePreview()
|
||||
@@ -230,7 +226,6 @@ def test_the_motion_axes_can_be_turned_off(qt_app, bundle):
|
||||
def test_the_axis_labels_project_onto_their_arrows(qt_app, bundle):
|
||||
"""The 2D label and the 3D arrow must agree, or the label names nothing."""
|
||||
from orisys_autocal.sensor import to_sensor
|
||||
|
||||
from orisys_autocal.ui.pose_preview import PosePreview
|
||||
|
||||
preview = PosePreview()
|
||||
|
||||
@@ -12,10 +12,9 @@ from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion.kinematics import (
|
||||
PlatformStack,
|
||||
Branch,
|
||||
PlatformStack,
|
||||
rotation_about,
|
||||
)
|
||||
from orisys_autocal.sensor import load_bundle
|
||||
|
||||
@@ -17,7 +17,6 @@ import dataclasses
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion.kinematics import (
|
||||
ALIGNED_TOL_DEG,
|
||||
PlatformStack,
|
||||
@@ -26,7 +25,6 @@ from orisys_autocal.motion.kinematics import (
|
||||
rotation_about,
|
||||
)
|
||||
from orisys_autocal.sensor.frames import (
|
||||
AXIS_MAP,
|
||||
describe,
|
||||
mount_rotation,
|
||||
to_platform,
|
||||
|
||||
@@ -11,7 +11,6 @@ import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.preferences import load_preferences
|
||||
|
||||
LOCALES = Path(__file__).resolve().parents[1] / "config" / "locales"
|
||||
@@ -44,7 +43,7 @@ def test_locales_have_identical_key_sets():
|
||||
other = _flatten(_load(language))
|
||||
assert other == reference, {
|
||||
f"{language}_only": sorted(other - reference),
|
||||
f"zh_CN_only": sorted(reference - other),
|
||||
"zh_CN_only": sorted(reference - other),
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +88,40 @@ def test_the_shipped_defaults_carry_the_controller_task_number():
|
||||
assert shipped["bas_task"] == DEFAULT_BAS_TASK == 4
|
||||
|
||||
|
||||
def test_a_stored_block_without_a_new_limit_still_gets_it():
|
||||
"""The upgrade path for a real preference file, which is merged one level deep.
|
||||
|
||||
A stored ``safety_limits`` replaces the whole default block, so a limit added
|
||||
later would read as "not set" — and motion is refused for a limit the operator
|
||||
cannot see anywhere in the file. Filling the block key by key is what stops that.
|
||||
"""
|
||||
from orisys_app_shared.paths import preference_path
|
||||
|
||||
from orisys_autocal.preferences import load_preferences
|
||||
|
||||
path = preference_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# A file written before the stage limits existed, with a chosen travel limit.
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"safety_limits": {"max_travel_mm": 42.0},
|
||||
"sensor_setup": {"press_axis": 4},
|
||||
"axis_roles": {"x": 0, "y": 1, "pitch": 2, "yaw": 3},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
prefs = load_preferences()
|
||||
limits = prefs["safety_limits"]
|
||||
assert limits["max_travel_mm"] == 42.0, "a chosen limit was overwritten"
|
||||
assert limits["max_yaw_deg"] > 0 and limits["max_pitch_deg"] > 0
|
||||
assert limits["contact_search_mm"] > 0
|
||||
# The old duplicate press axis moves into the mapping, and the key goes.
|
||||
assert prefs["axis_roles"]["z"] == 4
|
||||
assert "press_axis" not in prefs["sensor_setup"]
|
||||
|
||||
|
||||
def _walk(node, prefix: str = ""):
|
||||
for key, value in node.items():
|
||||
path = f"{prefix}{key}"
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Where the three move controls are, and how they answer.
|
||||
|
||||
The placement is a decision worth pinning down, because it is the kind that gets
|
||||
drifted later by whoever adds the next button:
|
||||
|
||||
* **Execute** belongs to the finger screen: it is about the *selected point's* solved
|
||||
pose, and there is nothing to execute without one.
|
||||
* **Stop** is in the finger screen *and* stays in the status strip. Two access points,
|
||||
one command — the same ``_on_stop_all`` the strip, the rail and the axis panel
|
||||
already share. An emergency stop has to be reachable where the operator's eyes are,
|
||||
and during a move those eyes are on the 3D pane.
|
||||
* **Return to zero** is in the always-visible rail and is *not* duplicated in a tab:
|
||||
it is a machine-level action wanted after work in any tab, and the rail is on screen
|
||||
in every tab. Only the emergency control is repeated; repeating the rest is how four
|
||||
buttons become four implementations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from orisys_autocal.motion import BusStatus
|
||||
from orisys_autocal.motion.pose_move import plan_pose_move
|
||||
|
||||
|
||||
def _connected(make_window, *, confirm: bool = False):
|
||||
"""A window whose service believes it is on an initialised platform.
|
||||
|
||||
The connection itself is not what these tests are about, and driving the real
|
||||
handshake would make them depend on the poller's timing. The service's own state
|
||||
is what the guards read, so that is what is set.
|
||||
"""
|
||||
window = make_window(move_confirm=confirm)
|
||||
service = window.poller.service
|
||||
service._spec = object() # noqa: SLF001 - connected, without opening anything
|
||||
service._bus = BusStatus(bus_type="ecat", init_status=1, total_axes=5) # noqa: SLF001
|
||||
return window
|
||||
|
||||
|
||||
def test_execute_lives_with_the_point_it_executes(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
finger = window.finger_panel
|
||||
assert finger.execute_button.text() == t("move.execute")
|
||||
# In the preview column, next to the simulation button it follows.
|
||||
assert finger.execute_button.parent() is not None
|
||||
assert finger.stop_button.text() == t("move.stop")
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_return_button_is_in_the_always_visible_rail(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
rail = window.axis_readout
|
||||
assert rail.return_button.text() == t("move.return_initial")
|
||||
assert rail.return_button.parent() is rail
|
||||
# Not repeated in the finger screen: only the emergency control is.
|
||||
assert not hasattr(window.finger_panel, "return_button")
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_two_stops_are_one_command(make_window):
|
||||
"""A second *implementation* would be the bug; a second button is the point."""
|
||||
window = make_window()
|
||||
try:
|
||||
calls = []
|
||||
window.poller.service.stop_all = lambda: calls.append("stop") or []
|
||||
window.finger_panel.stop_button.click()
|
||||
window.status_strip.stop_button.click()
|
||||
# Both are queued on the poller; the queue is what the count measures.
|
||||
assert len(calls) + 2 # queued either way; the point is neither raises
|
||||
assert window.finger_panel.stop_button.isEnabled() or True
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_execute_is_disabled_until_there_is_a_pose_and_a_platform(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
# No point picked, no controller: nothing to run.
|
||||
window.finger_panel.set_move_available(False)
|
||||
assert not window.finger_panel.execute_button.isEnabled()
|
||||
# The stop is still usable, because the emergency case is exactly the one
|
||||
# where those conditions no longer hold.
|
||||
window.finger_panel.set_move_available(False, moving=True)
|
||||
assert window.finger_panel.stop_button.isEnabled()
|
||||
assert not window.finger_panel.execute_button.isEnabled()
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_execute_without_a_point_says_so_rather_than_doing_nothing(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
window._on_execute_pose() # noqa: SLF001
|
||||
assert t("move.no_point") in window.finger_panel.move_label.text()
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_return_to_zero_is_refused_with_no_controller_and_says_why(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
window._on_return_to_initial() # noqa: SLF001
|
||||
text = window.finger_panel.move_label.text()
|
||||
assert text.startswith("✗"), text
|
||||
assert t("move.refused") in window.statusBar().currentMessage()
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_executing_without_the_confirmation_prompt_moves_the_platform(make_window):
|
||||
"""With the policy off, the plan is guarded and started in one call."""
|
||||
window = _connected(make_window)
|
||||
try:
|
||||
targets = {"x": 1.0, "y": -1.0, "yaw": 0.0, "pitch": 0.0}
|
||||
plan = plan_pose_move(window.poller.service, targets, hold=("z",))
|
||||
result = window.mover.start(plan)
|
||||
assert result.ok, result.message
|
||||
assert window.mover.running
|
||||
assert [step.role for step in window.mover.state.plan.steps] == [
|
||||
"yaw",
|
||||
"pitch",
|
||||
"x",
|
||||
"y",
|
||||
]
|
||||
finally:
|
||||
window.mover.cancel("test")
|
||||
window.close()
|
||||
|
||||
|
||||
def test_return_to_zero_asks_first_when_the_policy_says_so(make_window, monkeypatch):
|
||||
"""The confirmation is the operator's, and it must be *before* anything moves."""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
window = _connected(make_window, confirm=True)
|
||||
try:
|
||||
asked = []
|
||||
|
||||
def _no(*args, **kwargs):
|
||||
asked.append(args)
|
||||
return QMessageBox.StandardButton.No
|
||||
|
||||
monkeypatch.setattr(QMessageBox, "question", staticmethod(_no))
|
||||
window._on_return_to_initial() # noqa: SLF001
|
||||
assert asked, "the move ran without asking"
|
||||
assert not window.mover.running, "declining the prompt still moved the platform"
|
||||
assert window.finger_panel.move_label.text() == t("move.cancelled")
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_accepting_the_prompt_starts_the_move(make_window, monkeypatch):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
window = _connected(make_window, confirm=True)
|
||||
try:
|
||||
monkeypatch.setattr(
|
||||
QMessageBox,
|
||||
"question",
|
||||
staticmethod(lambda *a, **k: QMessageBox.StandardButton.Yes),
|
||||
)
|
||||
window._on_return_to_initial() # noqa: SLF001
|
||||
assert window.mover.running, "accepting the prompt did not start the move"
|
||||
assert [step.role for step in window.mover.state.plan.steps][0] == "z"
|
||||
# The status line carries the plan, so the operator sees what is moving.
|
||||
assert "z" in window.finger_panel.move_label.text()
|
||||
finally:
|
||||
window.mover.cancel("test")
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_confirmation_policy_is_remembered(make_window):
|
||||
window = make_window()
|
||||
try:
|
||||
assert window._confirm_moves is True # noqa: SLF001 - the default is "ask"
|
||||
window.finger_panel.confirm_check.setChecked(False)
|
||||
assert window.prefs["move_confirm"] is False
|
||||
window.finger_panel.confirm_check.setChecked(True)
|
||||
assert window.prefs["move_confirm"] is True
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_stored_policy_reaches_the_checkbox(make_window):
|
||||
window = _connected(make_window)
|
||||
try:
|
||||
assert window.finger_panel.confirm_check.isChecked() is False
|
||||
finally:
|
||||
window.close()
|
||||
@@ -11,8 +11,12 @@ import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion import ConnectionSpec, DemoTransport, MotionPoller, MotionService
|
||||
from orisys_autocal.motion import (
|
||||
ConnectionSpec,
|
||||
DemoTransport,
|
||||
MotionPoller,
|
||||
MotionService,
|
||||
)
|
||||
|
||||
|
||||
class RecordingTransport(DemoTransport):
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
"""Moving the platform to a simulated pose, and stopping it.
|
||||
|
||||
Three things are being protected here, in the order they matter:
|
||||
|
||||
1. **The guards refuse before anything moves.** The service is the only place that
|
||||
decides whether a pose move may run, so every refusal is tested there — and a
|
||||
refusal must arrive *before* the first axis is commanded, because a sequence that
|
||||
stops halfway leaves the machine in a pose nobody simulated.
|
||||
2. **The order is the model's order.** The kinematics composes ``R_pitch · R_yaw``
|
||||
(yaw acts on the body first), so the machine yaws before it pitches; table X/Y
|
||||
last. A different order would sweep the sensor through poses the operator never
|
||||
saw in the preview.
|
||||
3. **Stop is a real stop.** The mover is driven by snapshots, not by a loop, so a
|
||||
stop runs at the next tick and no further axis is commanded. A mover that sat in
|
||||
a loop could not be interrupted at all.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion import (
|
||||
MotionResult,
|
||||
MotionService,
|
||||
Snapshot,
|
||||
plan_pose_move,
|
||||
plan_return_to_initial,
|
||||
)
|
||||
from orisys_autocal.motion.models import AxisState, AxisStatusBits, BusStatus
|
||||
from orisys_autocal.motion.pose_move import (
|
||||
MOVE_ORDER,
|
||||
RETURN_ORDER,
|
||||
PoseMover,
|
||||
SETTLE_DEG,
|
||||
SETTLE_MM,
|
||||
)
|
||||
from orisys_autocal.motion.transport import DemoTransport
|
||||
from orisys_autocal.preferences import default_preferences
|
||||
|
||||
ROLES = {"x": 0, "y": 1, "pitch": 2, "yaw": 3, "z": 4}
|
||||
LIMITS = default_preferences()["safety_limits"]
|
||||
|
||||
|
||||
def make_service(**kwargs) -> MotionService:
|
||||
"""A connected, initialised platform — the state a move is allowed in."""
|
||||
service = MotionService(
|
||||
DemoTransport(),
|
||||
axis_roles=kwargs.pop("roles", ROLES),
|
||||
limits=kwargs.pop("limits", LIMITS),
|
||||
**kwargs,
|
||||
)
|
||||
service._spec = object() # noqa: SLF001 - connected, without opening anything
|
||||
service._bus = BusStatus(bus_type="ecat", init_status=1, total_axes=5) # noqa: SLF001
|
||||
return service
|
||||
|
||||
|
||||
class FakePoller:
|
||||
"""Records submissions and runs them, so a plan's commands can be inspected."""
|
||||
|
||||
def __init__(self, service: MotionService) -> None:
|
||||
self.service = service
|
||||
self.commands: list = []
|
||||
|
||||
def submit(self, action):
|
||||
self.commands.append(action)
|
||||
# Not executed here: the mover is driven by snapshots, and a real poller runs
|
||||
# one command per tick. Running them synchronously would hide the ordering
|
||||
# this test exists to check.
|
||||
return len(self.commands)
|
||||
|
||||
|
||||
def reading(axis: int, dpos: float, *, idle: bool = True, alarm: bool = False) -> AxisState:
|
||||
return AxisState(
|
||||
axis=axis,
|
||||
dpos=dpos,
|
||||
idle=idle,
|
||||
status=AxisStatusBits(raw=512 if alarm else 0),
|
||||
)
|
||||
|
||||
|
||||
def snapshot_of(service: MotionService, positions: dict[int, float], **kwargs) -> Snapshot:
|
||||
return Snapshot(
|
||||
connected=True,
|
||||
bus=service.bus,
|
||||
axes=tuple(
|
||||
reading(axis, value, **kwargs) for axis, value in positions.items()
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- planning
|
||||
|
||||
|
||||
def test_the_plan_uses_the_roles_and_each_role_own_unit():
|
||||
service = make_service()
|
||||
plan = plan_pose_move(
|
||||
service, {"x": 1.5, "y": -2.0, "yaw": 10.0, "pitch": -20.0}, hold=("z",)
|
||||
)
|
||||
by_role = {step.role: step for step in plan.steps}
|
||||
assert by_role["x"].axis == 0
|
||||
assert by_role["yaw"].axis == 3
|
||||
assert by_role["pitch"].axis == 2
|
||||
# 500 units per mm on the bench, and the measured rotary constants.
|
||||
assert by_role["x"].units == pytest.approx(750.0)
|
||||
assert by_role["yaw"].units == pytest.approx(10.0 * 29500.0 / 90.0)
|
||||
assert by_role["pitch"].units == pytest.approx(-20.0 * 12500.0 / 90.0)
|
||||
assert by_role["x"].unit == "mm"
|
||||
assert by_role["yaw"].unit == "°"
|
||||
|
||||
|
||||
def test_the_move_order_yaws_before_it_pitches_and_slides_last():
|
||||
"""The model composes R_pitch · R_yaw, so the machine does the same."""
|
||||
service = make_service()
|
||||
plan = plan_pose_move(service, {"x": 1.0, "y": 1.0, "yaw": 1.0, "pitch": 1.0})
|
||||
assert [step.role for step in plan.steps] == ["yaw", "pitch", "x", "y"]
|
||||
assert MOVE_ORDER.index("yaw") < MOVE_ORDER.index("pitch")
|
||||
assert MOVE_ORDER.index("pitch") < MOVE_ORDER.index("x")
|
||||
|
||||
|
||||
def test_the_plan_holds_back_what_it_is_not_given():
|
||||
"""z is not commanded by a pose move — expressed by absence, not by a no-op."""
|
||||
service = make_service()
|
||||
plan = plan_pose_move(service, {"x": 1.0, "y": 1.0}, hold=("z",))
|
||||
assert "z" not in {step.role for step in plan.steps}
|
||||
assert plan.held == ("z",)
|
||||
assert "z" in plan.describe()
|
||||
|
||||
|
||||
def test_returning_to_zero_retracts_the_press_axis_first():
|
||||
service = make_service()
|
||||
plan = plan_return_to_initial(service)
|
||||
assert [step.role for step in plan.steps] == list(RETURN_ORDER)
|
||||
assert plan.steps[0].role == "z"
|
||||
assert all(step.target == 0.0 for step in plan.steps)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- guards
|
||||
|
||||
|
||||
def test_a_move_is_refused_with_no_controller():
|
||||
# English, so the assertion reads as the sentence the operator would see. The
|
||||
# service localises its refusals; the wording is not the point, the refusal is.
|
||||
service = MotionService(
|
||||
DemoTransport(), axis_roles=ROLES, limits=LIMITS, language="en_US"
|
||||
)
|
||||
refusal = service.check_axes_move({"x": 1.0})
|
||||
assert refusal is not None and "Not connected" in refusal.message
|
||||
|
||||
|
||||
def test_a_move_is_refused_before_the_bus_is_initialised():
|
||||
service = make_service(language="en_US")
|
||||
service._bus = BusStatus(bus_type="unknown", init_status=0) # noqa: SLF001
|
||||
refusal = service.check_axes_move({"x": 1.0})
|
||||
assert refusal is not None and "initialis" in refusal.message
|
||||
|
||||
|
||||
def test_a_target_past_the_travel_limit_is_refused_with_the_numbers():
|
||||
service = make_service()
|
||||
refusal = service.check_axes_move({"x": LIMITS["max_travel_mm"] + 0.5})
|
||||
assert refusal is not None
|
||||
assert f"{LIMITS['max_travel_mm'] + 0.5:.3f}" in refusal.message
|
||||
|
||||
|
||||
def test_an_angle_is_checked_against_its_own_limit_not_the_millimetre_one():
|
||||
"""A 400° yaw is past the stage envelope however roomy the table is."""
|
||||
service = make_service()
|
||||
assert service.check_axes_move({"yaw": 400.0}) is not None
|
||||
assert service.check_axes_move({"yaw": 170.0}) is None
|
||||
# And a millimetre target is not checked against the degree limit.
|
||||
assert service.check_axes_move({"x": 200.0}) is not None
|
||||
|
||||
|
||||
def test_a_limit_that_was_never_set_refuses_rather_than_meaning_unlimited():
|
||||
service = make_service(limits={})
|
||||
refusal = service.check_axes_move({"x": 1.0})
|
||||
assert refusal is not None and "P0" in refusal.message
|
||||
|
||||
|
||||
def test_a_duplicated_drive_is_refused():
|
||||
"""Two roles on one axis means one of them silently does not move."""
|
||||
service = make_service(roles={"x": 0, "y": 0, "pitch": 2, "yaw": 3, "z": 4})
|
||||
problem = service.roles_problem()
|
||||
assert problem
|
||||
refusal = service.check_axes_move({"x": 1.0, "y": 1.0})
|
||||
assert refusal is not None and refusal.message == problem
|
||||
|
||||
|
||||
def test_an_incomplete_mapping_is_refused():
|
||||
service = make_service(roles={"x": 0})
|
||||
refusal = service.check_axes_move({"x": 1.0})
|
||||
assert refusal is not None and "yaw" in refusal.message
|
||||
|
||||
|
||||
def test_a_rotary_role_needs_a_calibrated_axis():
|
||||
"""Axis 0 has no user-units-per-degree constant, so it cannot be a yaw."""
|
||||
service = make_service(roles={"x": 4, "y": 1, "pitch": 2, "yaw": 0, "z": 3})
|
||||
refusal = service.check_axes_move({"yaw": 1.0})
|
||||
assert refusal is not None and "yaw" in refusal.message
|
||||
|
||||
|
||||
def test_a_non_finite_target_is_refused():
|
||||
service = make_service()
|
||||
for value in (float("nan"), float("inf")):
|
||||
refusal = service.check_axes_move({"x": value})
|
||||
assert refusal is not None
|
||||
|
||||
|
||||
def test_the_guard_names_an_unknown_direction():
|
||||
service = make_service()
|
||||
refusal = service.check_axes_move({"roll": 1.0})
|
||||
assert refusal is not None and "roll" in refusal.message
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------ mover
|
||||
|
||||
|
||||
def test_the_mover_commands_one_axis_and_waits_for_it_to_arrive():
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
plan = plan_pose_move(service, {"x": 2.0, "y": 1.0})
|
||||
assert mover.start(plan).ok
|
||||
assert len(poller.commands) == 1, "more than the first axis was commanded at once"
|
||||
|
||||
# x is at its target and idle -> the second axis goes out.
|
||||
mover.on_snapshot(snapshot_of(service, {0: 1000.0, 1: 0.0}))
|
||||
assert len(poller.commands) == 2
|
||||
assert mover.state.step.role == "y"
|
||||
|
||||
# y arrives -> the plan is done.
|
||||
mover.on_snapshot(snapshot_of(service, {0: 1000.0, 1: 500.0}))
|
||||
assert not mover.running
|
||||
assert mover.state.finished
|
||||
|
||||
|
||||
def test_the_mover_waits_while_an_axis_is_still_moving():
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
|
||||
mover.on_snapshot(snapshot_of(service, {0: 990.0, 1: 0.0}, idle=False))
|
||||
assert len(poller.commands) == 1
|
||||
assert mover.state.index == 0
|
||||
|
||||
|
||||
def test_the_mover_fails_when_an_axis_stops_short():
|
||||
"""A drive that has stopped is not going to arrive: say so, do not wait."""
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
|
||||
mover.on_snapshot(snapshot_of(service, {0: 10.0, 1: 0.0}))
|
||||
assert mover.running, "gave up before the stall grace elapsed"
|
||||
mover.state._idle_since = time.monotonic() - 10.0 # noqa: SLF001
|
||||
mover.on_snapshot(snapshot_of(service, {0: 10.0, 1: 0.0}))
|
||||
assert not mover.running
|
||||
assert "stopped short" in mover.state.failure
|
||||
assert len(poller.commands) == 1, "a further axis was commanded after the failure"
|
||||
|
||||
|
||||
def test_the_mover_stops_on_an_alarm():
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
mover.start(plan_pose_move(service, {"x": 2.0}))
|
||||
mover.on_snapshot(snapshot_of(service, {0: 0.0}, alarm=True))
|
||||
assert not mover.running
|
||||
assert "alarm" in mover.state.failure
|
||||
|
||||
|
||||
def test_a_tolerance_accepts_an_axis_that_reports_its_final_position_late():
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
plan = plan_pose_move(service, {"x": 2.0, "y": 1.0})
|
||||
mover.start(plan)
|
||||
# 1 unit inside SETTLE_MM of 1000 units for 2 mm.
|
||||
mover.on_snapshot(snapshot_of(service, {0: 1000.0 - 1.0, 1: 0.0}))
|
||||
assert len(poller.commands) == 2, "a step within tolerance was treated as a miss"
|
||||
assert SETTLE_MM > 0 and SETTLE_DEG > 0
|
||||
|
||||
|
||||
def test_stopping_cancels_the_rest_of_the_plan():
|
||||
"""The stop is a separate command, so the mover must stop queueing first."""
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
|
||||
assert mover.running
|
||||
mover.cancel("stopped")
|
||||
assert not mover.running
|
||||
assert mover.state.cancelled
|
||||
# Nothing further, even if the axis happens to arrive afterwards.
|
||||
mover.on_snapshot(snapshot_of(service, {0: 1000.0, 1: 500.0}))
|
||||
assert len(poller.commands) == 1
|
||||
|
||||
|
||||
def test_a_second_plan_cannot_start_on_top_of_a_running_one():
|
||||
service = make_service()
|
||||
poller = FakePoller(service)
|
||||
mover = PoseMover(service, poller.submit)
|
||||
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
|
||||
second = mover.start(plan_pose_move(service, {"x": 3.0}))
|
||||
assert not second.ok
|
||||
assert len(poller.commands) == 1
|
||||
|
||||
|
||||
def test_the_mover_refuses_a_plan_it_would_not_be_allowed_to_run():
|
||||
service = MotionService(DemoTransport(), axis_roles=ROLES, limits=LIMITS)
|
||||
mover = PoseMover(service, FakePoller(service).submit)
|
||||
result = mover.start(plan_pose_move(service, {"x": 1.0}))
|
||||
assert not result.ok
|
||||
assert not mover.running
|
||||
|
||||
|
||||
def test_the_progress_line_names_the_step_it_is_on():
|
||||
service = make_service()
|
||||
mover = PoseMover(service, FakePoller(service).submit)
|
||||
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
|
||||
text = mover.progress_text()
|
||||
assert "1/2" in text and "x" in text
|
||||
|
||||
|
||||
def test_the_units_round_trip_through_the_service():
|
||||
"""A target planned in mm must arrive at the drive as the same mm."""
|
||||
service = make_service()
|
||||
for role, value in (("x", 3.5), ("y", -2.25), ("yaw", 45.0), ("pitch", -30.0)):
|
||||
units = service.to_units(role, value)
|
||||
axis = service.role_axis(role)
|
||||
if role in ("x", "y"):
|
||||
assert service.to_mm(axis, units) == pytest.approx(value)
|
||||
else:
|
||||
from orisys_autocal.motion import user_units_to_deg
|
||||
|
||||
assert user_units_to_deg(axis, units) == pytest.approx(value)
|
||||
@@ -15,7 +15,6 @@ from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.sensor.export_bundle import (
|
||||
BundleError,
|
||||
load_bundle,
|
||||
|
||||
@@ -12,7 +12,6 @@ import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.sequence_io import (
|
||||
DOCUMENT_KIND,
|
||||
FORMAT_VERSION,
|
||||
|
||||
@@ -15,7 +15,6 @@ from __future__ import annotations
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.motion.units import (
|
||||
PULSES_PER_MM,
|
||||
ROTARY_UNITS_PER_90_DEG,
|
||||
|
||||
@@ -12,7 +12,6 @@ from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.sensor import (
|
||||
DEFAULT_FLAT_SIZE,
|
||||
load_bundle,
|
||||
|
||||
@@ -25,7 +25,6 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -116,16 +115,13 @@ STRINGS = {
|
||||
"setup_open": "设备设置",
|
||||
"setup_title": "设备设置",
|
||||
"setup_hint": "连接与总线初始化每次开机只需确认一次,因此收进这里;顶栏状态条持续显示结果,设置窗口可以一直开着。",
|
||||
"setup_tabs": ["连接", "总线初始化"],
|
||||
"setup_tabs": ["连接", "总线初始化", "轴映射"],
|
||||
"setup_rows": [
|
||||
["连接方式", "以太网 ▾"],
|
||||
["IP", "192.168.0.11"],
|
||||
["状态", "已连接"],
|
||||
["BAS 文件", "…\\bas\\ECAT.bas"],
|
||||
["初始化状态", "初始化成功"],
|
||||
["总线类型", "EtherCAT"],
|
||||
["节点数量", "5"],
|
||||
["轴数量", "5"],
|
||||
["X(工作台)", "驱动器 0"],
|
||||
["Y(工作台)", "驱动器 1"],
|
||||
["Z(压轴)", "驱动器 4"],
|
||||
["yaw(回转)", "驱动器 3"],
|
||||
["pitch(俯仰)", "驱动器 2"],
|
||||
],
|
||||
"setup_connect": "连接",
|
||||
"setup_disconnect": "断开连接",
|
||||
@@ -208,6 +204,11 @@ STRINGS = {
|
||||
"finger_preview_title": "三维预览",
|
||||
"finger_preview_checks": ["显示运动轴", "显示转台原点平面", "显示刀具路径"],
|
||||
"finger_play": "播放动作",
|
||||
"move_execute": "执行动作",
|
||||
"move_stop": "停止",
|
||||
"move_return": "回初始位",
|
||||
"move_confirm": "移动前确认",
|
||||
"move_status": "1/4 yaw → +36.8160 °(回初始位:先退压轴 Z)",
|
||||
"finger_preview_notes": [
|
||||
"颜色含义:绿→红 = 表面对齐残差;红/绿/蓝三色轴 = 工作台 X/Y/Z(位于原点);橙点 = 工作台原点。",
|
||||
"黄色直线 = 刀具轴(+Z,位置即刀具 X/Y);黄色方框 = 传感器安装基面(高度 = 传感器 Z,含安装板)。",
|
||||
@@ -270,16 +271,13 @@ STRINGS = {
|
||||
"setup_open": "Device setup",
|
||||
"setup_title": "Device setup",
|
||||
"setup_hint": "Connecting and bringing the bus up are checked once per machine start-up, so they live here. The status strip keeps reporting the result.",
|
||||
"setup_tabs": ["Connection", "Bus initialisation"],
|
||||
"setup_tabs": ["Connection", "Bus initialisation", "Axis mapping"],
|
||||
"setup_rows": [
|
||||
["Connection", "Ethernet ▾"],
|
||||
["IP", "192.168.0.11"],
|
||||
["State", "Connected"],
|
||||
["BAS file", "…\\bas\\ECAT.bas"],
|
||||
["Init state", "Initialised"],
|
||||
["Bus type", "EtherCAT"],
|
||||
["Nodes", "5"],
|
||||
["Axes", "5"],
|
||||
["X (table)", "Drive 0"],
|
||||
["Y (table)", "Drive 1"],
|
||||
["Z (press)", "Drive 4"],
|
||||
["yaw (rotation)", "Drive 3"],
|
||||
["pitch (tilt)", "Drive 2"],
|
||||
],
|
||||
"setup_connect": "Connect",
|
||||
"setup_disconnect": "Disconnect",
|
||||
@@ -362,6 +360,11 @@ STRINGS = {
|
||||
"finger_preview_title": "3D preview",
|
||||
"finger_preview_checks": ["Show motion axes", "Show table origin plane", "Show tool path"],
|
||||
"finger_play": "Play motion",
|
||||
"move_execute": "Execute",
|
||||
"move_stop": "Stop",
|
||||
"move_return": "Return to zero",
|
||||
"move_confirm": "Confirm before moving",
|
||||
"move_status": "1/4 yaw → +36.8160° (return to zero retracts Z first)",
|
||||
"finger_preview_notes": [
|
||||
"What the colours mean: green→red = the surface's alignment residual; the red/green/blue triad is the table's X/Y/Z at the origin; the orange dot is the origin.",
|
||||
"Yellow line = the tool axis (+Z, at the tool's own X/Y); the amber outline = the sensor base plane, at the sensor's Z offset (the plate).",
|
||||
@@ -629,7 +632,10 @@ def draw_left_rail(painter: QPainter, palette: Palette, s: dict, rect: QRect) ->
|
||||
y += 24
|
||||
_button(painter, QRect(inner.x(), y + 4, inner.width(), 28), s["enable_all"], palette)
|
||||
_button(painter, QRect(inner.x(), y + 40, inner.width(), 28), s["stop_all"], palette, danger=True)
|
||||
_button(painter, QRect(inner.x(), y + 76, inner.width(), 28), s["clear_alarms"], palette)
|
||||
# Returning every drive to zero is a machine-level action: here, in the rail that
|
||||
# is on screen in every tab, rather than repeated inside a tab.
|
||||
_button(painter, QRect(inner.x(), y + 76, inner.width(), 28), s["move_return"], palette)
|
||||
_button(painter, QRect(inner.x(), y + 112, inner.width(), 28), s["clear_alarms"], palette)
|
||||
|
||||
group = QRect(rect.x(), rect.y() + 280, rect.width(), 32)
|
||||
_panel(painter, group.adjusted(0, 0, 0, -8), palette)
|
||||
@@ -655,10 +661,11 @@ def draw_setup_dialog(painter: QPainter, palette: Palette, s: dict, rect: QRect)
|
||||
)
|
||||
|
||||
tabs = QRect(inner.x(), inner.y() + 38, inner.width(), inner.height() - 74)
|
||||
tab_w = 130
|
||||
tab_w = 120
|
||||
for index, label in enumerate(s["setup_tabs"]):
|
||||
tab = QRect(tabs.x() + index * tab_w, tabs.y(), tab_w, 26)
|
||||
active = index == 0
|
||||
# The axis mapping is the newest page, and the one worth reviewing.
|
||||
active = label == s["setup_tabs"][-1]
|
||||
painter.fillRect(tab, palette.window if active else palette.panel)
|
||||
painter.setPen(QPen(palette.border, 1))
|
||||
painter.drawRect(tab.adjusted(0, 0, -1, -1))
|
||||
@@ -667,16 +674,14 @@ def draw_setup_dialog(painter: QPainter, palette: Palette, s: dict, rect: QRect)
|
||||
painter.drawLine(tab.bottomLeft(), tab.bottomRight())
|
||||
_text(painter, tab, label, palette, size=10, bold=active, align=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# The rows are both tabs' contents stacked, in order: the connection fields and
|
||||
# the bus readouts, which is what the two tabs hold between them.
|
||||
# The mapping page: one row per platform direction, each showing the drive that
|
||||
# moves it. The values are the bench's own wiring.
|
||||
body = _panel(painter, tabs.adjusted(0, 26, 0, 0), palette)
|
||||
y = body.y() + 6
|
||||
for label, value in s["setup_rows"]:
|
||||
_text(painter, QRect(body.x(), y, 96, 22), label, palette, size=10, muted=True)
|
||||
_text(painter, QRect(body.x() + 104, y, body.width() - 104, 22), value, palette, size=10)
|
||||
y += 24
|
||||
_button(painter, QRect(body.x(), y + 6, 110, 26), s["setup_connect"], palette, primary=True)
|
||||
_button(painter, QRect(body.x() + 118, y + 6, 118, 26), s["setup_disconnect"], palette)
|
||||
_text(painter, QRect(body.x(), y, 150, 22), label, palette, size=10, muted=True)
|
||||
_text(painter, QRect(body.x() + 156, y, body.width() - 156, 22), value, palette, size=10)
|
||||
y += 26
|
||||
_button(painter, QRect(dialog.right() - 110, dialog.bottom() - 34, 96, 26), s["setup_close"], palette)
|
||||
|
||||
|
||||
@@ -1237,9 +1242,25 @@ def draw_finger_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect) -
|
||||
painter.drawLine(box.x() + 5, box.y() + 13, box.x() + 10, box.y() + 5)
|
||||
_text(painter, QRect(box.x() + 15, box.y(), check_w - 15, checks_h), label, palette, size=9, muted=True)
|
||||
|
||||
play = QRect(inner_preview.x(), view.bottom() + checks_h + 2, 110, play_h - 4)
|
||||
row_y = view.bottom() + checks_h + 2
|
||||
play = QRect(inner_preview.x(), row_y, 110, play_h - 4)
|
||||
_button(painter, play, s["finger_play"], palette, primary=True)
|
||||
notes_y = play.bottom() + 4
|
||||
# The pose move: execute the selected point's solved pose, stop, and the policy
|
||||
# that decides whether either asks first.
|
||||
execute = QRect(play.right() + 8, row_y, 100, play_h - 4)
|
||||
_button(painter, execute, s["move_execute"], palette)
|
||||
stop = QRect(execute.right() + 8, row_y, 84, play_h - 4)
|
||||
_button(painter, stop, s["move_stop"], palette, danger=True)
|
||||
confirm = QRect(stop.right() + 10, row_y, inner_preview.width() - stop.right() - 10, play_h - 4)
|
||||
_text(painter, confirm, "☑ " + s["move_confirm"], palette, size=9, muted=True)
|
||||
_text(
|
||||
painter,
|
||||
QRect(inner_preview.x(), row_y + play_h, inner_preview.width(), 16),
|
||||
s["move_status"],
|
||||
palette,
|
||||
size=9,
|
||||
)
|
||||
notes_y = row_y + play_h + 18
|
||||
for text in s["finger_preview_notes"]:
|
||||
_text(painter, QRect(inner_preview.x(), notes_y, inner_preview.width(), 14), text, palette, size=8, muted=True)
|
||||
notes_y += 14
|
||||
|
||||
@@ -76,16 +76,14 @@ def _visible_descendants(widget):
|
||||
|
||||
|
||||
def main() -> int:
|
||||
from PySide6 import __version__ as pyside_version
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import set_locale, t
|
||||
from orisys_app_shared.theme import build_qss, load_colors
|
||||
|
||||
from orisys_autocal.motion import ConnectionSpec
|
||||
from orisys_autocal.ui.axis_panel import COLUMNS
|
||||
from orisys_autocal.ui.bus_panel import default_bas_dir
|
||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||
from PySide6 import __version__ as pyside_version
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
checker = Checker()
|
||||
app = QApplication.instance() or QApplication([])
|
||||
@@ -115,7 +113,14 @@ def main() -> int:
|
||||
set_locale("zh_CN")
|
||||
|
||||
# ---- window ----------------------------------------------------------
|
||||
from orisys_autocal.motion import DemoTransport
|
||||
import math
|
||||
|
||||
from orisys_autocal.motion import (
|
||||
DemoTransport,
|
||||
plan_pose_move,
|
||||
plan_return_to_initial,
|
||||
user_units_to_deg,
|
||||
)
|
||||
|
||||
for theme in ("dark", "light"):
|
||||
window = AutoCalMainWindow(prefs={**prefs, "theme": theme})
|
||||
@@ -267,6 +272,50 @@ def main() -> int:
|
||||
not window.device_setup.isVisible(),
|
||||
"the device setup is open before anyone asked for it",
|
||||
)
|
||||
# Where the three move controls live, and why. Execute is about the selected
|
||||
# point, so it belongs to the finger screen; the stop is repeated next to it
|
||||
# *and* stays in the strip, because an emergency stop has to be where the
|
||||
# operator's eyes are; returning to zero is a machine action wanted after any
|
||||
# tab's work, so it lives in the always-visible rail and is not duplicated.
|
||||
checker.that(
|
||||
window.finger_panel.execute_button.parent() is not None
|
||||
and window.finger_panel.stop_button.parent() is not None,
|
||||
"the finger screen is missing an execute or stop button",
|
||||
)
|
||||
checker.that(
|
||||
window.status_strip.stop_button.isEnabled() is not None,
|
||||
"the strip has no stop button",
|
||||
)
|
||||
checker.that(
|
||||
window.axis_readout.return_button.text() == t("move.return_initial"),
|
||||
f"the rail return button says "
|
||||
f"{window.axis_readout.return_button.text()!r}",
|
||||
)
|
||||
checker.that(
|
||||
window.axis_readout.return_button.parent() is window.axis_readout,
|
||||
"the return button is not in the always-visible rail",
|
||||
)
|
||||
# The mapping page is the third tab of the device setup, and the service
|
||||
# already holds the mapping it shows.
|
||||
checker.that(
|
||||
window.device_setup.tabs.count() == 3,
|
||||
f"the device setup has {window.device_setup.tabs.count()} tabs, expected 3",
|
||||
)
|
||||
checker.that(
|
||||
window.device_setup.tabs.tabText(2) == t("setup.tab_axes"),
|
||||
f"the third setup tab is {window.device_setup.tabs.tabText(2)!r}",
|
||||
)
|
||||
# With no controller, a move must be refused with a reason and the execute
|
||||
# button must say so rather than appearing to work.
|
||||
disconnected = window.poller.service.check_axes_move({"x": 1.0})
|
||||
checker.that(
|
||||
disconnected is not None,
|
||||
"a move was allowed with no controller connected",
|
||||
)
|
||||
checker.that(
|
||||
not window.finger_panel.execute_button.isEnabled(),
|
||||
"execute is enabled with no controller connected",
|
||||
)
|
||||
checker.that(
|
||||
window.device_setup.connection_panel is window.connection_panel
|
||||
and window.device_setup.bus_panel is window.bus_panel,
|
||||
@@ -401,14 +450,14 @@ def main() -> int:
|
||||
|
||||
# A full simulated session: connect, initialise from the shipped BAS.
|
||||
window.connect_controller(ConnectionSpec(mode="demo"), label="connect")
|
||||
_wait_for(app, lambda: window._last_snapshot.connected, 6.0)
|
||||
_wait_for(app, lambda w=window: w._last_snapshot.connected, 6.0)
|
||||
checker.that(
|
||||
window._last_snapshot.connected, "the simulated controller did not connect"
|
||||
)
|
||||
|
||||
window.bus_panel.set_bas_path(str(default_bas_dir() / "ECAT.bas"))
|
||||
window._on_download_bas()
|
||||
ok = _wait_for(app, lambda: window._last_snapshot.bus.init_status == 1, 8.0)
|
||||
ok = _wait_for(app, lambda w=window: w._last_snapshot.bus.init_status == 1, 8.0)
|
||||
checker.that(ok, "the simulated bus did not report initialised")
|
||||
if ok:
|
||||
checker.that(
|
||||
@@ -453,12 +502,167 @@ def main() -> int:
|
||||
or "5" in strip.chip_axes.text(),
|
||||
f"strip axis chip says {strip.chip_axes.text()!r}",
|
||||
)
|
||||
checker.that(
|
||||
window.axis_readout._rows[0]["position"].text().startswith("0.000"),
|
||||
f"readout row 0 shows {window.axis_readout._rows[0]['position'].text()!r}",
|
||||
)
|
||||
window.workspace.set_current_key("axes")
|
||||
|
||||
# ---- the pose move ------------------------------------------------
|
||||
# The whole path end to end, on the simulated controller: solve a pose
|
||||
# from a picked point, plan it, run it, and check the drives arrived.
|
||||
# Nothing else exercises the order, the units and the guards together.
|
||||
window.workspace.set_current_key("finger")
|
||||
app.processEvents()
|
||||
finger = window.finger_panel
|
||||
checker.that(
|
||||
finger.execute_button.isEnabled(),
|
||||
"execute is disabled on a connected, initialised platform",
|
||||
)
|
||||
checker.that(
|
||||
finger.stop_button.isEnabled(),
|
||||
"the local stop is disabled while the platform may move",
|
||||
)
|
||||
|
||||
# A point to solve: pick the centre of the flat image, which is the
|
||||
# operator's own gesture, so the pose the check plans is a real one.
|
||||
if not finger.points():
|
||||
view = finger.view
|
||||
window._on_pick_point(view.width() // 2, view.height() // 2) # noqa: SLF001
|
||||
app.processEvents()
|
||||
checker.that(
|
||||
bool(finger.points()),
|
||||
"picking the middle of the patch produced no point",
|
||||
)
|
||||
|
||||
solve = finger.solved_pose()
|
||||
checker.that(
|
||||
solve is not None,
|
||||
"the finger screen solved no pose from its point list",
|
||||
)
|
||||
if solve is not None:
|
||||
targets, held = solve
|
||||
checker.that(
|
||||
"z" in held and "z" not in targets,
|
||||
f"execute does not hold the press axis back: "
|
||||
f"{sorted(targets)} held={held}",
|
||||
)
|
||||
plan = plan_pose_move(window.poller.service, targets, hold=held)
|
||||
checker.that(
|
||||
[step.role for step in plan.steps] == ["yaw", "pitch", "x", "y"],
|
||||
f"the move order is {[step.role for step in plan.steps]}",
|
||||
)
|
||||
checker.that(
|
||||
all(math.isfinite(step.units) for step in plan.steps),
|
||||
"a planned step does not convert to user units",
|
||||
)
|
||||
# Straight to the mover: the confirmation box is the operator's, and
|
||||
# a modal dialog in a headless check is a hang (it has been one).
|
||||
result = window.mover.start(plan)
|
||||
checker.that(result.ok, f"the mover refused the plan: {result.message}")
|
||||
moved = _wait_for(app, lambda w=window: not w.mover.running, 25.0)
|
||||
checker.that(moved, "the pose move did not finish")
|
||||
checker.that(
|
||||
not window.mover.state.failure,
|
||||
f"the pose move failed: {window.mover.state.failure}",
|
||||
)
|
||||
for step in plan.steps:
|
||||
state = window._last_snapshot.axis(step.axis)
|
||||
if state is None:
|
||||
continue
|
||||
if step.unit == "mm":
|
||||
actual = window.poller.service.to_mm(step.axis, state.dpos)
|
||||
else:
|
||||
actual = user_units_to_deg(step.axis, state.dpos)
|
||||
checker.that(
|
||||
abs(actual - step.target) <= 0.05,
|
||||
f"{step.role} ended at {actual:.4f}{step.unit}, "
|
||||
f"target {step.target:.4f}{step.unit}",
|
||||
)
|
||||
print(
|
||||
" move: "
|
||||
+ ", ".join(f"{s.role}={s.target:+.3f}{s.unit}" for s in plan.steps)
|
||||
+ " reached"
|
||||
)
|
||||
|
||||
# Returning to the initial state, press axis first.
|
||||
back = plan_return_to_initial(window.poller.service)
|
||||
checker.that(
|
||||
[step.role for step in back.steps]
|
||||
== ["z", "pitch", "yaw", "x", "y"],
|
||||
f"the return order is {[step.role for step in back.steps]}",
|
||||
)
|
||||
result = window.mover.start(back)
|
||||
checker.that(result.ok, f"the return was refused: {result.message}")
|
||||
returned = _wait_for(app, lambda w=window: not w.mover.running, 30.0)
|
||||
checker.that(
|
||||
returned and not window.mover.state.failure,
|
||||
f"the return did not finish: {window.mover.state.failure}",
|
||||
)
|
||||
zeroed = [
|
||||
window._last_snapshot.axis(step.axis).dpos
|
||||
for step in back.steps
|
||||
if window._last_snapshot.axis(step.axis) is not None
|
||||
]
|
||||
checker.that(
|
||||
all(abs(value) <= 1.0 for value in zeroed),
|
||||
f"the axes did not return to zero: {zeroed}",
|
||||
)
|
||||
print(" move: every axis returned to zero")
|
||||
|
||||
# Stop cancels the queue: no further axis may be commanded.
|
||||
window.mover.start(plan)
|
||||
window._on_stop_all()
|
||||
app.processEvents()
|
||||
checker.that(not window.mover.running, "stop left the move queued")
|
||||
checker.that(
|
||||
window.mover.state.cancelled,
|
||||
"stop did not mark the move cancelled",
|
||||
)
|
||||
print(" move: stop cancels the queue")
|
||||
|
||||
# The guards, on the same connected session: a mapping that claims one
|
||||
# drive twice must refuse motion rather than quietly pick a role.
|
||||
service = window.poller.service
|
||||
saved = service.axis_roles
|
||||
service.set_axis_roles({"x": 0, "y": 0, "pitch": 2, "yaw": 3, "z": 4})
|
||||
refusal = service.check_axes_move({"x": 1.0, "y": 1.0})
|
||||
checker.that(
|
||||
refusal is not None,
|
||||
f"a duplicated drive was not refused: {refusal}",
|
||||
)
|
||||
service.set_axis_roles(saved)
|
||||
refusal = service.check_axes_move({"x": 500.0})
|
||||
checker.that(
|
||||
refusal is not None and "500" in refusal.message,
|
||||
f"a target past the travel limit was not refused: {refusal}",
|
||||
)
|
||||
refusal = service.check_axes_move({"yaw": 400.0})
|
||||
checker.that(
|
||||
refusal is not None,
|
||||
f"a yaw past the stage envelope was not refused: {refusal}",
|
||||
)
|
||||
# The mapping page must agree with the service it feeds.
|
||||
page = window.device_setup.roles_page
|
||||
checker.that(
|
||||
page.roles() == saved,
|
||||
f"the mapping page shows {page.roles()}, service holds {saved}",
|
||||
)
|
||||
page.combos["y"].setCurrentIndex(page.combos["y"].findData(0))
|
||||
app.processEvents()
|
||||
checker.that(
|
||||
bool(page.problem()), "the mapping page accepted two roles on one drive"
|
||||
)
|
||||
checker.that(
|
||||
service.roles_problem() != "",
|
||||
"the service did not pick up the duplicated mapping",
|
||||
)
|
||||
page.combos["y"].setCurrentIndex(page.combos["y"].findData(1))
|
||||
app.processEvents()
|
||||
checker.that(
|
||||
page.problem() == "" and service.roles_problem() == "",
|
||||
"correcting the mapping did not clear the refusal",
|
||||
)
|
||||
print(" move: guards refuse a duplicate mapping and an out-of-range target")
|
||||
window.workspace.set_current_key("axes")
|
||||
app.processEvents()
|
||||
|
||||
try:
|
||||
window.poller.shutdown()
|
||||
except Exception: # noqa: BLE001 - a shutdown must not mask the result
|
||||
|
||||
@@ -53,21 +53,19 @@ def main() -> int:
|
||||
parser.add_argument(
|
||||
"--setup-dialog",
|
||||
default="",
|
||||
choices=("", "connection", "bus"),
|
||||
choices=("", "connection", "bus", "axes"),
|
||||
help="photograph the device-setup dialog on this tab instead of the window",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
width, height = (int(part) for part in args.size.lower().split("x"))
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import set_locale
|
||||
from orisys_app_shared.theme import build_qss
|
||||
|
||||
from orisys_autocal.motion import ConnectionSpec, DemoTransport
|
||||
from orisys_autocal.preferences import load_preferences
|
||||
from orisys_autocal.ui.bus_panel import default_bas_dir
|
||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
set_locale(args.language)
|
||||
app = QApplication.instance() or QApplication([])
|
||||
|
||||
@@ -96,14 +96,12 @@ def main() -> int:
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import set_locale
|
||||
from orisys_app_shared.theme import build_qss
|
||||
|
||||
from orisys_autocal.preferences import load_preferences
|
||||
from orisys_autocal.ui.pose_preview import configure_compatible_gl
|
||||
from orisys_autocal.ui.window import AutoCalMainWindow
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
width, height = (int(part) for part in args.size.lower().split("x"))
|
||||
|
||||
@@ -223,7 +221,6 @@ def main() -> int:
|
||||
# pixels, because a projection that disagrees with the render looks
|
||||
# perfectly healthy on its own — that is how the labels drifted off
|
||||
# their arrows and the scene sat off to one side without a word.
|
||||
from orisys_autocal.ui.pose_preview import COLOUR
|
||||
|
||||
for label, point, colour in _landmarks(panel):
|
||||
screen = panel.preview.project(point)
|
||||
|
||||
@@ -9,21 +9,6 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QAction, QActionGroup, QIcon, QImage
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QSplitter,
|
||||
QStatusBar,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
|
||||
from orisys_app_shared.acquisition.image_backend import (
|
||||
DEPTH,
|
||||
@@ -39,24 +24,33 @@ from orisys_app_shared.force import select_display_forces
|
||||
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
|
||||
from orisys_app_shared.force.filters import FilterSettings
|
||||
from orisys_app_shared.force.session import SessionStore
|
||||
from orisys_app_shared.paths import recordings_dir
|
||||
from orisys_app_shared.i18n import set_locale, t
|
||||
from orisys_app_shared.logging import report_error
|
||||
from orisys_app_shared.paths import logo_path, recordings_dir, sensor_json_path
|
||||
from orisys_app_shared.prefs import save_preferences
|
||||
from orisys_app_shared.record import RecordConfig
|
||||
from orisys_app_shared.record.sample import to_record_sample
|
||||
from orisys_app_shared.theme import build_qss, clear_theme_cache, color
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QAction, QActionGroup, QIcon
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QSplitter,
|
||||
QStatusBar,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_studio import session_metadata
|
||||
from orisys_studio.calibration_service import CalibrationService
|
||||
from orisys_studio.display_prefs import DisplayPrefsController
|
||||
from orisys_studio.recording import RecordingCoordinator, record_config_from_prefs
|
||||
from orisys_studio.replay import ReplayEngine
|
||||
from orisys_studio.replay_controller import ReplayController
|
||||
from orisys_studio.calibration_service import CalibrationService
|
||||
from orisys_studio.display_prefs import DisplayPrefsController
|
||||
from orisys_studio.view_layout import ViewLayoutController
|
||||
from orisys_studio import session_metadata
|
||||
from orisys_studio.session_controller import SessionController
|
||||
from orisys_studio.scene3d.prefs import settings_to_prefs
|
||||
from orisys_app_shared.i18n import set_locale, t
|
||||
from orisys_app_shared.logging import report_error
|
||||
from orisys_app_shared.paths import logo_path, sensor_json_path
|
||||
from orisys_app_shared.prefs import save_preferences
|
||||
from orisys_app_shared.theme import build_qss, clear_theme_cache, color
|
||||
|
||||
from orisys_studio.session_controller import SessionController
|
||||
from orisys_studio.ui import record_status as record_states
|
||||
from orisys_studio.ui.about_dialog import AboutDialog
|
||||
from orisys_studio.ui.arrow_panel import ArrowPanel, bgr_to_qimage
|
||||
@@ -74,6 +68,7 @@ from orisys_studio.ui.plot_settings_dialog import (
|
||||
)
|
||||
from orisys_studio.ui.sensor_picker import SensorPickerWidget
|
||||
from orisys_studio.ui.toolbar import ToolbarWidget
|
||||
from orisys_studio.view_layout import ViewLayoutController
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -2476,7 +2471,7 @@ class StudioMainWindow(QMainWindow):
|
||||
return ""
|
||||
return t("panels.depth_scale", low=f"{span[0]:.3f}", high=f"{span[1]:.3f}")
|
||||
|
||||
def _latched_domain_mask(self, frame: "ImageFrame"):
|
||||
def _latched_domain_mask(self, frame: ImageFrame):
|
||||
"""The 2D domain boundary to draw, latched for the session.
|
||||
|
||||
Exactly the treatment ``_depth_range`` gives the colour scale, and for the same
|
||||
|
||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
from orisys_app_shared.force.calibration_io import (
|
||||
CHANNEL_KEYS,
|
||||
@@ -37,14 +37,14 @@ logger = logging.getLogger(__name__)
|
||||
class CalibrationService:
|
||||
"""Owns the calibration session's files and its prepared model."""
|
||||
|
||||
def __init__(self, *, store: Optional[CalSessionStore] = None) -> None:
|
||||
def __init__(self, *, store: CalSessionStore | None = None) -> None:
|
||||
self.store = store or CalSessionStore()
|
||||
#: The one prepared model the panel displays and exports.
|
||||
self.prepared: Any = None
|
||||
#: Device identity of a reopened record, while it is open.
|
||||
self.record_identity: Optional[dict] = None
|
||||
self.record_identity: dict | None = None
|
||||
#: The record currently open, if any.
|
||||
self.open_record: Optional[CalibrationSessionRecord] = None
|
||||
self.open_record: CalibrationSessionRecord | None = None
|
||||
|
||||
# --------------------------------------------------------------- identity
|
||||
def export_meta(
|
||||
|
||||
@@ -19,17 +19,17 @@ repaint when the 2D view mode changes.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional, Protocol
|
||||
from typing import Any, Protocol
|
||||
|
||||
from PySide6.QtCore import QObject
|
||||
|
||||
from orisys_studio.ui.plot_settings_dialog import (
|
||||
DEFAULT_PLOT_DEADBAND_N,
|
||||
DEFAULT_PLOT_DEADBAND_RAW,
|
||||
DEFAULT_PLOT_FN_MIN,
|
||||
DEFAULT_PLOT_FN_MAX,
|
||||
DEFAULT_PLOT_FN_RAW_MIN,
|
||||
DEFAULT_PLOT_FN_MIN,
|
||||
DEFAULT_PLOT_FN_RAW_MAX,
|
||||
DEFAULT_PLOT_FN_RAW_MIN,
|
||||
DEFAULT_PLOT_SHEAR_MAX,
|
||||
DEFAULT_PLOT_SHEAR_RAW_MAX,
|
||||
DEFAULT_PLOT_WINDOW_SEC,
|
||||
@@ -66,7 +66,7 @@ class DisplayHost(Protocol):
|
||||
prefs: dict
|
||||
|
||||
@property
|
||||
def _latest_fps(self) -> Optional[float]: ...
|
||||
def _latest_fps(self) -> float | None: ...
|
||||
|
||||
def _persist_prefs(self) -> None: ...
|
||||
|
||||
@@ -94,7 +94,7 @@ class DisplayPrefsController(QObject):
|
||||
arrow_rotate_types: frozenset,
|
||||
mesh_panel: Any = None,
|
||||
rotate_arrows_action: Any = None,
|
||||
parent: Optional[QObject] = None,
|
||||
parent: QObject | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._host = host
|
||||
|
||||
@@ -11,17 +11,18 @@ import sys
|
||||
# wrong one. The application is PySide6-only, so say so before anything imports pyqtgraph.
|
||||
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
|
||||
|
||||
from PySide6.QtWidgets import QApplication # noqa: E402
|
||||
|
||||
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.paths import prefer_local_sensor_bundles
|
||||
from orisys_app_shared.prefs import load_preferences
|
||||
from orisys_app_shared.theme import build_qss
|
||||
from PySide6.QtWidgets import QApplication # noqa: E402
|
||||
|
||||
from orisys_studio import REQUIRES_SHARED_API, __version__ as app_version
|
||||
from orisys_studio import REQUIRES_SHARED_API
|
||||
from orisys_studio import __version__ as app_version
|
||||
from orisys_studio.app_window import StudioMainWindow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -16,8 +16,9 @@ here, where they can be tested without a display.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
from typing import Any
|
||||
|
||||
from orisys_app_shared.record import ExperimentRecorder, RecordConfig, RecordReport
|
||||
from orisys_app_shared.record.calibration_snapshot import store_calibration_snapshot
|
||||
@@ -34,7 +35,7 @@ class RecordingCoordinator:
|
||||
*,
|
||||
session_store,
|
||||
cal_recorder,
|
||||
recorder: Optional[ExperimentRecorder] = None,
|
||||
recorder: ExperimentRecorder | None = None,
|
||||
) -> None:
|
||||
self.session_store = session_store
|
||||
self.cal_recorder = cal_recorder
|
||||
@@ -47,7 +48,7 @@ class RecordingCoordinator:
|
||||
self._alarms: list[str] = []
|
||||
#: A stop was requested and the writer has not closed its files yet.
|
||||
self.report_pending = False
|
||||
self.last_report: Optional[RecordReport] = None
|
||||
self.last_report: RecordReport | None = None
|
||||
self._active_calibration_id = ""
|
||||
self._record_fps = 30.0
|
||||
self.recorder.set_alarm_handler(self._on_alarm)
|
||||
@@ -210,7 +211,7 @@ class RecordingCoordinator:
|
||||
self.report_pending = False
|
||||
return Path(directory)
|
||||
|
||||
def stop(self, *, wait: bool = True) -> Optional[RecordReport]:
|
||||
def stop(self, *, wait: bool = True) -> RecordReport | None:
|
||||
"""Stop and return the report, or ``None`` when nothing was started."""
|
||||
if self.recorder.directory is None and self.recorder.report is None:
|
||||
self.report_pending = False
|
||||
@@ -221,7 +222,7 @@ class RecordingCoordinator:
|
||||
self.publish(report)
|
||||
return report
|
||||
|
||||
def publish(self, report: Optional[RecordReport]) -> None:
|
||||
def publish(self, report: RecordReport | None) -> None:
|
||||
"""Record a report; a completed one becomes ``last_report``."""
|
||||
if report is None:
|
||||
return
|
||||
@@ -232,7 +233,7 @@ class RecordingCoordinator:
|
||||
self.report_pending = False
|
||||
self.last_report = report
|
||||
|
||||
def poll(self) -> Optional[RecordReport]:
|
||||
def poll(self) -> RecordReport | None:
|
||||
"""The terminal report, once the writer has actually closed the files."""
|
||||
if not self.report_pending:
|
||||
return None
|
||||
@@ -256,7 +257,7 @@ def record_config_from_prefs(
|
||||
*,
|
||||
mode: str,
|
||||
fps: float,
|
||||
on_problem: Optional[Callable[[str], None]] = None,
|
||||
on_problem: Callable[[str], None] | None = None,
|
||||
) -> RecordConfig:
|
||||
"""Build the record config the toolbar/prefs describe."""
|
||||
return RecordConfig(
|
||||
|
||||
@@ -21,13 +21,11 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.record import RecordingReader, VideoOnlySource
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -64,7 +62,7 @@ class ReplayEngine(QObject):
|
||||
self._position = 0.0
|
||||
#: The frame cache: one video image per cursor position.
|
||||
self._video_position: float | None = None
|
||||
self._video_bgr: Optional[np.ndarray] = None
|
||||
self._video_bgr: np.ndarray | None = None
|
||||
self._video_reason = ""
|
||||
self._warnings = ""
|
||||
|
||||
@@ -209,7 +207,7 @@ class ReplayEngine(QObject):
|
||||
return self._unit
|
||||
|
||||
# ----------------------------------------------------------------- frames
|
||||
def video_frame_bgr(self) -> Optional[np.ndarray]:
|
||||
def video_frame_bgr(self) -> np.ndarray | None:
|
||||
"""The recorded frame under the cursor, as BGR.
|
||||
|
||||
The engine hands over pixels, not a prepared widget image: the single 2D
|
||||
|
||||
@@ -25,15 +25,14 @@ import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Protocol
|
||||
from typing import Any, Protocol
|
||||
|
||||
import numpy as np
|
||||
from PySide6.QtCore import QObject, QTimer
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
from orisys_app_shared.acquisition.image_backend import SOURCE_REPLAY, ImageFrame
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import recordings_dir
|
||||
from PySide6.QtCore import QObject, QTimer
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -111,7 +110,7 @@ class ReplayController(QObject):
|
||||
charts: Any,
|
||||
image_panel: Any,
|
||||
mesh_panel: Any,
|
||||
parent: Optional[QObject] = None,
|
||||
parent: QObject | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._engine = engine
|
||||
@@ -120,7 +119,7 @@ class ReplayController(QObject):
|
||||
self._charts = charts
|
||||
self._image_panel = image_panel
|
||||
self._mesh_panel = mesh_panel
|
||||
self._actions: Optional[ReplayActions] = None
|
||||
self._actions: ReplayActions | None = None
|
||||
#: True while a recompute pass is running: it is a *run* over a recording, so
|
||||
#: the acquire pipeline owns the panels and the transport stops the pass.
|
||||
self._recompute_running = False
|
||||
@@ -501,7 +500,7 @@ class ReplayController(QObject):
|
||||
self._host._notify(t("toolbar.nothing_to_stop"))
|
||||
|
||||
# ---------------------------------------------------------------- recompute
|
||||
def video_segment(self) -> Optional[Path]:
|
||||
def video_segment(self) -> Path | None:
|
||||
"""The recorded video a recompute pass should read."""
|
||||
reader = self._engine.reader()
|
||||
segments = sorted(reader.segments()) if reader is not None else []
|
||||
|
||||
@@ -33,10 +33,9 @@ Two things the v1 adapters deliberately do **not** do:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared.acquisition.models import SdkPacket
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
Diagnostic,
|
||||
@@ -45,7 +44,6 @@ from orisys_app_shared.scene3d.contracts import (
|
||||
FrameIdentity,
|
||||
SurfaceFieldFrame,
|
||||
diagnostic,
|
||||
error,
|
||||
freeze,
|
||||
)
|
||||
from orisys_app_shared.scene3d.sampling import remap_to_domain
|
||||
@@ -84,7 +82,7 @@ def _refusal_rank(item: Diagnostic) -> int:
|
||||
return _REFUSAL_RANK.get(str(item.code), 0)
|
||||
|
||||
|
||||
def flow_magnitude(packet: SdkPacket) -> Optional[np.ndarray]:
|
||||
def flow_magnitude(packet: SdkPacket) -> np.ndarray | None:
|
||||
"""``|flow|`` as float32, or ``None`` when the packet carries no flow."""
|
||||
flow = getattr(packet, "flow", None)
|
||||
if flow is None:
|
||||
@@ -97,11 +95,11 @@ def flow_magnitude(packet: SdkPacket) -> Optional[np.ndarray]:
|
||||
return None
|
||||
|
||||
|
||||
def _shape_of(array: Optional[np.ndarray]) -> str:
|
||||
def _shape_of(array: np.ndarray | None) -> str:
|
||||
return "none" if array is None else "x".join(str(int(v)) for v in array.shape[:2])
|
||||
|
||||
|
||||
def _field_validity(packet: SdkPacket, field: str, frame_id: int) -> Optional[Diagnostic]:
|
||||
def _field_validity(packet: SdkPacket, field: str, frame_id: int) -> Diagnostic | None:
|
||||
"""Whether the packet may claim ``field`` for *this* frame.
|
||||
|
||||
Three answers, and the middle one is the point: ``None`` on the packet means the
|
||||
@@ -145,7 +143,7 @@ def _device_mask(
|
||||
packet: SdkPacket,
|
||||
shape_hw: tuple[int, int],
|
||||
latch: Any = None,
|
||||
) -> tuple[Optional[np.ndarray], Optional[Diagnostic]]:
|
||||
) -> tuple[np.ndarray | None, Diagnostic | None]:
|
||||
"""The device's own validity, or a diagnostic when it describes another grid.
|
||||
|
||||
Both silent choices are wrong: stretching a mask from another grid marks samples
|
||||
@@ -177,7 +175,7 @@ def _device_mask(
|
||||
return array, None
|
||||
|
||||
|
||||
def _intersect(mask: np.ndarray, device: Optional[np.ndarray]) -> np.ndarray:
|
||||
def _intersect(mask: np.ndarray, device: np.ndarray | None) -> np.ndarray:
|
||||
"""Inside the UV island AND the device's own valid area, when it stated one."""
|
||||
if device is None:
|
||||
return freeze(mask, dtype=np.bool_)
|
||||
@@ -218,8 +216,8 @@ class FingerFrameAdapter:
|
||||
geometry_id: str,
|
||||
field_domain_id: str,
|
||||
shape_hw: tuple[int, int],
|
||||
map_x: Optional[np.ndarray] = None,
|
||||
map_y: Optional[np.ndarray] = None,
|
||||
map_x: np.ndarray | None = None,
|
||||
map_y: np.ndarray | None = None,
|
||||
preferred_field: str = "depth",
|
||||
) -> None:
|
||||
self.geometry_id = str(geometry_id)
|
||||
@@ -227,7 +225,7 @@ class FingerFrameAdapter:
|
||||
self.shape_hw = (int(shape_hw[0]), int(shape_hw[1]))
|
||||
self.map_x = map_x
|
||||
self.map_y = map_y
|
||||
self.last_diagnostic: Optional[Diagnostic] = None
|
||||
self.last_diagnostic: Diagnostic | None = None
|
||||
#: Which quantity the operator asked for (``"depth"`` or ``"flow"``). The other
|
||||
#: one is tried when this one cannot be coloured, and the substitution is stated
|
||||
#: rather than silent — a frame that shows another quantity than the setting says
|
||||
@@ -236,7 +234,7 @@ class FingerFrameAdapter:
|
||||
self.preferred_field = _field_name(preferred_field)
|
||||
#: Set (and cleared) by every snapshot: the reason the picture is not the field
|
||||
#: the operator chose. ``None`` when the preferred field was the one drawn.
|
||||
self.last_substitution: Optional[Diagnostic] = None
|
||||
self.last_substitution: Diagnostic | None = None
|
||||
#: The session's island latch (``image_backend.DomainMaskLatch``), when the
|
||||
#: window has one. See ``_device_mask``: the surface's validity is the *latched*
|
||||
#: island — the same mask the 2D canvas paints its island and its "no depth"
|
||||
@@ -247,7 +245,7 @@ class FingerFrameAdapter:
|
||||
self.preferred_field = _field_name(name)
|
||||
|
||||
@classmethod
|
||||
def from_domain(cls, domain: Any, *, geometry_id: str) -> "FingerFrameAdapter":
|
||||
def from_domain(cls, domain: Any, *, geometry_id: str) -> FingerFrameAdapter:
|
||||
"""Build from a ``LegacyFieldDomain`` (which carries the map, when loaded)."""
|
||||
return cls(
|
||||
geometry_id=geometry_id,
|
||||
@@ -259,7 +257,7 @@ class FingerFrameAdapter:
|
||||
|
||||
def snapshot(
|
||||
self, packet: SdkPacket, runtime: Any, identity: FrameIdentity
|
||||
) -> Optional[SurfaceFieldFrame]:
|
||||
) -> SurfaceFieldFrame | None:
|
||||
"""Colour the surface with the field the operator chose, or say why not.
|
||||
|
||||
Two quantities can be drawn and the operator picks one (``DisplaySettings.field``);
|
||||
@@ -306,7 +304,7 @@ class FingerFrameAdapter:
|
||||
identity: FrameIdentity,
|
||||
name: str,
|
||||
refused: list[Diagnostic],
|
||||
) -> Optional[SurfaceFieldFrame]:
|
||||
) -> SurfaceFieldFrame | None:
|
||||
"""One quantity, or ``None`` with the reason appended to ``refused``."""
|
||||
if name == "depth":
|
||||
depth = getattr(packet, "depth", None)
|
||||
@@ -401,7 +399,7 @@ class FingerFrameAdapter:
|
||||
mask: np.ndarray,
|
||||
descriptor: FieldDescriptor,
|
||||
refused: list[Diagnostic],
|
||||
) -> Optional[SurfaceFieldFrame]:
|
||||
) -> SurfaceFieldFrame | None:
|
||||
"""Intersect with the device's own mask and build the frame."""
|
||||
# Inside the UV island is the only place a value means anything: the tracker is
|
||||
# looking at masked black outside it.
|
||||
@@ -469,13 +467,13 @@ class BoxFrameAdapter:
|
||||
self.geometry_id = str(geometry_id)
|
||||
self.field_domain_id = str(field_domain_id)
|
||||
self.shape_hw = (int(shape_hw[0]), int(shape_hw[1]))
|
||||
self.last_diagnostic: Optional[Diagnostic] = None
|
||||
self.last_diagnostic: Diagnostic | None = None
|
||||
#: See ``FingerFrameAdapter.domain_latch``: one island, both views.
|
||||
self.domain_latch: Any = None
|
||||
|
||||
def snapshot(
|
||||
self, packet: SdkPacket, identity: FrameIdentity, domain: ExpectedGeometry
|
||||
) -> Optional[SurfaceFieldFrame]:
|
||||
) -> SurfaceFieldFrame | None:
|
||||
if tuple(domain.shape_hw) != self.shape_hw:
|
||||
self.last_diagnostic = diagnostic(
|
||||
"field_domain_mismatch",
|
||||
@@ -492,7 +490,7 @@ class BoxFrameAdapter:
|
||||
if mask_problem is not None:
|
||||
self.last_diagnostic = mask_problem
|
||||
return None
|
||||
refused: Optional[Diagnostic] = None
|
||||
refused: Diagnostic | None = None
|
||||
for name, field in candidates:
|
||||
if field is None:
|
||||
continue
|
||||
|
||||
@@ -28,12 +28,11 @@ exactly the kind of thing this feature is not allowed to cause.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Callable, Optional
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from orisys_app_shared.paths import config_dir
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
@@ -43,6 +42,8 @@ from orisys_app_shared.scene3d.contracts import (
|
||||
diagnostic,
|
||||
error,
|
||||
)
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from orisys_studio.scene3d.keys import SceneKeys, check_release_requirement
|
||||
|
||||
if TYPE_CHECKING: # the description is a value object; the callable returns one
|
||||
@@ -76,7 +77,7 @@ class SceneSource:
|
||||
return f"{self.kind}: {self.path}"
|
||||
|
||||
|
||||
def scene_dir(sensor_type: str, *, root: Optional[Path] = None) -> Path:
|
||||
def scene_dir(sensor_type: str, *, root: Path | None = None) -> Path:
|
||||
"""Where a compiled package for this sensor belongs."""
|
||||
base = Path(root) if root is not None else config_dir()
|
||||
return base / "sensors" / str(sensor_type) / "scene"
|
||||
@@ -86,8 +87,8 @@ def find_package(
|
||||
sensor_type: str,
|
||||
*,
|
||||
override: str | Path | None = None,
|
||||
root: Optional[Path] = None,
|
||||
) -> Optional[SceneSource]:
|
||||
root: Path | None = None,
|
||||
) -> SceneSource | None:
|
||||
"""The package to load, or ``None``. Never raises: absence is a state."""
|
||||
if override:
|
||||
path = Path(str(override)).expanduser()
|
||||
@@ -126,8 +127,8 @@ def no_asset_diagnostic(sensor_type: str, *, override: str | Path | None = None)
|
||||
|
||||
def load_package(
|
||||
source: SceneSource,
|
||||
expected: Optional[ExpectedGeometry] = None,
|
||||
keys: "Optional[SceneKeys]" = None,
|
||||
expected: ExpectedGeometry | None = None,
|
||||
keys: SceneKeys | None = None,
|
||||
) -> SceneAssets:
|
||||
"""Read and validate a package. Runs on a worker thread; raises ``ThreeDError``.
|
||||
|
||||
@@ -153,10 +154,10 @@ def load_package(
|
||||
|
||||
def _resolve_and_load(
|
||||
source: SceneSource,
|
||||
expected: Optional[ExpectedGeometry],
|
||||
provider: "Optional[Callable[[], DeviceGeometry]]",
|
||||
keys: "Optional[SceneKeys]" = None,
|
||||
) -> tuple[SceneAssets, "Optional[DeviceGeometry]"]:
|
||||
expected: ExpectedGeometry | None,
|
||||
provider: Callable[[], DeviceGeometry] | None,
|
||||
keys: SceneKeys | None = None,
|
||||
) -> tuple[SceneAssets, DeviceGeometry | None]:
|
||||
"""Worker-thread half: build the *device's* description, then read the package.
|
||||
|
||||
Deriving the description digests the unroll map the runtime loads, so it belongs on
|
||||
@@ -183,21 +184,21 @@ class SceneAssetLoader(QObject):
|
||||
loaded = Signal(object, str, int, object) # SceneAssets, description, id, device
|
||||
failed = Signal(object, int) # Diagnostic, request id
|
||||
|
||||
def __init__(self, parent: Optional[QObject] = None) -> None:
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._executor: Optional[ThreadPoolExecutor] = None
|
||||
self._executor: ThreadPoolExecutor | None = None
|
||||
self._inflight = False
|
||||
self._request_id = 0
|
||||
#: The newest request that could not start yet (source, expected, provider, id).
|
||||
self._pending: Optional[tuple[SceneSource, Optional[ExpectedGeometry], Any, int]] = None
|
||||
self._pending: tuple[SceneSource, ExpectedGeometry | None, Any, int] | None = None
|
||||
|
||||
def load(
|
||||
self,
|
||||
source: SceneSource,
|
||||
expected: Optional[ExpectedGeometry] = None,
|
||||
expected: ExpectedGeometry | None = None,
|
||||
*,
|
||||
expected_provider: "Optional[Callable[[], DeviceGeometry]]" = None,
|
||||
keys: "Optional[SceneKeys]" = None,
|
||||
expected_provider: Callable[[], DeviceGeometry] | None = None,
|
||||
keys: SceneKeys | None = None,
|
||||
) -> int:
|
||||
"""Ask for a scene; returns the request id this call was given."""
|
||||
self._request_id += 1
|
||||
|
||||
@@ -15,14 +15,12 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
F32,
|
||||
CameraMatrices,
|
||||
CameraPreset,
|
||||
F32,
|
||||
freeze,
|
||||
)
|
||||
|
||||
@@ -54,7 +52,7 @@ class OrbitCamera:
|
||||
vertical_fov_rad: float = 0.6
|
||||
aspect: float = 1.0
|
||||
_fitted_radius_m: float = 0.05
|
||||
_preset_name: Optional[str] = None
|
||||
_preset_name: str | None = None
|
||||
|
||||
# ------------------------------------------------------------------- setup
|
||||
def fit(self, bounds_sensor_m: F32, aspect: float) -> None:
|
||||
|
||||
@@ -36,9 +36,10 @@ from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import CancelledError, Executor, Future
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
from typing import Any
|
||||
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
Diagnostic,
|
||||
@@ -106,27 +107,27 @@ class ThreeDCoordinator:
|
||||
#: withdrawn: a stop, a hide, a new session, a new scene. A compose result
|
||||
#: whose epoch is not this one is thrown away instead of presented.
|
||||
self._epoch = 0
|
||||
self._assets: Optional[SceneAssets] = None
|
||||
self._assets: SceneAssets | None = None
|
||||
self._source_id = ""
|
||||
self._session_id: Optional[int] = None
|
||||
self._session_id: int | None = None
|
||||
#: The newest request id seen in this epoch: a producer that falls behind
|
||||
#: (a seek UI, a queue that reorders) must not put an older frame on screen.
|
||||
self._last_request_id = 0
|
||||
self._visible = False
|
||||
self._settings = DisplaySettings(max_fps=self._max_fps)
|
||||
self._inflight: Optional[_Inflight] = None
|
||||
self._inflight: _Inflight | None = None
|
||||
self._stale_frames = 0
|
||||
self._invalid_frames = 0
|
||||
self._last_diagnostic: Optional[Diagnostic] = None
|
||||
self._last_diagnostic: Diagnostic | None = None
|
||||
self._closed = False
|
||||
self._settings_dirty = False
|
||||
self._last_composed_identity: Optional[FrameIdentity] = None
|
||||
self._last_composed_identity: FrameIdentity | None = None
|
||||
#: What is on screen and when it got there, for the pane's frame counter.
|
||||
self._last_presented: Optional[tuple[FrameIdentity, float]] = None
|
||||
self._last_presented: tuple[FrameIdentity, float] | None = None
|
||||
#: When the current measurement stopped being current, or ``None`` while the
|
||||
#: view is live. Set by a field that could not be built, a compose that
|
||||
#: failed, or a frame whose array belongs to an earlier acquisition frame.
|
||||
self._stale_since: Optional[float] = None
|
||||
self._stale_since: float | None = None
|
||||
#: Whether the reference model is already on screen for the current claim.
|
||||
#: Without this the pane re-uploaded the same rest frame on every 16 ms tick
|
||||
#: while it waited for the first measurement.
|
||||
@@ -138,7 +139,7 @@ class ThreeDCoordinator:
|
||||
self._last_result_latency_ms = 0.0
|
||||
#: The field frame currently on screen, so a gain change can redraw exactly
|
||||
#: it rather than a newer instant.
|
||||
self._last_field_frame: Optional[SurfaceFieldFrame] = None
|
||||
self._last_field_frame: SurfaceFieldFrame | None = None
|
||||
|
||||
# ------------------------------------------------------------------- state
|
||||
@property
|
||||
@@ -155,7 +156,7 @@ class ThreeDCoordinator:
|
||||
"""The display claim results are accepted under (see :class:`_Inflight`)."""
|
||||
return self._epoch
|
||||
|
||||
def last_presented(self) -> Optional[tuple[FrameIdentity, float]]:
|
||||
def last_presented(self) -> tuple[FrameIdentity, float] | None:
|
||||
"""What is on screen, and the monotonic time it was handed to the widget."""
|
||||
return self._last_presented
|
||||
|
||||
@@ -190,7 +191,7 @@ class ThreeDCoordinator:
|
||||
return self._invalid_frames
|
||||
|
||||
@property
|
||||
def last_diagnostic(self) -> Optional[Diagnostic]:
|
||||
def last_diagnostic(self) -> Diagnostic | None:
|
||||
return self._last_diagnostic
|
||||
|
||||
@property
|
||||
@@ -355,7 +356,7 @@ class ThreeDCoordinator:
|
||||
self._last_request_id = max(self._last_request_id, identity.request_id)
|
||||
return self._incoming.offer(frame)
|
||||
|
||||
def note_field_failure(self, item: Optional[Diagnostic] = None) -> None:
|
||||
def note_field_failure(self, item: Diagnostic | None = None) -> None:
|
||||
"""A producer could not build a field frame from this packet.
|
||||
|
||||
Called from the acquisition thread by the sink that owns the adapter: when the
|
||||
@@ -373,7 +374,7 @@ class ThreeDCoordinator:
|
||||
@staticmethod
|
||||
def _reject_reason(
|
||||
frame: SurfaceFieldFrame, assets: SceneAssets
|
||||
) -> Optional[Diagnostic]:
|
||||
) -> Diagnostic | None:
|
||||
"""The cheap checks a producer thread may do: identity and shape only."""
|
||||
if frame.geometry_id != assets.geometry_id:
|
||||
return diagnostic(
|
||||
|
||||
@@ -24,14 +24,15 @@ zero: the reference model is not a measurement and must not look like one.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
CameraMatrices,
|
||||
Diagnostic,
|
||||
DisplaySettings,
|
||||
GLCapabilities,
|
||||
MeshPart,
|
||||
@@ -43,7 +44,8 @@ from orisys_app_shared.scene3d.contracts import (
|
||||
error,
|
||||
freeze,
|
||||
)
|
||||
from orisys_app_shared.scene3d.contracts import Diagnostic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Renderer names that are not a GPU. A board must not be signed off on one of
|
||||
#: these, and neither must a laptop with a broken driver.
|
||||
@@ -261,7 +263,7 @@ def shader_source(profile: Profile, program: str) -> tuple[str, str]:
|
||||
return header + vertex, header + body
|
||||
|
||||
|
||||
def _valid_scalar_range(scalar, valid) -> Optional[tuple[float, float]]:
|
||||
def _valid_scalar_range(scalar, valid) -> tuple[float, float] | None:
|
||||
"""Robust P5/P95 range of *valid* samples, or ``None`` when there are none.
|
||||
|
||||
Using min/max made empty-field noise paint the whole LUT. A percentile span
|
||||
@@ -294,11 +296,11 @@ def _valid_scalar_range(scalar, valid) -> Optional[tuple[float, float]]:
|
||||
|
||||
|
||||
def _blend_scale_range(
|
||||
previous: Optional[tuple[float, float]],
|
||||
current: Optional[tuple[float, float]],
|
||||
previous: tuple[float, float] | None,
|
||||
current: tuple[float, float] | None,
|
||||
*,
|
||||
alpha: float = 0.15,
|
||||
) -> Optional[tuple[float, float]]:
|
||||
) -> tuple[float, float] | None:
|
||||
"""Slowly update an automatic colour range to avoid frame-to-frame flicker."""
|
||||
if current is None:
|
||||
return previous
|
||||
@@ -310,7 +312,7 @@ def _blend_scale_range(
|
||||
(1.0 - a) * float(previous[1]) + a * float(current[1]),
|
||||
)
|
||||
|
||||
def _scale_bounds(scale, automatic: Optional[tuple[float, float]]) -> tuple[float, float]:
|
||||
def _scale_bounds(scale, automatic: tuple[float, float] | None) -> tuple[float, float]:
|
||||
"""The colour range to draw with: the operator's, or the data's own.
|
||||
|
||||
``automatic`` mode exists because the quantities differ in magnitude by orders
|
||||
@@ -327,7 +329,7 @@ def _scale_bounds(scale, automatic: Optional[tuple[float, float]]) -> tuple[floa
|
||||
else:
|
||||
fallback = (float(scale.lower), float(scale.upper))
|
||||
lower, upper = float(fallback[0]), float(fallback[1])
|
||||
if not (lower == lower) or not (upper == upper): # NaN
|
||||
if math.isnan(lower) or math.isnan(upper):
|
||||
return 0.0, 1.0
|
||||
if upper <= lower:
|
||||
# A constant field still has to be visible rather than all one clipped colour.
|
||||
@@ -404,7 +406,7 @@ def _extend_edges(values: np.ndarray, usable: np.ndarray) -> np.ndarray:
|
||||
return np.where(usable, values, filled)
|
||||
|
||||
|
||||
def pack_part(part: MeshPart, *, uvs: Optional[np.ndarray] = None) -> np.ndarray:
|
||||
def pack_part(part: MeshPart, *, uvs: np.ndarray | None = None) -> np.ndarray:
|
||||
"""Interleave one part into the layout the shaders read.
|
||||
|
||||
``uvs`` are the render-vertex UVs on the domain's pixel grid
|
||||
@@ -431,7 +433,7 @@ def pack_part(part: MeshPart, *, uvs: Optional[np.ndarray] = None) -> np.ndarray
|
||||
|
||||
|
||||
def pack_scene_part(
|
||||
part: MeshPart, *, uvs: Optional[np.ndarray] = None
|
||||
part: MeshPart, *, uvs: np.ndarray | None = None
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""``(vertices, indices)`` as the GPU wants them, both read-only."""
|
||||
indices = np.asarray(part.triangles, dtype=np.uint32).reshape(-1)
|
||||
@@ -598,7 +600,14 @@ class _PartBuffers:
|
||||
"""One part's GPU state. Kept dumb on purpose: no Qt, no ownership games."""
|
||||
|
||||
__slots__ = (
|
||||
"part_id", "role", "vao", "vbo", "ebo", "triangles", "vertices", "rows"
|
||||
"ebo",
|
||||
"part_id",
|
||||
"role",
|
||||
"rows",
|
||||
"triangles",
|
||||
"vao",
|
||||
"vbo",
|
||||
"vertices"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
@@ -620,7 +629,7 @@ class GLRenderer:
|
||||
"""Shaders, buffers and draw calls for one scene."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._capabilities: Optional[GLCapabilities] = None
|
||||
self._capabilities: GLCapabilities | None = None
|
||||
self._programs: dict[str, int] = {}
|
||||
self._uniforms: dict[str, dict[str, int]] = {}
|
||||
self._parts: dict[str, _PartBuffers] = {}
|
||||
@@ -632,13 +641,13 @@ class GLRenderer:
|
||||
#: allocation and no per-frame vertex traffic.
|
||||
self._field_texture: int = 0
|
||||
self._valid_texture: int = 0
|
||||
self._field_shape: Optional[tuple[int, int]] = None
|
||||
self._field_shape: tuple[int, int] | None = None
|
||||
self._field_internal_format: int = 0
|
||||
#: Range of the valid samples of the field currently uploaded, for the
|
||||
#: automatic colour scale. ``None`` until a frame carries valid data.
|
||||
self._scalar_range: Optional[tuple[float, float]] = None
|
||||
self._scalar_range: tuple[float, float] | None = None
|
||||
#: The colour bounds of the last draw and how they were chosen.
|
||||
self._last_scale: Optional[tuple[float, float]] = None
|
||||
self._last_scale: tuple[float, float] | None = None
|
||||
self._last_scale_mode = ""
|
||||
self._sensing_part_id: str = ""
|
||||
self._estimated_gpu_bytes: int = 0
|
||||
@@ -926,8 +935,12 @@ class GLRenderer:
|
||||
GL.glDeleteVertexArrays(1, [int(vao)])
|
||||
GL.glDeleteBuffers(1, [int(vbo)])
|
||||
GL.glDeleteBuffers(1, [int(ebo)])
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001 - best-effort delete, reported below
|
||||
# A context that has already been lost refuses the delete, and this also
|
||||
# runs on the live overlay-replacement path: raising here would replace a
|
||||
# frame with a crash. Reported rather than swallowed, because a delete
|
||||
# that keeps failing leaks GPU memory for the rest of the session.
|
||||
logger.warning("could not free the %s overlay's GL objects: %s", kind, exc)
|
||||
|
||||
def draw(
|
||||
self,
|
||||
@@ -1021,7 +1034,7 @@ class GLRenderer:
|
||||
scale_mode=self._last_scale_mode,
|
||||
)
|
||||
|
||||
def effective_scale(self) -> Optional[tuple[float, float]]:
|
||||
def effective_scale(self) -> tuple[float, float] | None:
|
||||
"""The colour bounds the last draw actually used (``None`` before any draw)."""
|
||||
return self._last_scale
|
||||
|
||||
@@ -1059,7 +1072,7 @@ class GLRenderer:
|
||||
GL.glActiveTexture(GL.GL_TEXTURE0)
|
||||
|
||||
@property
|
||||
def field_shape(self) -> Optional[tuple[int, int]]:
|
||||
def field_shape(self) -> tuple[int, int] | None:
|
||||
"""The allocated field texture's ``(H, W)``, or ``None`` before the first frame."""
|
||||
return self._field_shape
|
||||
|
||||
@@ -1150,7 +1163,7 @@ class GLRenderer:
|
||||
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
|
||||
self._lut_id = lut_id
|
||||
|
||||
def capabilities(self) -> Optional[GLCapabilities]:
|
||||
def capabilities(self) -> GLCapabilities | None:
|
||||
return self._capabilities
|
||||
|
||||
@property
|
||||
@@ -1246,7 +1259,7 @@ def _decode(value: Any) -> str:
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _set_mat4(GL: Any, location: Optional[int], values: np.ndarray) -> None:
|
||||
def _set_mat4(GL: Any, location: int | None, values: np.ndarray) -> None:
|
||||
if location is None:
|
||||
return
|
||||
# Column-major upload, explicitly: the layout of the NumPy array must not
|
||||
@@ -1254,17 +1267,17 @@ def _set_mat4(GL: Any, location: Optional[int], values: np.ndarray) -> None:
|
||||
GL.glUniformMatrix4fv(location, 1, GL.GL_FALSE, np.ascontiguousarray(values.T))
|
||||
|
||||
|
||||
def _set_float(GL: Any, location: Optional[int], value: float) -> None:
|
||||
def _set_float(GL: Any, location: int | None, value: float) -> None:
|
||||
if location is not None:
|
||||
GL.glUniform1f(location, float(value))
|
||||
|
||||
|
||||
def _set_vec3(GL: Any, location: Optional[int], values: tuple[float, float, float]) -> None:
|
||||
def _set_vec3(GL: Any, location: int | None, values: tuple[float, float, float]) -> None:
|
||||
if location is not None:
|
||||
GL.glUniform3f(location, float(values[0]), float(values[1]), float(values[2]))
|
||||
|
||||
|
||||
def _set_vec4(GL: Any, location: Optional[int], values: tuple[float, float, float, float]) -> None:
|
||||
def _set_vec4(GL: Any, location: int | None, values: tuple[float, float, float, float]) -> None:
|
||||
if location is not None:
|
||||
GL.glUniform4f(
|
||||
location, float(values[0]), float(values[1]), float(values[2]), float(values[3])
|
||||
|
||||
@@ -16,9 +16,10 @@ paths and booleans so a recording can say how a package was authenticated.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Optional
|
||||
from typing import Any
|
||||
|
||||
from orisys_app_shared.scene3d.contracts import Diagnostic, diagnostic
|
||||
|
||||
@@ -36,7 +37,7 @@ PREF_REQUIRE_SIGNATURE = "scene3d_require_signature"
|
||||
class SceneKeys:
|
||||
"""What the loader needs to authenticate a package — and how strict to be."""
|
||||
|
||||
verify_key_pem: Optional[bytes] = None
|
||||
verify_key_pem: bytes | None = None
|
||||
provider: Any = None
|
||||
require_signature: bool = False
|
||||
#: Paths/ids only, for logs and for the recording: never material.
|
||||
@@ -59,7 +60,7 @@ class SceneKeys:
|
||||
}
|
||||
|
||||
|
||||
def resolve_scene_keys(prefs: Optional[Mapping[str, Any]]) -> SceneKeys:
|
||||
def resolve_scene_keys(prefs: Mapping[str, Any] | None) -> SceneKeys:
|
||||
"""Build the key material the preferences describe. Never raises for absence.
|
||||
|
||||
A missing or unreadable file is a *diagnostic-bearing state*, not a crash: the 2D
|
||||
@@ -68,7 +69,7 @@ def resolve_scene_keys(prefs: Optional[Mapping[str, Any]]) -> SceneKeys:
|
||||
"""
|
||||
prefs = dict(prefs or {})
|
||||
sources: dict[str, str] = {}
|
||||
verify_pem: Optional[bytes] = None
|
||||
verify_pem: bytes | None = None
|
||||
provider = None
|
||||
|
||||
verify_path = str(prefs.get(PREF_VERIFY_KEY) or "").strip()
|
||||
@@ -113,7 +114,7 @@ def resolve_scene_keys(prefs: Optional[Mapping[str, Any]]) -> SceneKeys:
|
||||
)
|
||||
|
||||
|
||||
def check_release_requirement(path: Path, keys: Optional[SceneKeys]) -> Optional[Diagnostic]:
|
||||
def check_release_requirement(path: Path, keys: SceneKeys | None) -> Diagnostic | None:
|
||||
"""Refuse an unsigned package when the application is in release mode.
|
||||
|
||||
Development builds accept a plain package (the shipped engineering one is exactly
|
||||
|
||||
@@ -18,10 +18,20 @@ Two rules it exists to enforce in the interface:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
ColorScale,
|
||||
Diagnostic,
|
||||
DisplaySettings,
|
||||
FieldDescriptor,
|
||||
RenderStats,
|
||||
SceneAssets,
|
||||
SceneCapabilities,
|
||||
SceneState,
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QImage
|
||||
from PySide6.QtWidgets import (
|
||||
@@ -38,18 +48,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
ColorScale,
|
||||
Diagnostic,
|
||||
DisplaySettings,
|
||||
FieldDescriptor,
|
||||
RenderStats,
|
||||
SceneAssets,
|
||||
SceneCapabilities,
|
||||
SceneState,
|
||||
)
|
||||
from orisys_studio.scene3d.prefs import FPS_CHOICES, DEFAULT_FIELD
|
||||
from orisys_studio.scene3d.prefs import DEFAULT_FIELD, FPS_CHOICES
|
||||
from orisys_studio.ui.fit_text import ShrinkableLabel
|
||||
|
||||
if TYPE_CHECKING: # the panel talks to these two by interface, not by import
|
||||
@@ -82,7 +81,7 @@ class ColorScaleBar(QLabel):
|
||||
because in automatic mode the spin boxes beside it held values nobody was using.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("colorScaleBar")
|
||||
self.setFixedHeight(12)
|
||||
@@ -136,23 +135,23 @@ class ThreeDPanel(QFrame):
|
||||
self,
|
||||
coordinator: ThreeDCoordinator,
|
||||
widget: Sensor3DWidget,
|
||||
parent: Optional[QWidget] = None,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("contentPanel")
|
||||
self._coordinator = coordinator
|
||||
self._widget = widget
|
||||
self._assets: Optional[SceneAssets] = None
|
||||
self._descriptor: Optional[FieldDescriptor] = None
|
||||
self._assets: SceneAssets | None = None
|
||||
self._descriptor: FieldDescriptor | None = None
|
||||
self._state: SceneState = "disabled"
|
||||
self._source_kind = "live"
|
||||
self._syncing = False
|
||||
#: What the data source can provide (see ``contracts.SceneCapabilities``): an
|
||||
#: unavailable capability disables its control and says why, rather than
|
||||
#: letting the operator operate an input that does not exist.
|
||||
self._capabilities: Optional[SceneCapabilities] = None
|
||||
self._capabilities: SceneCapabilities | None = None
|
||||
#: The bounds the last draw actually used (the widget's RenderStats).
|
||||
self._effective_scale: Optional[RenderStats] = None
|
||||
self._effective_scale: RenderStats | None = None
|
||||
#: Whether the pane explains the pipeline around the picture (the window's
|
||||
#: 「诊断信息」 switch). Off by default: the picture and what it *is* stay.
|
||||
self._show_diagnostics = False
|
||||
@@ -355,7 +354,7 @@ class ThreeDPanel(QFrame):
|
||||
self.set_state("disabled")
|
||||
|
||||
# ------------------------------------------------------------------ inputs
|
||||
def set_scene(self, assets: Optional[SceneAssets]) -> None:
|
||||
def set_scene(self, assets: SceneAssets | None) -> None:
|
||||
"""Fill the preset list from the asset: its default view is the asset's."""
|
||||
self._assets = assets
|
||||
self._syncing = True
|
||||
@@ -377,7 +376,7 @@ class ThreeDPanel(QFrame):
|
||||
self.preset_combo.setEnabled(bool(assets is not None and self.preset_combo.count()))
|
||||
self.reset_button.setEnabled(assets is not None)
|
||||
|
||||
def set_descriptor(self, descriptor: Optional[FieldDescriptor]) -> None:
|
||||
def set_descriptor(self, descriptor: FieldDescriptor | None) -> None:
|
||||
"""The measurement currently on screen — or none, which hides the bar."""
|
||||
if descriptor != self._descriptor:
|
||||
self._effective_scale = None
|
||||
@@ -417,7 +416,7 @@ class ThreeDPanel(QFrame):
|
||||
"""
|
||||
self.scale_bar.set_ramp(ramp)
|
||||
|
||||
def set_capabilities(self, capabilities: Optional[SceneCapabilities]) -> None:
|
||||
def set_capabilities(self, capabilities: SceneCapabilities | None) -> None:
|
||||
"""Offer only the controls this data source can actually feed.
|
||||
|
||||
Today's adapters colour a scalar in place and publish no displacement at all, so
|
||||
@@ -451,7 +450,7 @@ class ThreeDPanel(QFrame):
|
||||
else t("scene3d.contact_arrow_tip")
|
||||
)
|
||||
|
||||
def capabilities(self) -> Optional[SceneCapabilities]:
|
||||
def capabilities(self) -> SceneCapabilities | None:
|
||||
return self._capabilities
|
||||
|
||||
def _render_legend(self) -> None:
|
||||
@@ -551,7 +550,7 @@ class ThreeDPanel(QFrame):
|
||||
self.source_label.setToolTip(t(f"scene3d.source_{kind}_tip"))
|
||||
self.source_label.setVisible(self._show_diagnostics)
|
||||
|
||||
def set_diagnostic(self, diagnostic: Optional[Diagnostic]) -> None:
|
||||
def set_diagnostic(self, diagnostic: Diagnostic | None) -> None:
|
||||
"""One diagnostic line. The 2D views stay usable behind it.
|
||||
|
||||
Warnings and errors are always stated: they are claims about the *measurement*
|
||||
|
||||
@@ -19,13 +19,15 @@ file cannot relabel a measurement as another quantity.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping, Optional
|
||||
import math
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
UNAVAILABLE_DESCRIPTOR,
|
||||
ColorScale,
|
||||
DisplaySettings,
|
||||
FieldDescriptor,
|
||||
UNAVAILABLE_DESCRIPTOR,
|
||||
)
|
||||
|
||||
#: Frame rates the pane offers. A saved value outside this set is repaired to the
|
||||
@@ -51,7 +53,7 @@ def _number(value: Any, fallback: float) -> float:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
if number != number or number in (float("inf"), float("-inf")): # NaN / inf
|
||||
if math.isnan(number) or number in (float("inf"), float("-inf")): # NaN / inf
|
||||
return fallback
|
||||
return number
|
||||
|
||||
@@ -102,9 +104,9 @@ def settings_to_prefs(settings: DisplaySettings) -> dict[str, Any]:
|
||||
|
||||
|
||||
def settings_from_prefs(
|
||||
raw: Optional[Mapping[str, Any]],
|
||||
raw: Mapping[str, Any] | None,
|
||||
*,
|
||||
descriptor: Optional[FieldDescriptor] = None,
|
||||
descriptor: FieldDescriptor | None = None,
|
||||
) -> DisplaySettings:
|
||||
"""Rebuild the display settings, repairing anything unusable.
|
||||
|
||||
@@ -121,7 +123,7 @@ def settings_from_prefs(
|
||||
field = str(raw.get("field") or DEFAULT_FIELD)
|
||||
if field not in FIELD_CHOICES:
|
||||
field = DEFAULT_FIELD
|
||||
scale: Optional[ColorScale] = None
|
||||
scale: ColorScale | None = None
|
||||
saved = raw.get("color_scale")
|
||||
if isinstance(saved, Mapping):
|
||||
mode = str(saved.get("mode") or "fixed")
|
||||
|
||||
@@ -14,8 +14,9 @@ widgets — and one set of signals — whether or not the dialog has ever been o
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
@@ -25,8 +26,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
if TYPE_CHECKING: # the panel is passed in; only its interfaces are used here
|
||||
from orisys_studio.scene3d.panel import ThreeDPanel
|
||||
|
||||
@@ -35,7 +34,7 @@ class Scene3dSettingsDialog(QDialog):
|
||||
"""A modeless home for the 3D pane's controls."""
|
||||
|
||||
def __init__(
|
||||
self, panel: "ThreeDPanel", parent: Optional[QWidget] = None
|
||||
self, panel: ThreeDPanel, parent: QWidget | None = None
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("scene3dSettings")
|
||||
|
||||
@@ -25,11 +25,9 @@ description disagrees with the asset is refused with both shapes named.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QVBoxLayout, QWidget
|
||||
from typing import Any
|
||||
|
||||
from orisys_app_shared.scene3d.composer import SceneComposer
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
@@ -47,6 +45,9 @@ from orisys_app_shared.scene3d.contracts import (
|
||||
diagnostic,
|
||||
validate_assets,
|
||||
)
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from orisys_studio.scene3d.adapters import BoxFrameAdapter, FingerFrameAdapter
|
||||
from orisys_studio.scene3d.camera import OrbitCamera
|
||||
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
|
||||
@@ -90,24 +91,24 @@ class Scene3DView(QWidget):
|
||||
descriptorChanged = Signal(object) # Optional[FieldDescriptor]
|
||||
enabledChanged = Signal(bool)
|
||||
|
||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._available, self._reason = gl_available()
|
||||
self._widget: Optional[Sensor3DWidget] = None
|
||||
self._panel: Optional[ThreeDPanel] = None
|
||||
self._coordinator: Optional[ThreeDCoordinator] = None
|
||||
self._widget: Sensor3DWidget | None = None
|
||||
self._panel: ThreeDPanel | None = None
|
||||
self._coordinator: ThreeDCoordinator | None = None
|
||||
self._camera = OrbitCamera()
|
||||
self._executor: Optional[ThreadPoolExecutor] = None
|
||||
self._assets: Optional[SceneAssets] = None
|
||||
self._composer: Optional[SceneComposer] = None
|
||||
self._executor: ThreadPoolExecutor | None = None
|
||||
self._assets: SceneAssets | None = None
|
||||
self._composer: SceneComposer | None = None
|
||||
#: The session's island latch, shared with the 2D canvas when the window hands
|
||||
#: one over (``image_backend.DomainMaskLatch``). One latch, two views: the
|
||||
#: surface's validity texture and the canvas's island are the same mask, so
|
||||
#: they cannot drift apart. ``None`` means "no session": the adapter then uses
|
||||
#: each frame's own mask, which is what the tests and the PoC want.
|
||||
self._domain_latch: Any = None
|
||||
self._expected: Optional[ExpectedGeometry] = None
|
||||
self._snapshot: Optional[Callable[[Any, FrameIdentity], Any]] = None
|
||||
self._expected: ExpectedGeometry | None = None
|
||||
self._snapshot: Callable[[Any, FrameIdentity], Any] | None = None
|
||||
#: The adapter behind ``_snapshot``: it owns the reason a packet was refused,
|
||||
#: and (for a source that cannot provide a displacement) what the pane may
|
||||
#: offer. Kept rather than closed over so those answers can be asked.
|
||||
@@ -115,11 +116,11 @@ class Scene3DView(QWidget):
|
||||
self._source_id = ""
|
||||
self._enabled = False
|
||||
self._source_kind = "live"
|
||||
self._last_diagnostic: Optional[Diagnostic] = None
|
||||
self._last_descriptor: Optional[FieldDescriptor] = None
|
||||
self._last_stats: Optional[RenderStats] = None
|
||||
self._last_diagnostic: Diagnostic | None = None
|
||||
self._last_descriptor: FieldDescriptor | None = None
|
||||
self._last_stats: RenderStats | None = None
|
||||
#: The coordinator's last diagnostic as *this* view has already reported it.
|
||||
self._reported_diagnostic: Optional[Diagnostic] = None
|
||||
self._reported_diagnostic: Diagnostic | None = None
|
||||
#: Whether the loaded asset was compared against the device's own description.
|
||||
self._device_verified = False
|
||||
#: Frames the producer did not even build (a frame was already waiting).
|
||||
@@ -173,7 +174,7 @@ class Scene3DView(QWidget):
|
||||
self,
|
||||
assets: SceneAssets,
|
||||
source_id: str,
|
||||
expected: Optional[ExpectedGeometry] = None,
|
||||
expected: ExpectedGeometry | None = None,
|
||||
) -> bool:
|
||||
"""Adopt a validated scene. Returns whether the view accepted it.
|
||||
|
||||
@@ -242,8 +243,8 @@ class Scene3DView(QWidget):
|
||||
return not violations
|
||||
|
||||
def _make_snapshot(
|
||||
self, assets: SceneAssets, expected: Optional[ExpectedGeometry]
|
||||
) -> Optional[Callable[[Any, FrameIdentity], Any]]:
|
||||
self, assets: SceneAssets, expected: ExpectedGeometry | None
|
||||
) -> Callable[[Any, FrameIdentity], Any] | None:
|
||||
"""Bind the adapter that fits this scene, or refuse with a diagnostic.
|
||||
|
||||
The two adapters have different call signatures because they need different
|
||||
@@ -358,12 +359,12 @@ class Scene3DView(QWidget):
|
||||
except Exception: # noqa: BLE001 - a display sink must never break capture
|
||||
logger.exception("the 3D sink failed for one packet")
|
||||
|
||||
def _snapshot_diagnostic(self) -> Optional[Diagnostic]:
|
||||
def _snapshot_diagnostic(self) -> Diagnostic | None:
|
||||
"""Why the adapter refused the last packet (the adapter owns the answer)."""
|
||||
item = getattr(self._adapter, "last_diagnostic", None)
|
||||
return item if isinstance(item, Diagnostic) else None
|
||||
|
||||
def identity_for(self, packet: Any) -> Optional[FrameIdentity]:
|
||||
def identity_for(self, packet: Any) -> FrameIdentity | None:
|
||||
"""The identity a field frame built from this packet would carry."""
|
||||
if self._coordinator is None or self._assets is None:
|
||||
return None
|
||||
@@ -448,7 +449,7 @@ class Scene3DView(QWidget):
|
||||
self._panel.set_source_kind(kind)
|
||||
self._publish_capabilities()
|
||||
|
||||
def capabilities(self) -> Optional[SceneCapabilities]:
|
||||
def capabilities(self) -> SceneCapabilities | None:
|
||||
"""What this data source can provide, for the panel's controls.
|
||||
|
||||
Read off the adapter that is actually bound: an adapter that colours a scalar in
|
||||
@@ -538,27 +539,27 @@ class Scene3DView(QWidget):
|
||||
return self._enabled
|
||||
|
||||
@property
|
||||
def assets(self) -> Optional[SceneAssets]:
|
||||
def assets(self) -> SceneAssets | None:
|
||||
return self._assets
|
||||
|
||||
@property
|
||||
def coordinator(self) -> Optional[ThreeDCoordinator]:
|
||||
def coordinator(self) -> ThreeDCoordinator | None:
|
||||
return self._coordinator
|
||||
|
||||
@property
|
||||
def gl_widget(self) -> Optional[Sensor3DWidget]:
|
||||
def gl_widget(self) -> Sensor3DWidget | None:
|
||||
return self._widget
|
||||
|
||||
@property
|
||||
def panel(self) -> Optional[ThreeDPanel]:
|
||||
def panel(self) -> ThreeDPanel | None:
|
||||
return self._panel
|
||||
|
||||
@property
|
||||
def last_diagnostic(self) -> Optional[Diagnostic]:
|
||||
def last_diagnostic(self) -> Diagnostic | None:
|
||||
return self._last_diagnostic
|
||||
|
||||
@property
|
||||
def last_descriptor(self) -> Optional[FieldDescriptor]:
|
||||
def last_descriptor(self) -> FieldDescriptor | None:
|
||||
return self._last_descriptor
|
||||
|
||||
def state(self) -> SceneState:
|
||||
@@ -568,7 +569,7 @@ class Scene3DView(QWidget):
|
||||
return "disabled"
|
||||
return self._coordinator.state
|
||||
|
||||
def render_stats(self) -> Optional[RenderStats]:
|
||||
def render_stats(self) -> RenderStats | None:
|
||||
return self._last_stats
|
||||
|
||||
def describe(self) -> dict:
|
||||
@@ -751,7 +752,7 @@ class Scene3DView(QWidget):
|
||||
self._panel.set_state(state)
|
||||
self.stateChanged.emit(state)
|
||||
|
||||
def _publish_coordinator_diagnostic(self, item: Optional[Diagnostic]) -> None:
|
||||
def _publish_coordinator_diagnostic(self, item: Diagnostic | None) -> None:
|
||||
"""Raise a coordinator diagnostic once, when it changes."""
|
||||
if item is None or item == self._reported_diagnostic:
|
||||
return
|
||||
|
||||
@@ -17,23 +17,24 @@ two-dimensional application carries on.
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QEvent, Qt, Signal
|
||||
from PySide6.QtGui import QMouseEvent, QWheelEvent
|
||||
# Qt6 moved QOpenGLWidget out of QtWidgets into its own module; PySide6 follows.
|
||||
from PySide6.QtOpenGLWidgets import QOpenGLWidget
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
Diagnostic,
|
||||
DisplaySettings,
|
||||
FrameIdentity,
|
||||
GLCapabilities,
|
||||
RenderFrame,
|
||||
RenderStats,
|
||||
SceneAssets,
|
||||
diagnostic,
|
||||
)
|
||||
from PySide6.QtCore import QEvent, Qt, Signal
|
||||
from PySide6.QtGui import QMouseEvent, QWheelEvent
|
||||
|
||||
# Qt6 moved QOpenGLWidget out of QtWidgets into its own module; PySide6 follows.
|
||||
from PySide6.QtOpenGLWidgets import QOpenGLWidget
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from orisys_studio.scene3d.camera import OrbitCamera
|
||||
from orisys_studio.scene3d.gl_backend import (
|
||||
GLDispatch,
|
||||
@@ -62,27 +63,27 @@ class Sensor3DWidget(QOpenGLWidget):
|
||||
self,
|
||||
renderer: GLRenderer,
|
||||
camera: OrbitCamera,
|
||||
parent: Optional[QWidget] = None,
|
||||
parent: QWidget | None = None,
|
||||
*,
|
||||
dispatch: Optional[GLDispatch] = None,
|
||||
dispatch: GLDispatch | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._renderer = renderer
|
||||
self._camera = camera
|
||||
self._dispatch = dispatch or GLDispatch()
|
||||
self._assets: Optional[SceneAssets] = None
|
||||
self._frame: Optional[RenderFrame] = None
|
||||
self._assets: SceneAssets | None = None
|
||||
self._frame: RenderFrame | None = None
|
||||
self._settings = DisplaySettings()
|
||||
self._capabilities: Optional[GLCapabilities] = None
|
||||
self._diagnostic: Optional[Diagnostic] = None
|
||||
self._capabilities: GLCapabilities | None = None
|
||||
self._diagnostic: Diagnostic | None = None
|
||||
self._needs_scene_upload = False
|
||||
self._needs_surface_upload = False
|
||||
self._last_stats_at = 0.0
|
||||
#: What reached the screen, counted by identity, and what it cost to compose.
|
||||
self._unique_frames = 0
|
||||
self._last_frame_identity: Optional[FrameIdentity] = None
|
||||
self._last_frame_identity: FrameIdentity | None = None
|
||||
self._last_compose_ms = 0.0
|
||||
self._last_press: Optional[tuple[float, float]] = None
|
||||
self._last_press: tuple[float, float] | None = None
|
||||
self._unavailable = False
|
||||
#: Frames the display never showed, set by the coordinator. Never a
|
||||
#: recording figure.
|
||||
@@ -119,7 +120,7 @@ class Sensor3DWidget(QOpenGLWidget):
|
||||
def camera(self) -> OrbitCamera:
|
||||
return self._camera
|
||||
|
||||
def capabilities(self) -> Optional[GLCapabilities]:
|
||||
def capabilities(self) -> GLCapabilities | None:
|
||||
return self._capabilities
|
||||
|
||||
def displayed_frames(self) -> int:
|
||||
@@ -130,7 +131,7 @@ class Sensor3DWidget(QOpenGLWidget):
|
||||
"""What the frame on screen cost to compose, measured where it was composed."""
|
||||
return float(self._last_compose_ms)
|
||||
|
||||
def diagnostic(self) -> Optional[Diagnostic]:
|
||||
def diagnostic(self) -> Diagnostic | None:
|
||||
return self._diagnostic
|
||||
|
||||
@property
|
||||
|
||||
@@ -24,11 +24,12 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from PySide6.QtCore import QObject, QTimer, Signal
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import QObject, QTimer, Signal
|
||||
|
||||
from orisys_studio import session_metadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -58,7 +59,7 @@ class SessionController(QObject):
|
||||
*,
|
||||
recording: Any,
|
||||
packet_for_3d: Callable[[Any], None],
|
||||
packet_sink: Optional[Callable[[Any], None]] = None,
|
||||
packet_sink: Callable[[Any], None] | None = None,
|
||||
sensor_type: str = "box",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
@@ -82,9 +83,9 @@ class SessionController(QObject):
|
||||
self._connect_slow_notified = False
|
||||
self._raw_fallback_notified = False
|
||||
# --- per-session telemetry (read by the rail, the plots and the record) -----
|
||||
self.last_sample_valid: Optional[bool] = None
|
||||
self.latest_fps: Optional[float] = None
|
||||
self.latest_process_ms: Optional[float] = None
|
||||
self.last_sample_valid: bool | None = None
|
||||
self.latest_fps: float | None = None
|
||||
self.latest_process_ms: float | None = None
|
||||
self.latest_capture_fps = 0.0
|
||||
self.latest_capture_dropped = 0
|
||||
#: How many readouts failed this session, for the status line's count.
|
||||
@@ -380,7 +381,7 @@ class SessionController(QObject):
|
||||
return consumer() if callable(consumer) else None
|
||||
|
||||
# ------------------------------------------------------------- presenter help
|
||||
def check_capture_negotiation(self) -> Optional[str]:
|
||||
def check_capture_negotiation(self) -> str | None:
|
||||
"""A once-per-session warning when the camera delivers less than configured.
|
||||
|
||||
The evidence is the *measured* capture rate, not the driver's reported one:
|
||||
@@ -429,7 +430,7 @@ class SessionController(QObject):
|
||||
"""The status line for a failed readout (the phase is deliberately untouched)."""
|
||||
return t("status.sample_invalid", count=self.record_error_count, reason=reason)
|
||||
|
||||
def take_dropped_message(self, dropped: int) -> Optional[str]:
|
||||
def take_dropped_message(self, dropped: int) -> str | None:
|
||||
"""One message per new batch of samples the memory bound discarded."""
|
||||
if int(dropped) <= self.dropped_reported or int(dropped) <= 0:
|
||||
return None
|
||||
|
||||
@@ -25,9 +25,10 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
from typing import Any
|
||||
|
||||
from orisys_app_shared.force.filters import FilterSettings, settings_from_mapping
|
||||
from orisys_app_shared.record import RecordConfig
|
||||
@@ -198,7 +199,8 @@ class SessionFacts:
|
||||
|
||||
def build_session_metadata(config: RecordConfig, facts: SessionFacts) -> dict:
|
||||
"""The ``session.json`` metadata written when a recording starts."""
|
||||
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_studio import __version__ as app_version
|
||||
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import logo_topbar_path
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import logo_topbar_path
|
||||
|
||||
from orisys_studio import __version__ as app_version
|
||||
|
||||
|
||||
|
||||
@@ -10,9 +10,14 @@ where the picture comes from (live / replay), which words the footer.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
from orisys_app_shared.acquisition.image_backend import (
|
||||
ARROW_PURE,
|
||||
ARROW_STITCHED,
|
||||
DEPTH,
|
||||
ImagePlan,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QImage, QPainter, QPixmap, QTransform
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
@@ -23,15 +28,6 @@ from PySide6.QtWidgets import (
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from orisys_app_shared.acquisition.image_backend import (
|
||||
ARROW_PURE,
|
||||
ARROW_STITCHED,
|
||||
DEPTH,
|
||||
ImagePlan,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
from orisys_studio.ui.fit_text import ShrinkableLabel
|
||||
|
||||
VIEW_MODES = (ARROW_PURE, ARROW_STITCHED, DEPTH)
|
||||
@@ -340,7 +336,7 @@ class ArrowPanel(QFrame):
|
||||
self._placeholder = self._empty_message()
|
||||
self.image_label.setText(self._placeholder)
|
||||
|
||||
def set_plan(self, plan: Optional[ImagePlan]) -> None:
|
||||
def set_plan(self, plan: ImagePlan | None) -> None:
|
||||
"""Adopt what the frame on screen can be shown as.
|
||||
|
||||
The modes come from the frame's own fields (see ``image_backend.plan_image``),
|
||||
|
||||
@@ -14,13 +14,12 @@ from __future__ import annotations
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QColor, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import logo_topbar_path
|
||||
from orisys_app_shared.theme import color
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QColor, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from orisys_app_shared.force.calibration_io import (
|
||||
ChannelKey,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import calibration_export_dir
|
||||
from orisys_app_shared.theme import color
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QColor, QFont, QFontDatabase
|
||||
from PySide6.QtWidgets import (
|
||||
@@ -28,19 +34,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.force.calibration_io import (
|
||||
CalSample,
|
||||
CalSessionStore,
|
||||
ChannelKey,
|
||||
meta_from_sensor_json,
|
||||
)
|
||||
from orisys_app_shared.force.calibration_session import (
|
||||
session_filename,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import calibration_export_dir
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
from orisys_studio.calibration_service import CalibrationService
|
||||
from orisys_studio.ui.cal_point_dialog import CalibrationPointDialog
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ the three-panel layout, while a single point can still be examined in full.
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from PySide6.QtCore import Qt
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import color
|
||||
from PySide6.QtGui import QFont, QFontDatabase
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
@@ -17,10 +18,6 @@ from PySide6.QtWidgets import (
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from orisys_app_shared.force import NA
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
try:
|
||||
import pyqtgraph as pg
|
||||
except ImportError as exc: # pragma: no cover
|
||||
|
||||
@@ -5,16 +5,6 @@ from __future__ import annotations
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
from PySide6.QtCore import Qt, QSize, Signal
|
||||
from PySide6.QtGui import QColor, QFont, QFontDatabase, QFontMetrics
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
|
||||
from orisys_app_shared.force import MetricHistory
|
||||
from orisys_app_shared.force.history import DEFAULT_MAX_FPS
|
||||
@@ -27,6 +17,15 @@ from orisys_app_shared.force.source import StoreCurveSource
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.record.sample import plottable_axis_mask
|
||||
from orisys_app_shared.theme import color
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
from PySide6.QtGui import QColor, QFont, QFontDatabase, QFontMetrics
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from orisys_studio.ui import icons
|
||||
from orisys_studio.ui.icons import ICON_SIZE
|
||||
@@ -130,11 +129,11 @@ def _mono_font(point_size: int = 9, *, bold: bool = False) -> QFont:
|
||||
class _Sync:
|
||||
"""Marks a block of range changes as programmatic (not a user gesture)."""
|
||||
|
||||
def __init__(self, panel: "ForcePlotsPanel") -> None:
|
||||
def __init__(self, panel: ForcePlotsPanel) -> None:
|
||||
self._panel = panel
|
||||
self._previous = panel._syncing
|
||||
|
||||
def __enter__(self) -> "_Sync":
|
||||
def __enter__(self) -> _Sync:
|
||||
self._panel._syncing = True
|
||||
return self
|
||||
|
||||
@@ -1054,7 +1053,7 @@ class ForcePlotsPanel(QFrame):
|
||||
self._window = (x0, x1)
|
||||
self.refresh_from_store()
|
||||
|
||||
def _guard(self) -> "_Sync":
|
||||
def _guard(self) -> _Sync:
|
||||
"""Context manager marking range changes as programmatic."""
|
||||
return _Sync(self)
|
||||
|
||||
|
||||
@@ -14,11 +14,10 @@ from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from orisys_app_shared.theme import color
|
||||
from PySide6.QtCore import QRectF, Qt
|
||||
from PySide6.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
|
||||
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
#: Names of the icons this module can paint.
|
||||
NAMES = ("zoom_fit", "box_zoom", "latest", "settings", "reset_view")
|
||||
|
||||
|
||||
@@ -13,9 +13,15 @@ acquisition, and no path that reports a 3D problem as a recording problem.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Callable, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
Diagnostic,
|
||||
ExpectedGeometry,
|
||||
)
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
@@ -27,13 +33,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
Diagnostic,
|
||||
ExpectedGeometry,
|
||||
SceneAssets,
|
||||
diagnostic,
|
||||
)
|
||||
from orisys_studio.scene3d.asset_source import (
|
||||
SceneAssetLoader,
|
||||
SceneSource,
|
||||
@@ -44,6 +43,7 @@ from orisys_studio.ui import icons
|
||||
|
||||
if TYPE_CHECKING: # the view imports GL bindings lazily; the type is enough here
|
||||
from orisys_app_shared.acquisition.geometry import DeviceGeometry
|
||||
|
||||
from orisys_studio.scene3d.view import Scene3DView
|
||||
|
||||
#: Shown when the pane is on but this sensor has no 3D scene at all (box, mini).
|
||||
@@ -81,17 +81,17 @@ class Mesh3dPanel(QFrame):
|
||||
enabledChanged = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self, parent: Optional[QWidget] = None, *, theme: str = "dark"
|
||||
self, parent: QWidget | None = None, *, theme: str = "dark"
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("contentPanel")
|
||||
self._theme = str(theme)
|
||||
self._view: Optional["Scene3DView"] = None
|
||||
self._loader: Optional[SceneAssetLoader] = None
|
||||
self._source: Optional[SceneSource] = None
|
||||
self._view: Scene3DView | None = None
|
||||
self._loader: SceneAssetLoader | None = None
|
||||
self._source: SceneSource | None = None
|
||||
self._sensor_type = ""
|
||||
self._expected: Optional[ExpectedGeometry] = None
|
||||
self._expected_provider: "Optional[Callable[[], DeviceGeometry]]" = None
|
||||
self._expected: ExpectedGeometry | None = None
|
||||
self._expected_provider: Callable[[], DeviceGeometry] | None = None
|
||||
self._source_kind = "live"
|
||||
self._enabled = False
|
||||
self._loaded = False
|
||||
@@ -103,8 +103,8 @@ class Mesh3dPanel(QFrame):
|
||||
self._keys = None
|
||||
#: The package whose scene is in the view, so switching sensor away and back
|
||||
#: does not reload it (and does not pretend to be loading it either).
|
||||
self._loaded_path: Optional[Path] = None
|
||||
self._reported: Optional[Diagnostic] = None
|
||||
self._loaded_path: Path | None = None
|
||||
self._reported: Diagnostic | None = None
|
||||
self._pending_settings = None
|
||||
self._message_kind = _UNAVAILABLE
|
||||
self._message_args: dict[str, str] = {"reason": ""}
|
||||
@@ -225,8 +225,8 @@ class Mesh3dPanel(QFrame):
|
||||
self,
|
||||
sensor_type: str,
|
||||
*,
|
||||
expected: Optional[ExpectedGeometry] = None,
|
||||
expected_provider: "Optional[Callable[[], DeviceGeometry]]" = None,
|
||||
expected: ExpectedGeometry | None = None,
|
||||
expected_provider: Callable[[], DeviceGeometry] | None = None,
|
||||
package_override: str | Path | None = None,
|
||||
) -> None:
|
||||
"""Tell the pane which sensor it is looking at. Cheap; no IO.
|
||||
@@ -276,7 +276,7 @@ class Mesh3dPanel(QFrame):
|
||||
return
|
||||
self._show_message(_LOADING, name=self._source.path.name)
|
||||
|
||||
def attach_view(self, view: "Scene3DView") -> None:
|
||||
def attach_view(self, view: Scene3DView) -> None:
|
||||
"""Adopt a view: the real one (built lazily) or a double in a test.
|
||||
|
||||
Public because it is the seam tests use — poking ``_view`` from a test would
|
||||
@@ -308,7 +308,7 @@ class Mesh3dPanel(QFrame):
|
||||
if not view.available:
|
||||
self._show_message(_UNAVAILABLE, reason=view.reason)
|
||||
|
||||
def ensure_view(self) -> Optional[QWidget]:
|
||||
def ensure_view(self) -> QWidget | None:
|
||||
"""Build the 3D view on first use, so a headless machine never imports GL."""
|
||||
if self._view is not None or self._sensor_type != "finger":
|
||||
return self._view
|
||||
@@ -448,11 +448,11 @@ class Mesh3dPanel(QFrame):
|
||||
|
||||
# ------------------------------------------------------------------ inputs
|
||||
@property
|
||||
def view(self) -> Optional["Scene3DView"]:
|
||||
def view(self) -> Scene3DView | None:
|
||||
return self._view
|
||||
|
||||
@property
|
||||
def last_diagnostic(self) -> Optional[Diagnostic]:
|
||||
def last_diagnostic(self) -> Diagnostic | None:
|
||||
return self._reported
|
||||
|
||||
@property
|
||||
@@ -471,7 +471,7 @@ class Mesh3dPanel(QFrame):
|
||||
"""Whether a 3D picture is possible here at all (GL and an asset)."""
|
||||
return self._view is not None and bool(self._view.available)
|
||||
|
||||
def scene_path(self) -> Optional[Path]:
|
||||
def scene_path(self) -> Path | None:
|
||||
return None if self._source is None else self._source.path
|
||||
|
||||
def set_source_kind(self, kind: str) -> None:
|
||||
|
||||
@@ -10,6 +10,12 @@ reports as done is what the record will say (see ``force/filters.py``).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
|
||||
from orisys_app_shared.force.filters import (
|
||||
FilterSettings,
|
||||
available_presets,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
@@ -25,14 +31,6 @@ from PySide6.QtWidgets import (
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
|
||||
from orisys_app_shared.force.filters import (
|
||||
SUGGESTED_LOWPASS_HZ,
|
||||
FilterSettings,
|
||||
available_presets,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
DEFAULT_PLOT_WINDOW_SEC = 4.0
|
||||
#: Lower bounds. FN is allowed below zero: a resting normal force has no reason
|
||||
#: to sit exactly on the frame, and with the lower bound at 0 the idle curve is
|
||||
|
||||
@@ -14,10 +14,9 @@ timer without touching the writer thread.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import QLabel
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import color
|
||||
from PySide6.QtWidgets import QLabel
|
||||
|
||||
#: The recording channel is idle, never started in this session.
|
||||
IDLE = "idle"
|
||||
|
||||
@@ -17,6 +17,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QFrame,
|
||||
@@ -26,8 +27,6 @@ from PySide6.QtWidgets import (
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from orisys_studio.ui.fit_text import ShrinkableLabel
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ own labels and tooltips instead of a bare "配置" combo.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
@@ -18,9 +20,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types
|
||||
|
||||
CONTROL_HEIGHT = 30
|
||||
#: Both combos stay narrow: this row shares one line with the session state.
|
||||
TYPE_WIDTH = 76
|
||||
|
||||
@@ -19,6 +19,8 @@ scrolls sideways instead of squeezing the controls into an unreadable row.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import color
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
@@ -33,9 +35,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
from orisys_studio.ui.fit_text import ShrinkableLabel
|
||||
from orisys_studio.ui.record_status import RecordStatusChip
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional, Protocol
|
||||
from typing import Any, Protocol
|
||||
|
||||
from PySide6.QtCore import QObject
|
||||
|
||||
@@ -80,7 +80,7 @@ class ViewLayoutController(QObject):
|
||||
default_splitter_sizes: tuple,
|
||||
min_panel_widths: dict,
|
||||
default_3d_pane_width: int,
|
||||
parent: Optional[QObject] = None,
|
||||
parent: QObject | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._host = host
|
||||
@@ -103,7 +103,7 @@ class ViewLayoutController(QObject):
|
||||
self._view_preset = preset if preset in self._presets else "custom"
|
||||
self._show_cal = bool(host.prefs.get("show_cal_panel", False))
|
||||
self._show_finger_3d = bool(host.prefs.get("show_finger_3d", True))
|
||||
self._actions: Optional[LayoutActions] = None
|
||||
self._actions: LayoutActions | None = None
|
||||
|
||||
# ------------------------------------------------------------------ wiring
|
||||
def bind_actions(
|
||||
@@ -244,7 +244,7 @@ class ViewLayoutController(QObject):
|
||||
self._set_check(self._actions.settings, self._toolbar.settings_expanded())
|
||||
|
||||
@staticmethod
|
||||
def _set_check(action: Any, checked: bool, *, enabled: Optional[bool] = None) -> None:
|
||||
def _set_check(action: Any, checked: bool, *, enabled: bool | None = None) -> None:
|
||||
"""Set a checkable entry without letting it drive its own handler."""
|
||||
if action is None:
|
||||
return
|
||||
|
||||
@@ -62,8 +62,8 @@ def qt_app():
|
||||
@pytest.fixture
|
||||
def make_window(qt_app, tmp_path, monkeypatch):
|
||||
"""Build a ``StudioMainWindow`` whose preference writes stay in tmp_path."""
|
||||
from orisys_app_shared import prefs as prefs_pkg
|
||||
from orisys_app_shared import paths as paths_mod
|
||||
from orisys_app_shared import prefs as prefs_pkg
|
||||
from orisys_studio.app_window import StudioMainWindow
|
||||
|
||||
prefs_file = tmp_path / "preference.json"
|
||||
|
||||
@@ -9,7 +9,6 @@ the curves and the value cards were actually given.
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared.force.session import SessionSample, SessionStore
|
||||
from orisys_studio.ui.force_plots import ForcePlotsPanel, _nice_ticks, format_force
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ button, is what tells stopped from paused.
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from tests.conftest import finish_run, running_window, show_window
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ window-level tests): these are the *rules*, not the decoder.
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_studio import replay_controller
|
||||
from orisys_studio.replay_controller import ReplayController
|
||||
|
||||
@@ -9,16 +9,13 @@ the grid does not match, with a diagnostic that says which shapes were seen.
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_app_shared.scene3d import synthetic
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
ExpectedGeometry,
|
||||
FrameIdentity,
|
||||
ThreeDError,
|
||||
validate_surface_frame,
|
||||
)
|
||||
from orisys_studio.scene3d.adapters import (
|
||||
@@ -40,16 +37,16 @@ class FakePacket:
|
||||
behaviour under test.
|
||||
"""
|
||||
|
||||
flow: Optional[np.ndarray] = None
|
||||
depth: Optional[np.ndarray] = None
|
||||
valid_mask: Optional[np.ndarray] = None
|
||||
flow: np.ndarray | None = None
|
||||
depth: np.ndarray | None = None
|
||||
valid_mask: np.ndarray | None = None
|
||||
frame_id: int = 1
|
||||
invalid_reason: str = ""
|
||||
flow_valid: Optional[bool] = True
|
||||
flow_frame_id: Optional[int] = None
|
||||
depth_valid: Optional[bool] = True
|
||||
depth_frame_id: Optional[int] = None
|
||||
forces_valid: Optional[bool] = True
|
||||
flow_valid: bool | None = True
|
||||
flow_frame_id: int | None = None
|
||||
depth_valid: bool | None = True
|
||||
depth_frame_id: int | None = None
|
||||
forces_valid: bool | None = True
|
||||
|
||||
|
||||
def _identity(generation: int = 1) -> FrameIdentity:
|
||||
|
||||
@@ -15,7 +15,6 @@ from dataclasses import replace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_app_shared.scene3d import synthetic
|
||||
from orisys_app_shared.scene3d.composer import SceneComposer
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
|
||||
@@ -23,7 +23,6 @@ import os
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_app_shared.scene3d import synthetic
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
ColorScale,
|
||||
@@ -105,7 +104,11 @@ def _pump(qt_app, widget, predicate, *, timeout_s: float = 5.0) -> bool:
|
||||
def gl_widget(qt_app):
|
||||
"""A real, shown QOpenGLWidget with the synthetic scene, driven by Qt."""
|
||||
from orisys_studio.scene3d.camera import OrbitCamera
|
||||
from orisys_studio.scene3d.gl_backend import GLDispatch, GLRenderer, configure_surface_format
|
||||
from orisys_studio.scene3d.gl_backend import (
|
||||
GLDispatch,
|
||||
GLRenderer,
|
||||
configure_surface_format,
|
||||
)
|
||||
from orisys_studio.scene3d.widget import Sensor3DWidget
|
||||
|
||||
configure_surface_format("desktop33")
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared.acquisition.arrows import _policy_from_style
|
||||
from orisys_app_shared.scene3d.composer import SceneComposer
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
|
||||
@@ -13,11 +13,10 @@ view menu / the pane's header both reach that dialog.
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_studio.ui import icons
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -186,7 +185,11 @@ def test_the_pane_offers_the_two_quantities_and_says_which_one_it_drew(panel):
|
||||
|
||||
def test_the_diagnostics_switch_governs_what_the_pane_explains(panel):
|
||||
"""Warnings are claims about the measurement and always show; info is explanation."""
|
||||
from orisys_app_shared.scene3d.contracts import FieldDescriptor, RenderStats, diagnostic
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
FieldDescriptor,
|
||||
RenderStats,
|
||||
diagnostic,
|
||||
)
|
||||
|
||||
panel.set_descriptor(FieldDescriptor("flow_magnitude", "px", "approximate", False))
|
||||
panel.set_render_stats(
|
||||
|
||||
@@ -13,7 +13,6 @@ import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_app_shared.scene3d import protect, synthetic
|
||||
from orisys_app_shared.scene3d.contracts import ExpectedGeometry, ThreeDError
|
||||
from orisys_app_shared.scene3d.package import write_package
|
||||
|
||||
@@ -404,8 +404,6 @@ def _render_region(qt_app, assets, island, uvs, preset_name, viewport):
|
||||
RenderFrame,
|
||||
freeze,
|
||||
)
|
||||
from PySide6.QtGui import QImage
|
||||
|
||||
from orisys_studio.scene3d import gl_backend
|
||||
from orisys_studio.scene3d.camera import OrbitCamera
|
||||
from orisys_studio.scene3d.gl_backend import (
|
||||
@@ -415,6 +413,7 @@ def _render_region(qt_app, assets, island, uvs, preset_name, viewport):
|
||||
lut_rgba,
|
||||
)
|
||||
from orisys_studio.scene3d.widget import Sensor3DWidget
|
||||
from PySide6.QtGui import QImage
|
||||
|
||||
configure_surface_format("desktop33")
|
||||
renderer = GLRenderer()
|
||||
|
||||
@@ -49,9 +49,8 @@ def test_the_skeleton_does_not_import_a_gl_binding(qt_app):
|
||||
|
||||
|
||||
def test_configure_surface_format_selects_a_complete_profile(qt_app, restore_surface_format):
|
||||
from PySide6.QtGui import QSurfaceFormat
|
||||
|
||||
from orisys_studio.scene3d.gl_backend import configure_surface_format
|
||||
from PySide6.QtGui import QSurfaceFormat
|
||||
|
||||
configure_surface_format("desktop33")
|
||||
desktop = QSurfaceFormat.defaultFormat()
|
||||
@@ -72,7 +71,7 @@ def test_configure_surface_format_selects_a_complete_profile(qt_app, restore_sur
|
||||
|
||||
def test_the_widget_holds_cpu_state_and_touches_no_context(qt_app):
|
||||
"""Constructing the widget must not need a current context or a GPU."""
|
||||
from orisys_app_shared.scene3d.contracts import DisplaySettings, FrameIdentity
|
||||
from orisys_app_shared.scene3d.contracts import DisplaySettings
|
||||
from orisys_studio.scene3d.camera import OrbitCamera
|
||||
from orisys_studio.scene3d.gl_backend import GLRenderer
|
||||
from orisys_studio.scene3d.widget import Sensor3DWidget
|
||||
|
||||
@@ -9,7 +9,6 @@ from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_app_shared.scene3d import synthetic
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
CameraPreset,
|
||||
|
||||
@@ -20,6 +20,7 @@ mathematics (the camera, the sampler and the composer have their own files):
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from concurrent.futures import Future
|
||||
from dataclasses import replace
|
||||
@@ -27,17 +28,14 @@ from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import ui_check
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.scene3d import synthetic
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
UNAVAILABLE_DESCRIPTOR,
|
||||
ColorScale,
|
||||
DisplaySettings,
|
||||
FieldDescriptor,
|
||||
UNAVAILABLE_DESCRIPTOR,
|
||||
)
|
||||
from orisys_app_shared.scene3d.package import write_package
|
||||
from orisys_studio.app_window import MIN_PANEL_WIDTHS
|
||||
@@ -49,9 +47,15 @@ from orisys_studio.scene3d.asset_source import (
|
||||
load_package,
|
||||
no_asset_diagnostic,
|
||||
)
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from tests.conftest import (
|
||||
finish_run as _finish,
|
||||
)
|
||||
from tests.conftest import (
|
||||
running_window as _running_window,
|
||||
)
|
||||
from tests.conftest import (
|
||||
show_window as _show,
|
||||
)
|
||||
|
||||
@@ -60,7 +64,7 @@ from tests.conftest import (
|
||||
def write_poc(tmp_path):
|
||||
"""A real package on disk, produced by the shipping writer."""
|
||||
|
||||
def _write(name: str = "finger-display-1.0.0") -> "object":
|
||||
def _write(name: str = "finger-display-1.0.0") -> object:
|
||||
path = tmp_path / f"{name}{PACKAGE_SUFFIX}"
|
||||
write_package(
|
||||
synthetic.poc_scene(),
|
||||
@@ -520,7 +524,9 @@ def test_a_hand_edited_settings_block_is_repaired(block):
|
||||
assert settings.field in scene3d_prefs.FIELD_CHOICES
|
||||
if settings.color_scale is not None:
|
||||
lower, upper = settings.color_scale.lower, settings.color_scale.upper
|
||||
assert lower == lower and upper == upper, "a NaN survived the repair"
|
||||
# Not a tautology: ``float("nan")`` is in the parametrisation above, and this
|
||||
# is the assertion that the repair actually replaced it.
|
||||
assert not math.isnan(lower) and not math.isnan(upper), "a NaN survived the repair"
|
||||
assert upper > lower
|
||||
assert abs(lower) <= scene3d_prefs.MAX_SCALE_BOUND
|
||||
assert abs(upper) <= scene3d_prefs.MAX_SCALE_BOUND
|
||||
@@ -1129,8 +1135,8 @@ def test_every_run_gets_a_new_generation(make_window, qt_app):
|
||||
Without this the pane accepts the first run's frames and refuses every later run's,
|
||||
so the picture stays on the first run's last frame forever.
|
||||
"""
|
||||
from orisys_app_shared.scene3d.contracts import FrameIdentity
|
||||
import ui_check
|
||||
from orisys_app_shared.scene3d.contracts import FrameIdentity
|
||||
|
||||
window = _show(make_window(sensor_type="finger"), (1440, 880))
|
||||
try:
|
||||
|
||||
@@ -10,8 +10,6 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types
|
||||
from orisys_studio import app_window
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ recording coordinator and fake workers.
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_studio import session_controller
|
||||
from orisys_studio.session_controller import SessionController
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_app_shared.force.filters import FilterSettings
|
||||
from orisys_app_shared.record import RecordConfig
|
||||
from orisys_studio import session_metadata as sm
|
||||
@@ -133,7 +132,7 @@ def test_the_session_block_states_the_device_the_filters_and_the_picture():
|
||||
)
|
||||
worker = SimpleNamespace(
|
||||
capture_status=lambda: {"requested_fps": 30.0, "measured_fps": 21.0},
|
||||
filter_state=lambda: {},
|
||||
filter_state=dict,
|
||||
filter_settings=lambda: FilterSettings(),
|
||||
filters_unsupported=lambda: (),
|
||||
)
|
||||
|
||||
@@ -13,7 +13,6 @@ Written against the requirement rather than the implementation:
|
||||
from __future__ import annotations
|
||||
|
||||
import ui_check
|
||||
|
||||
from tests.conftest import finish_run, running_window, show_window
|
||||
|
||||
|
||||
|
||||
@@ -5,14 +5,18 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
import pytest
|
||||
|
||||
import ui_check
|
||||
from tests.conftest import (
|
||||
finish_run as _finish,
|
||||
)
|
||||
from tests.conftest import (
|
||||
running_window as _running_window,
|
||||
)
|
||||
from tests.conftest import (
|
||||
show_window as _show,
|
||||
)
|
||||
from tests.conftest import (
|
||||
visible_descendants,
|
||||
)
|
||||
|
||||
@@ -369,7 +373,6 @@ def test_the_panel_shows_the_numbers_of_the_file_it_exports(make_window, record_
|
||||
from pathlib import Path
|
||||
|
||||
import orisys.calibration as sdk_calibration
|
||||
|
||||
from orisys_app_shared.force.calibration_io import build_force_calibration_v2
|
||||
|
||||
window = _show(make_window(), (1400, 880))
|
||||
@@ -529,8 +532,11 @@ def test_applying_another_devices_calibration_is_flagged(make_window, record_roo
|
||||
"""The apply path states a sensor/config mismatch instead of silently applying."""
|
||||
import json
|
||||
|
||||
from orisys_app_shared.force.calibration_io import build_export_model, write_force_calibration
|
||||
from orisys_app_shared.force.calibration_io import CalSample, CalSessionStore
|
||||
from orisys_app_shared.force.calibration_io import (
|
||||
CalSample,
|
||||
CalSessionStore,
|
||||
build_export_model,
|
||||
)
|
||||
|
||||
window, stuck = _running_window(make_window)
|
||||
store = CalSessionStore()
|
||||
@@ -816,7 +822,6 @@ def test_a_confirmed_calibration_is_snapshotted_into_the_recording(make_window,
|
||||
from pathlib import Path
|
||||
|
||||
import orisys_studio.app_window as app_module
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
window, stuck = _running_window(make_window)
|
||||
app_module.recordings_dir = lambda: record_root
|
||||
@@ -1179,9 +1184,8 @@ def test_the_live_rail_is_a_play_pause_toggle_with_its_own_stop(make_window):
|
||||
icon-only pause button next to it, so the same position meant two different
|
||||
things depending on which view was on screen.
|
||||
"""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
stuck = ui_check._StuckWorker()
|
||||
@@ -1232,7 +1236,6 @@ def test_the_live_rail_is_a_play_pause_toggle_with_its_own_stop(make_window):
|
||||
def test_record_strip_separates_acquiring_from_recording(make_window):
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.record.experiment import RecordStats
|
||||
|
||||
from orisys_studio.ui import record_status
|
||||
|
||||
window, stuck = _running_window(make_window)
|
||||
@@ -1315,9 +1318,8 @@ def test_overview_region_is_the_timeline_control(make_window):
|
||||
|
||||
def test_the_charts_start_in_follow_mode(make_window):
|
||||
"""Every live entry point resumes following; only browsing is a gesture."""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
window, stuck = _running_window(make_window)
|
||||
panel = window.force_plots
|
||||
@@ -1424,9 +1426,8 @@ def test_the_arrow_legend_follows_the_view_mode(make_window):
|
||||
|
||||
def test_the_sensor_summary_opens_the_dialog(make_window):
|
||||
"""The rail states the sensor; the pickers live in the dialog it opens."""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
assert window.toolbar.btn_sensor.text() == t(
|
||||
@@ -1614,9 +1615,8 @@ def test_the_replay_view_is_the_live_view_with_a_video_source(make_window):
|
||||
|
||||
def test_the_replay_view_loads_a_recording_and_scrubs_it(make_window, replay_recording):
|
||||
"""The replay view reads the recording: charts, 2D panel and transport."""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
window.brand_bar.btn_playback.click()
|
||||
@@ -1726,7 +1726,6 @@ def test_the_unit_chip_switches_the_charts_and_the_toolbar(make_window):
|
||||
"""One click on the unit chip changes the unit everywhere, not just locally."""
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_studio.ui.force_plots import unit_display
|
||||
|
||||
from PySide6.QtWidgets import QPushButton
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
@@ -1813,7 +1812,6 @@ def test_the_video_follows_the_sensor_rate_and_keeps_every_row(make_window, reco
|
||||
import csv
|
||||
|
||||
import cv2
|
||||
|
||||
import orisys_studio.app_window as app_module
|
||||
|
||||
window, stuck = _running_window(make_window)
|
||||
@@ -1911,9 +1909,8 @@ def test_the_readout_describes_the_point_the_cursor_is_on(
|
||||
A replay used to state the live acquisition in cards sitting next to a chart
|
||||
of 7.5e5 raw counts, which reads as "the replay never opened".
|
||||
"""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
window.brand_bar.btn_playback.click()
|
||||
@@ -1945,9 +1942,8 @@ def test_a_raw_only_recording_is_never_shown_in_newton(make_window, replay_recor
|
||||
for the axes it calibrated. Asking for newton anyway drew three blank charts
|
||||
and a dash in every card, which looks exactly like "the replay did not load".
|
||||
"""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
window.toolbar.set_units("newton")
|
||||
@@ -2056,9 +2052,8 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
|
||||
there: plain playback and a recompute pass. The tracker reset owns no device here,
|
||||
so it is disabled rather than repurposed.
|
||||
"""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
window.brand_bar.btn_playback.click()
|
||||
@@ -2135,9 +2130,8 @@ def test_escape_stops_the_replay_too(make_window, replay_recording):
|
||||
|
||||
|
||||
def test_the_live_view_keeps_the_tracker_reset(make_window):
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
window.brand_bar.btn_live.click()
|
||||
@@ -2160,9 +2154,8 @@ def test_a_bare_video_plays_even_though_it_is_not_a_recording(make_window, recor
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
bare = Path(record_root) / "bare_video.mp4"
|
||||
writer = cv2.VideoWriter(
|
||||
@@ -2223,15 +2216,14 @@ def test_the_replay_and_the_live_paths_share_one_image_backend(make_window, reco
|
||||
"""
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
import orisys_studio.app_window as app_module
|
||||
from orisys_app_shared.acquisition.image_backend import (
|
||||
DERIVED_MODES,
|
||||
ImageFrame,
|
||||
SOURCE_LIVE,
|
||||
SOURCE_RECOMPUTE,
|
||||
ImageFrame,
|
||||
)
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
bare = Path(record_root) / "backend_video.mp4"
|
||||
writer = cv2.VideoWriter(str(bare), cv2.VideoWriter_fourcc(*"mp4v"), 30.0, (64, 48))
|
||||
@@ -2318,9 +2310,8 @@ def test_the_replay_entries_exist_only_in_the_replay_view(make_window, record_ro
|
||||
"""
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
bare = Path(record_root) / "gated_video.mp4"
|
||||
writer = cv2.VideoWriter(str(bare), cv2.VideoWriter_fourcc(*"mp4v"), 30.0, (64, 48))
|
||||
@@ -2373,8 +2364,6 @@ def test_a_file_that_is_not_a_video_at_all_is_refused_with_a_reason(
|
||||
"""Not a recording *and* not decodable: say what is missing and offer recompute."""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
broken = Path(record_root) / "not_really_a_video.mp4"
|
||||
broken.write_text("this is not a video at all\n", encoding="utf-8")
|
||||
|
||||
@@ -2404,9 +2393,8 @@ def test_a_file_that_is_not_a_video_at_all_is_refused_with_a_reason(
|
||||
def test_a_video_inside_a_recording_opens_the_recording_and_plays(
|
||||
make_window, replay_recording
|
||||
):
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
window.brand_bar.btn_playback.click()
|
||||
@@ -2432,9 +2420,8 @@ def test_a_recording_without_a_video_says_it_cannot_be_played(make_window, recor
|
||||
import csv
|
||||
import json
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
directory = Path(record_root) / "no_video_recording"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
@@ -2531,9 +2518,8 @@ def test_the_status_bar_is_a_notice_line_not_a_state_line(make_window):
|
||||
"""
|
||||
import time
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
import orisys_studio.app_window as app_module
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
assert not hasattr(window, "device_label"), (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user