added ruff test

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